diff --git a/.agents/skills/debug/SKILL.md b/.agents/skills/debug/SKILL.md index 5d0b8f4f9..01782267f 100644 --- a/.agents/skills/debug/SKILL.md +++ b/.agents/skills/debug/SKILL.md @@ -157,10 +157,23 @@ After fixing a bug: - [ ] Test doubles updated if interfaces changed - [ ] Original failure scenario verified end-to-end +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/define/SKILL.md b/.agents/skills/define/SKILL.md index 2fced36d5..c60108c7a 100644 --- a/.agents/skills/define/SKILL.md +++ b/.agents/skills/define/SKILL.md @@ -165,10 +165,23 @@ Before proceeding to `/plan`: - [ ] Impact scope identifies affected modules / surfaces / tests - [ ] User has explicitly approved the Overview +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md index f5a31107e..8095a17ff 100644 --- a/.agents/skills/implement/SKILL.md +++ b/.agents/skills/implement/SKILL.md @@ -125,11 +125,12 @@ feat: rating statistics service **Cycle per Task:** 1. Implement Slice → compile / type-check → pass? continue : fix 2. Repeat until all Slices in the Task are done -3. Run `/self-review` on the Task's changes -4. Run `/verify standard` (unit + arch rules) +3. Run a self-review pass on the Task's changes (the 5-axis check). For a full independent review, stop and recommend `/self-review` as a separate invocation — do not run its full skill body inside `/implement`. +4. Run the compile/type-check and unit-test commands needed for this Task. For full `/verify standard` or `/verify full`, stop and recommend `/verify` as a separate invocation. 5. Commit with descriptive message 6. Update plan document (check off Task, add to Progress Log) -7. Move to next Task +7. **HARD STOP after this Task.** Do NOT start the next Task in the same response turn. Report: completed Task number/title, verification evidence, changed files, next recommended Task. Then wait for the user's next message. + - Exception: proceed to the next Task in the same turn ONLY IF the user explicitly named multiple Tasks for continuous execution in their request (e.g. "do Tasks 1 through 3"). An ambiguous "continue" / "진행하자" is NOT such permission. ### Phase 4: Final Verification @@ -211,6 +212,9 @@ During Slice execution, load ONLY the references for the current Slice's module. - Skipping Phase 0 (Explore) and jumping straight to coding - Multiple unrelated changes in a single Task - Task completed without running `/self-review` +- Starting Task N+1 after completing Task N without explicit user permission for continuous execution +- Treating an ambiguous "continue" as permission to finish all remaining Tasks +- Running `/test`, `/verify`, or `/self-review` as full skill bodies inside `/implement` instead of stopping at that skill boundary ## Verification @@ -225,10 +229,23 @@ After completing all Tasks for a feature: - [ ] Build / package succeeds - [ ] Plan document updated (all Tasks checked, Progress Log filled) +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/implement/references/languages/bottlenote-patterns.md b/.agents/skills/implement/references/languages/bottlenote-patterns.md new file mode 100644 index 000000000..33d07b7a2 --- /dev/null +++ b/.agents/skills/implement/references/languages/bottlenote-patterns.md @@ -0,0 +1,114 @@ +# bottlenote-patterns + +> bottle-note 한정 implement 함정 / 실수 방지 부록. +> 일반 컨벤션은 `plan/conventions.md`, 일반 Java/Spring 패턴은 `java-spring.md` 참조. +> 본 파일은 GSL sync 와 무관한 프로젝트 특화 reference. + +## When to load + +`/implement` Phase 0 에서 `plan/conventions.md` 와 함께 자동 참조. Repository / RestDocs / 이벤트 발행 관련 작업 시 우선 확인. + +--- + +## P4. InMemory 구현체 갱신 누락 (Repository interface 변경 시) + +### 함정 + +Repository interface 에 메서드 추가·시그니처 변경 시, JPA 구현체만 갱신하고 **InMemory 테스트 구현체 갱신을 잊는 경우** — PR #578 rebase 등에서 반복 발생. + +### 점검 경로 (필수) + +```text +bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/InMemory{Domain}Repository.java +bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/InMemory{Domain}Repository.java +``` + +Admin / 외부 모듈에서도 동일 패턴이 추가될 수 있음: +```text +bottlenote-admin-api/src/test/kotlin/.../fixture/InMemory{Domain}Repository.kt +``` + +### 절차 (`/implement` 또는 `/self-review` 단계) + +1. Repository interface diff 의 변경 메서드 시그니처 확인 +2. 위 두 경로에서 `InMemory{Domain}Repository` 검색 +3. 동일 시그니처 구현 추가 (도메인 객체 상태 변경의 단순 모방으로 충분) +4. 해당 fake 를 사용하는 단위 테스트가 컴파일·통과하는지 확인 + +--- + +## P3 흡수. RestDocs / asciidoctor 워크플로우 + +원래 `/docs` 신규 스킬 후보였던 흐름. GSL 9 스킬로 분배: + +### 책임 분리 + +| 단계 | GSL 스킬 | bottle-note 구체 | +|------|---------|------------------| +| RestDocs 테스트 작성 | `/test` | `Rest{Domain}ControllerDocsTest` (product), `Admin{Domain}ControllerDocsTest` (admin) | +| `.adoc` 인덱스·조각 작성 | `/implement` Slice | `bottlenote-{product\|admin}-api/src/docs/asciidoc/**.adoc` | +| asciidoctor 빌드 검증 | `/verify` L3 | `./gradlew asciidoctor` | +| admin default test 검증 | `/verify` L3 | `./gradlew :bottlenote-admin-api:test` | + +### admin default test 케이스 (PR #578 사례) + +- product 의 `Rest*DocsTest` 는 `@Tag("integration")` 로 분리돼 root `test` 에서 제외됨 +- 그러나 **admin 의 `Admin*DocsTest` 는 default `:bottlenote-admin-api:test` 에서 함께 도는 경우 있음** +- 따라서 admin RestDocs 수정 시 `./gradlew :bottlenote-admin-api:test` 도 반드시 확인 (누락 시 PR drift 발생) + +### `/verify full` 권장 명령 시퀀스 + +```bash +./gradlew check_rule_test # baseline rule check +./gradlew unit_test integration_test # 표준 단위·통합 +./gradlew admin_integration_test # admin 통합 +./gradlew :bottlenote-admin-api:test # admin default test (DocsTest 누락 방지) +./gradlew asciidoctor # RestDocs HTML 빌드 검증 +``` + +--- + +## P5. (GSL 참조) `Projections.constructor` 와 local record + +→ 이미 GSL `java-spring.md` Tier 3 trap 에 명시됨. 본 plan 의 SC3 는 GSL 표준으로 충족. +요약: `Projections.constructor()` 인자에 쓰는 record 는 **메서드 본문 안 local record 금지**, 반드시 클래스/인터페이스 레벨 정의. + +--- + +## 추가 함정 + +### publishEvent drift + +- `ApplicationEventPublisher.publishEvent(...)` 호출이 도메인 로직 수정·리팩토링 중 **소리 없이 누락** 되는 경우 +- 검출: `/self-review` Correctness 축에서 "이 변경이 기존 publishEvent 호출을 제거·우회하지 않았는가?" +- 보조: `git diff` 시 `publishEvent` 키워드 grep 으로 누락 여부 확인 + +### Facade ↔ Service 경계 drift + +- Cross-domain 접근은 `{Domain}Facade` 경유 원칙 +- 일부 service 가 facade 도 구현하는 기존 drift 존재 (`conventions.md` Comparison 표 참조) +- 새 작업: facade 분리 권장. 기존 drift 는 plan 에 명시되지 않은 한 정리 대상 아님 + +### `@ThirdPartyService` 사용 시점 + +→ GSL `java-spring.md` External Integration Layer 섹션 참조. bottle-note 에서는 `app.external` 또는 도메인 외부 패키지에 위치. +- 테스트 격리: 외부 client 의 fake (예: `FakeProfanityClient`, `FakeWebhookRestTemplate`) 사용 +- 트랜잭션 불필요, `@Service` 포함하지만 도메인 service 와 의도 명확히 분리 + +### Batch 모듈 특이사항 (`/define` / `/plan` 시 주의) + +`conventions.md` 의 "Batch-Specific Current Conventions" 참조. 핵심만: +- `bottlenote-batch` 는 `testFixtures` 대신 `mono` test output 직접 사용 (drift) +- `git.environment-variables` 가 main+test 양쪽 resources 에 포함됨 +- main resource 에 하드코딩된 JWT secret / nonce salt default 존재 +- batch source test 가 0 (테스트 공백 인지 필요) + +위 항목들은 **알려진 drift** — 새 plan 이 명시적으로 정리하지 않는 한 보존. + +--- + +## Maintenance + +- 항목이 GSL 표준 `java-spring.md` 에 흡수되면 본 파일에서 제거 → 중복 방지 +- 새 함정 발견 시 추가 (PR 사례 인용 권장) +- conventions.md 가 자체 갱신될 때 본 파일과 충돌하는 부분은 conventions.md 우선 diff --git a/.agents/skills/next-flow/SKILL.md b/.agents/skills/next-flow/SKILL.md index 0b3dd578d..824a60073 100644 --- a/.agents/skills/next-flow/SKILL.md +++ b/.agents/skills/next-flow/SKILL.md @@ -28,7 +28,7 @@ Hard policy (from codex consultation): *auto-progression is restricted to read-o - Before `/define` (no plan document yet — start with `/define` directly) - When a `/debug` recovery is in progress (let `/debug` finish first) - When the user has already named the next step explicitly -- For `/plan` transition — `/plan` is a Claude Code UI command and cannot be auto-invoked from a skill; this skill will only print a notice telling the user to type `/plan` themselves +- For any next GSL command transition — print the command name and stop. In slash-command UI runtimes, tell the user to type that command directly; in codex, tell the user to send a new message containing that skill name. ## Process @@ -45,7 +45,7 @@ Read Overview, Tasks, Progress Log. Classify the work into exactly one of: | Signal | Current step | Next step | |--------|--------------|-----------| -| Plan doc has Overview only, no Tasks | post-`/define` | `/plan` (UI command — print notice) | +| Plan doc has Overview only, no Tasks | post-`/define` | `/plan` (print command and stop) | | Tasks exist, Progress Log empty | post-`/plan` | `/implement` | | Some Tasks done, more remain | mid-`/implement` | continue `/implement` with next Task | | All Tasks done, no integration tests written | post-`/implement` | `/test` (integration tests) | @@ -97,10 +97,10 @@ Preconditions: PASS [Run it? You can type / yourself, or reply "yes" to have me run it only if it is read-only.] ``` -For `/plan` and other UI commands, replace the last line with: +For any next GSL command, do not run it in this turn. Replace the last line with runtime-neutral guidance: ``` -/plan is a Claude Code UI command — please type it in the input box yourself; I cannot trigger it from a skill. +To continue, send a new message containing this command. In slash-command UI runtimes, type the command directly; in codex, send the skill name in a new message. ``` ## Common Rationalizations @@ -111,16 +111,16 @@ For `/plan` and other UI commands, replace the last line with: | "User said 'continue', so I can just push through commit" | Commit is a write boundary and an externally-visible action. Propose, never auto-run. | | "It's only a small edit to plan doc Progress Log" | Plan document is the single source of truth. Only `/implement` updates it after a Task commit. | | "/verify full passed — let me start the next feature" | Closing the current feature (stamp + plan/complete/ move) is a manual decision. Propose, don't act. | -| "I'll auto-call /plan since /implement needs it" | `/plan` is a UI command. You cannot. Print the notice and stop. | +| "I'll auto-call the next GSL command since it is obvious" | Next GSL commands are runtime boundaries. Print the command name and stop. | ## Red Flags - Auto-progression has caused any file to be created, edited, or deleted (immediate STOP — this skill should never do that) - Multiple next-step candidates proposed (the table in Step 2 should yield exactly one) - Preconditions skipped because "the user implied it was fine" -- Proposing `/plan` for auto-invocation (impossible — print notice instead) -- **Bypassing the UI-command notice by invoking `/plan` (or any other Claude Code UI command) through Bash, shell scripts, `subprocess`, MCP tools, or other wrappers — anti-pattern, STOP.** The notice is the only correct output for UI-command transitions. -- The next command requires user approval (e.g., post-`/define` → `/plan`) and you tried to invoke it anyway +- Proposing any next GSL command for same-turn execution instead of printing the command name and stopping +- **Bypassing the runtime-boundary notice by invoking the next GSL command through Bash, shell scripts, `subprocess`, MCP tools, or other wrappers — anti-pattern, STOP.** The notice is the only correct output for GSL command transitions. +- The next command requires user approval (e.g., post-`/define` → `/plan`) and you tried to execute it anyway ## Verification @@ -130,5 +130,18 @@ After running: - [ ] All preconditions were checked in a read-only way - [ ] If any auto-action was run, it was read-only only (verify / status / log / diff) - [ ] No file was created, edited, or deleted by this skill -- [ ] If the next step is `/plan`, the UI-command notice was printed instead of attempting auto-invoke +- [ ] If the next step is a GSL command, the runtime-boundary notice was printed instead of attempting same-turn execution - [ ] The proposal includes the reason and the user's explicit invocation prompt + +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 44f01d8b5..821f19007 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -180,10 +180,23 @@ Before starting `/implement`, confirm: - [ ] Checkpoints exist between major groups of Tasks - [ ] User has explicitly approved the task list +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/scan-conventions/SKILL.md b/.agents/skills/scan-conventions/SKILL.md index 84d09de1e..4529c7980 100644 --- a/.agents/skills/scan-conventions/SKILL.md +++ b/.agents/skills/scan-conventions/SKILL.md @@ -214,3 +214,16 @@ After running: - [ ] All CONFLICTs have a recorded user resolution - [ ] No source files were modified - [ ] Report printed to user with counts (MATCH / REFINEMENT / CONFLICT) + +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. diff --git a/.agents/skills/self-review/SKILL.md b/.agents/skills/self-review/SKILL.md index b6c0059bd..b2e0730e2 100644 --- a/.agents/skills/self-review/SKILL.md +++ b/.agents/skills/self-review/SKILL.md @@ -154,10 +154,23 @@ After completing the review: - [ ] Code compiles / type-checks (see `/verify` quick) - [ ] Unit tests pass (see `/verify` standard) +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/test/SKILL.md b/.agents/skills/test/SKILL.md index bd79a3ca8..cd842adfd 100644 --- a/.agents/skills/test/SKILL.md +++ b/.agents/skills/test/SKILL.md @@ -33,10 +33,11 @@ Write tests that prove code works. This skill guides you through creating unit t ## Relationship with `/implement` -- **Unit tests**: ideally written together with implementation during a Task in `/implement` -- **Integration tests**: written after all Tasks are implemented, via separate `/test` invocation -- **Docs tests** (API contract docs): only when user explicitly requests -- Slice-level compile checks in `/implement` may RUN existing tests; this skill WRITES new tests +- **Unit tests**: may be written together with implementation during a Task in `/implement` — writing test code alongside a Task is allowed. +- **NOT allowed**: running the full `/test` workflow (Phase 0 explore, Phase 1 scenario-approval gate, Phase 2-4) inside `/implement`. The full `/test` skill is a separate runtime boundary and needs its own explicit invocation. +- **Integration tests**: written after all Tasks are implemented, via a separate `/test` invocation. +- **Docs tests** (API contract docs): only when the user explicitly requests. +- Slice-level compile checks in `/implement` may RUN existing tests; the full `/test` skill WRITES new tests through its scenario-approval process. ## Test Types and Timing @@ -193,10 +194,23 @@ After completing test implementation: - [ ] All tests pass: `/verify` at appropriate level - [ ] Test doubles updated if domain interfaces changed +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md index 12f4eb8f4..755ad26df 100644 --- a/.agents/skills/verify/SKILL.md +++ b/.agents/skills/verify/SKILL.md @@ -135,10 +135,23 @@ After running: - [ ] On failure: last 30 lines of output shown, remaining steps marked SKIPPED - [ ] Total time reported +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 5d0b8f4f9..01782267f 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -157,10 +157,23 @@ After fixing a bug: - [ ] Test doubles updated if interfaces changed - [ ] Original failure scenario verified end-to-end +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/define/SKILL.md b/.claude/skills/define/SKILL.md index 2fced36d5..c60108c7a 100644 --- a/.claude/skills/define/SKILL.md +++ b/.claude/skills/define/SKILL.md @@ -165,10 +165,23 @@ Before proceeding to `/plan`: - [ ] Impact scope identifies affected modules / surfaces / tests - [ ] User has explicitly approved the Overview +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index f5a31107e..8095a17ff 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -125,11 +125,12 @@ feat: rating statistics service **Cycle per Task:** 1. Implement Slice → compile / type-check → pass? continue : fix 2. Repeat until all Slices in the Task are done -3. Run `/self-review` on the Task's changes -4. Run `/verify standard` (unit + arch rules) +3. Run a self-review pass on the Task's changes (the 5-axis check). For a full independent review, stop and recommend `/self-review` as a separate invocation — do not run its full skill body inside `/implement`. +4. Run the compile/type-check and unit-test commands needed for this Task. For full `/verify standard` or `/verify full`, stop and recommend `/verify` as a separate invocation. 5. Commit with descriptive message 6. Update plan document (check off Task, add to Progress Log) -7. Move to next Task +7. **HARD STOP after this Task.** Do NOT start the next Task in the same response turn. Report: completed Task number/title, verification evidence, changed files, next recommended Task. Then wait for the user's next message. + - Exception: proceed to the next Task in the same turn ONLY IF the user explicitly named multiple Tasks for continuous execution in their request (e.g. "do Tasks 1 through 3"). An ambiguous "continue" / "진행하자" is NOT such permission. ### Phase 4: Final Verification @@ -211,6 +212,9 @@ During Slice execution, load ONLY the references for the current Slice's module. - Skipping Phase 0 (Explore) and jumping straight to coding - Multiple unrelated changes in a single Task - Task completed without running `/self-review` +- Starting Task N+1 after completing Task N without explicit user permission for continuous execution +- Treating an ambiguous "continue" as permission to finish all remaining Tasks +- Running `/test`, `/verify`, or `/self-review` as full skill bodies inside `/implement` instead of stopping at that skill boundary ## Verification @@ -225,10 +229,23 @@ After completing all Tasks for a feature: - [ ] Build / package succeeds - [ ] Plan document updated (all Tasks checked, Progress Log filled) +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/implement/references/languages/bottlenote-patterns.md b/.claude/skills/implement/references/languages/bottlenote-patterns.md new file mode 100644 index 000000000..33d07b7a2 --- /dev/null +++ b/.claude/skills/implement/references/languages/bottlenote-patterns.md @@ -0,0 +1,114 @@ +# bottlenote-patterns + +> bottle-note 한정 implement 함정 / 실수 방지 부록. +> 일반 컨벤션은 `plan/conventions.md`, 일반 Java/Spring 패턴은 `java-spring.md` 참조. +> 본 파일은 GSL sync 와 무관한 프로젝트 특화 reference. + +## When to load + +`/implement` Phase 0 에서 `plan/conventions.md` 와 함께 자동 참조. Repository / RestDocs / 이벤트 발행 관련 작업 시 우선 확인. + +--- + +## P4. InMemory 구현체 갱신 누락 (Repository interface 변경 시) + +### 함정 + +Repository interface 에 메서드 추가·시그니처 변경 시, JPA 구현체만 갱신하고 **InMemory 테스트 구현체 갱신을 잊는 경우** — PR #578 rebase 등에서 반복 발생. + +### 점검 경로 (필수) + +```text +bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/InMemory{Domain}Repository.java +bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/InMemory{Domain}Repository.java +``` + +Admin / 외부 모듈에서도 동일 패턴이 추가될 수 있음: +```text +bottlenote-admin-api/src/test/kotlin/.../fixture/InMemory{Domain}Repository.kt +``` + +### 절차 (`/implement` 또는 `/self-review` 단계) + +1. Repository interface diff 의 변경 메서드 시그니처 확인 +2. 위 두 경로에서 `InMemory{Domain}Repository` 검색 +3. 동일 시그니처 구현 추가 (도메인 객체 상태 변경의 단순 모방으로 충분) +4. 해당 fake 를 사용하는 단위 테스트가 컴파일·통과하는지 확인 + +--- + +## P3 흡수. RestDocs / asciidoctor 워크플로우 + +원래 `/docs` 신규 스킬 후보였던 흐름. GSL 9 스킬로 분배: + +### 책임 분리 + +| 단계 | GSL 스킬 | bottle-note 구체 | +|------|---------|------------------| +| RestDocs 테스트 작성 | `/test` | `Rest{Domain}ControllerDocsTest` (product), `Admin{Domain}ControllerDocsTest` (admin) | +| `.adoc` 인덱스·조각 작성 | `/implement` Slice | `bottlenote-{product\|admin}-api/src/docs/asciidoc/**.adoc` | +| asciidoctor 빌드 검증 | `/verify` L3 | `./gradlew asciidoctor` | +| admin default test 검증 | `/verify` L3 | `./gradlew :bottlenote-admin-api:test` | + +### admin default test 케이스 (PR #578 사례) + +- product 의 `Rest*DocsTest` 는 `@Tag("integration")` 로 분리돼 root `test` 에서 제외됨 +- 그러나 **admin 의 `Admin*DocsTest` 는 default `:bottlenote-admin-api:test` 에서 함께 도는 경우 있음** +- 따라서 admin RestDocs 수정 시 `./gradlew :bottlenote-admin-api:test` 도 반드시 확인 (누락 시 PR drift 발생) + +### `/verify full` 권장 명령 시퀀스 + +```bash +./gradlew check_rule_test # baseline rule check +./gradlew unit_test integration_test # 표준 단위·통합 +./gradlew admin_integration_test # admin 통합 +./gradlew :bottlenote-admin-api:test # admin default test (DocsTest 누락 방지) +./gradlew asciidoctor # RestDocs HTML 빌드 검증 +``` + +--- + +## P5. (GSL 참조) `Projections.constructor` 와 local record + +→ 이미 GSL `java-spring.md` Tier 3 trap 에 명시됨. 본 plan 의 SC3 는 GSL 표준으로 충족. +요약: `Projections.constructor()` 인자에 쓰는 record 는 **메서드 본문 안 local record 금지**, 반드시 클래스/인터페이스 레벨 정의. + +--- + +## 추가 함정 + +### publishEvent drift + +- `ApplicationEventPublisher.publishEvent(...)` 호출이 도메인 로직 수정·리팩토링 중 **소리 없이 누락** 되는 경우 +- 검출: `/self-review` Correctness 축에서 "이 변경이 기존 publishEvent 호출을 제거·우회하지 않았는가?" +- 보조: `git diff` 시 `publishEvent` 키워드 grep 으로 누락 여부 확인 + +### Facade ↔ Service 경계 drift + +- Cross-domain 접근은 `{Domain}Facade` 경유 원칙 +- 일부 service 가 facade 도 구현하는 기존 drift 존재 (`conventions.md` Comparison 표 참조) +- 새 작업: facade 분리 권장. 기존 drift 는 plan 에 명시되지 않은 한 정리 대상 아님 + +### `@ThirdPartyService` 사용 시점 + +→ GSL `java-spring.md` External Integration Layer 섹션 참조. bottle-note 에서는 `app.external` 또는 도메인 외부 패키지에 위치. +- 테스트 격리: 외부 client 의 fake (예: `FakeProfanityClient`, `FakeWebhookRestTemplate`) 사용 +- 트랜잭션 불필요, `@Service` 포함하지만 도메인 service 와 의도 명확히 분리 + +### Batch 모듈 특이사항 (`/define` / `/plan` 시 주의) + +`conventions.md` 의 "Batch-Specific Current Conventions" 참조. 핵심만: +- `bottlenote-batch` 는 `testFixtures` 대신 `mono` test output 직접 사용 (drift) +- `git.environment-variables` 가 main+test 양쪽 resources 에 포함됨 +- main resource 에 하드코딩된 JWT secret / nonce salt default 존재 +- batch source test 가 0 (테스트 공백 인지 필요) + +위 항목들은 **알려진 drift** — 새 plan 이 명시적으로 정리하지 않는 한 보존. + +--- + +## Maintenance + +- 항목이 GSL 표준 `java-spring.md` 에 흡수되면 본 파일에서 제거 → 중복 방지 +- 새 함정 발견 시 추가 (PR 사례 인용 권장) +- conventions.md 가 자체 갱신될 때 본 파일과 충돌하는 부분은 conventions.md 우선 diff --git a/.claude/skills/next-flow/SKILL.md b/.claude/skills/next-flow/SKILL.md index 0b3dd578d..824a60073 100644 --- a/.claude/skills/next-flow/SKILL.md +++ b/.claude/skills/next-flow/SKILL.md @@ -28,7 +28,7 @@ Hard policy (from codex consultation): *auto-progression is restricted to read-o - Before `/define` (no plan document yet — start with `/define` directly) - When a `/debug` recovery is in progress (let `/debug` finish first) - When the user has already named the next step explicitly -- For `/plan` transition — `/plan` is a Claude Code UI command and cannot be auto-invoked from a skill; this skill will only print a notice telling the user to type `/plan` themselves +- For any next GSL command transition — print the command name and stop. In slash-command UI runtimes, tell the user to type that command directly; in codex, tell the user to send a new message containing that skill name. ## Process @@ -45,7 +45,7 @@ Read Overview, Tasks, Progress Log. Classify the work into exactly one of: | Signal | Current step | Next step | |--------|--------------|-----------| -| Plan doc has Overview only, no Tasks | post-`/define` | `/plan` (UI command — print notice) | +| Plan doc has Overview only, no Tasks | post-`/define` | `/plan` (print command and stop) | | Tasks exist, Progress Log empty | post-`/plan` | `/implement` | | Some Tasks done, more remain | mid-`/implement` | continue `/implement` with next Task | | All Tasks done, no integration tests written | post-`/implement` | `/test` (integration tests) | @@ -97,10 +97,10 @@ Preconditions: PASS [Run it? You can type / yourself, or reply "yes" to have me run it only if it is read-only.] ``` -For `/plan` and other UI commands, replace the last line with: +For any next GSL command, do not run it in this turn. Replace the last line with runtime-neutral guidance: ``` -/plan is a Claude Code UI command — please type it in the input box yourself; I cannot trigger it from a skill. +To continue, send a new message containing this command. In slash-command UI runtimes, type the command directly; in codex, send the skill name in a new message. ``` ## Common Rationalizations @@ -111,16 +111,16 @@ For `/plan` and other UI commands, replace the last line with: | "User said 'continue', so I can just push through commit" | Commit is a write boundary and an externally-visible action. Propose, never auto-run. | | "It's only a small edit to plan doc Progress Log" | Plan document is the single source of truth. Only `/implement` updates it after a Task commit. | | "/verify full passed — let me start the next feature" | Closing the current feature (stamp + plan/complete/ move) is a manual decision. Propose, don't act. | -| "I'll auto-call /plan since /implement needs it" | `/plan` is a UI command. You cannot. Print the notice and stop. | +| "I'll auto-call the next GSL command since it is obvious" | Next GSL commands are runtime boundaries. Print the command name and stop. | ## Red Flags - Auto-progression has caused any file to be created, edited, or deleted (immediate STOP — this skill should never do that) - Multiple next-step candidates proposed (the table in Step 2 should yield exactly one) - Preconditions skipped because "the user implied it was fine" -- Proposing `/plan` for auto-invocation (impossible — print notice instead) -- **Bypassing the UI-command notice by invoking `/plan` (or any other Claude Code UI command) through Bash, shell scripts, `subprocess`, MCP tools, or other wrappers — anti-pattern, STOP.** The notice is the only correct output for UI-command transitions. -- The next command requires user approval (e.g., post-`/define` → `/plan`) and you tried to invoke it anyway +- Proposing any next GSL command for same-turn execution instead of printing the command name and stopping +- **Bypassing the runtime-boundary notice by invoking the next GSL command through Bash, shell scripts, `subprocess`, MCP tools, or other wrappers — anti-pattern, STOP.** The notice is the only correct output for GSL command transitions. +- The next command requires user approval (e.g., post-`/define` → `/plan`) and you tried to execute it anyway ## Verification @@ -130,5 +130,18 @@ After running: - [ ] All preconditions were checked in a read-only way - [ ] If any auto-action was run, it was read-only only (verify / status / log / diff) - [ ] No file was created, edited, or deleted by this skill -- [ ] If the next step is `/plan`, the UI-command notice was printed instead of attempting auto-invoke +- [ ] If the next step is a GSL command, the runtime-boundary notice was printed instead of attempting same-turn execution - [ ] The proposal includes the reason and the user's explicit invocation prompt + +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md index 44f01d8b5..821f19007 100644 --- a/.claude/skills/plan/SKILL.md +++ b/.claude/skills/plan/SKILL.md @@ -180,10 +180,23 @@ Before starting `/implement`, confirm: - [ ] Checkpoints exist between major groups of Tasks - [ ] User has explicitly approved the task list +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/scan-conventions/SKILL.md b/.claude/skills/scan-conventions/SKILL.md index 84d09de1e..4529c7980 100644 --- a/.claude/skills/scan-conventions/SKILL.md +++ b/.claude/skills/scan-conventions/SKILL.md @@ -214,3 +214,16 @@ After running: - [ ] All CONFLICTs have a recorded user resolution - [ ] No source files were modified - [ ] Report printed to user with counts (MATCH / REFINEMENT / CONFLICT) + +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. diff --git a/.claude/skills/self-review/SKILL.md b/.claude/skills/self-review/SKILL.md index b6c0059bd..b2e0730e2 100644 --- a/.claude/skills/self-review/SKILL.md +++ b/.claude/skills/self-review/SKILL.md @@ -154,10 +154,23 @@ After completing the review: - [ ] Code compiles / type-checks (see `/verify` quick) - [ ] Unit tests pass (see `/verify` standard) +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index bd79a3ca8..cd842adfd 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -33,10 +33,11 @@ Write tests that prove code works. This skill guides you through creating unit t ## Relationship with `/implement` -- **Unit tests**: ideally written together with implementation during a Task in `/implement` -- **Integration tests**: written after all Tasks are implemented, via separate `/test` invocation -- **Docs tests** (API contract docs): only when user explicitly requests -- Slice-level compile checks in `/implement` may RUN existing tests; this skill WRITES new tests +- **Unit tests**: may be written together with implementation during a Task in `/implement` — writing test code alongside a Task is allowed. +- **NOT allowed**: running the full `/test` workflow (Phase 0 explore, Phase 1 scenario-approval gate, Phase 2-4) inside `/implement`. The full `/test` skill is a separate runtime boundary and needs its own explicit invocation. +- **Integration tests**: written after all Tasks are implemented, via a separate `/test` invocation. +- **Docs tests** (API contract docs): only when the user explicitly requests. +- Slice-level compile checks in `/implement` may RUN existing tests; the full `/test` skill WRITES new tests through its scenario-approval process. ## Test Types and Timing @@ -193,10 +194,23 @@ After completing test implementation: - [ ] All tests pass: `/verify` at appropriate level - [ ] Test doubles updated if domain interfaces changed +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 12f4eb8f4..755ad26df 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -135,10 +135,23 @@ After running: - [ ] On failure: last 30 lines of output shown, remaining steps marked SKIPPED - [ ] Total time reported +## Runtime Boundary — HARD STOP + +This skill ENDS after the Verification checklist and final report are completed. + +For codex and any runtime without an enforced skill-return boundary: +- MUST stop the assistant turn here. +- MUST NOT invoke, load, or execute any next GSL skill in the same response turn. +- MUST NOT continue into `/next-flow`, `/define`, `/plan`, `/implement`, `/test`, `/verify`, `/debug`, or `/self-review`. +- MAY print exactly one suggested next command as plain text. +- MUST wait for the user's next message before running any next skill. + +If the user says only "continue", treat that as permission to report the next recommended command, not permission to execute it. + --- ## Lifecycle Integration **Before this skill:** if `plan/conventions.md` does not exist, run `/scan-conventions` first — analysis relies on knowing the project's actual conventions (naming, layering, test patterns, build system). -**After this skill:** invoke `/next-flow` to diagnose lifecycle state and propose the next command. `/next-flow` auto-progresses read-only verification only and never writes files. Note: `/plan` is a Claude Code UI command and cannot be auto-invoked — the user must type it themselves; `/next-flow` will print a notice in that case. +**After this skill:** the next GSL skill is started by the user, not by this skill — see the Runtime Boundary section above. `/next-flow` may be suggested for lifecycle diagnosis but is not auto-invoked. Runtime note: some environments expose slash commands as UI commands; codex loads GSL skills from `.agents/skills/`. In both cases, the next GSL skill requires a new explicit user message. diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index ea3b36186..5c9b4245f 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -8,6 +8,7 @@ on: - 'bottlenote-*/src/test/java/**/docs/**' - 'bottlenote-*/src/test/kotlin/**/docs/**' - 'bottlenote-*/build.gradle*' + - 'build.gradle' - 'gradle/libs.versions.toml' - 'docs/**' - '.github/workflows/github-pages.yml' @@ -38,8 +39,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - - name: Generate REST Docs snippets - run: ./gradlew restDocsTest + - name: Generate and assemble REST Docs + run: ./gradlew docs_test - name: Setup Node.js uses: actions/setup-node@v4 @@ -89,4 +90,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index c8bd096a6..5f7507780 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,9 @@ docs/modules/admin-api/pages/ docs/modules/admin-api/examples/ docs/_site/ +# Local static demo workspace +.example/ + # 기존 Jekyll 빌드 결과물 (Antora 전환 후 삭제 예정) docs/admin-api.html docs/index.html diff --git a/AGENTS.md b/AGENTS.md index 95195562f..39b11c5fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -317,3 +317,15 @@ Use these skills to follow the structured development lifecycle: - 의존성 주입: 생성자 주입 우선 - 불변성 지향: `final` 필드, `record` 활용 - 단일 책임 원칙: 클래스와 메서드 역할 명확화 + +## GSL on codex — Runtime Boundary Rules + +When using GSL skills from `.agents/skills/`, treat each `SKILL.md` as a single-turn procedure. + +- Load only the one GSL skill that matches the user's current explicit request. +- Do not pre-load all 9 GSL skills unless the user explicitly asks for a read-only diagnosis of the skillset. +- A GSL skill boundary is a hard stop. When a skill reaches its Verification / Runtime Boundary section, end the assistant turn. +- `After this skill`, `Next: /...`, or `should invoke` means "suggest the next command", not "execute it now". +- Do not transition between GSL skills (`/define`->`/plan`, `/plan`->`/implement`, `/implement` Task N->N+1, `/implement`->`/test`, `/test`->`/verify`, etc.) in the same assistant turn unless the user explicitly named that next skill — or named multiple Tasks for continuous execution — in their message. +- `/implement` may write test code alongside a Task, but must NOT run the full `/test` / `/verify` / `/self-review` workflow inside itself. Those are separate skill boundaries. +- If the user says "continue" ambiguously, ask whether to run the next suggested GSL skill or only report the next command. diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 65087b4f5..0664a8fd2 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.1.4 +1.1.6 diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index b04d1bcb6..714624624 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -79,7 +79,7 @@ tasks.jar { tasks.asciidoctor { inputs.dir(snippetsDir) configurations(asciidoctorExt.name) - dependsOn(tasks.test) + dependsOn(tasks.named("restDocsTest")) sources { include("**/admin-api.adoc") } diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 53cef55a7..8ab95a103 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -70,12 +70,22 @@ include::api/admin-curations/curations.adoc[] ''' +include::api/admin-curations/spec-based-curations.adoc[] + +''' + == Users API include::api/admin-users/users.adoc[] ''' +== Review API + +include::admin-review.adoc[] + +''' + == Banner API include::api/admin-banners/banners.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-review.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-review.adoc new file mode 100644 index 000000000..174f82809 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-review.adoc @@ -0,0 +1,28 @@ +=== 리뷰 목록 조회 === + +- 리뷰 목록을 페이지네이션으로 조회합니다. +- 술, 작성자, 활성 상태, 노출 상태, 키워드, 작성일시 범위로 필터링할 수 있습니다. +- `sortType`은 정렬 기준을, `sortOrder`는 선택한 기준의 정렬 방향을 의미합니다. +- 기본 정렬은 작성일시 기준 내림차순입니다. + +[source] +---- +GET /admin/api/v1/reviews +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/reviews/list/query-parameters.adoc[] +include::api/common/enums/admin-review-sort-type.adoc[] +include::api/common/enums/sort-order.adoc[] +include::{snippets}/admin/reviews/list/curl-request.adoc[] +include::{snippets}/admin/reviews/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/reviews/list/response-fields.adoc[] +include::{snippets}/admin/reviews/list/http-response.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc new file mode 100644 index 000000000..518c10d49 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc @@ -0,0 +1,134 @@ +=== 큐레이션 스펙 목록 조회 === + +- 리소스 OpenAPI spec에서 동기화된 큐레이션 스펙 목록을 조회합니다. +- 큐레이션 생성/수정 payload는 이 스펙의 requestSpec 기준으로 검증됩니다. + +[source] +---- +GET /admin/api/v2/curation-specs +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curation-specs/list/curl-request.adoc[] +include::{snippets}/admin/v2/curation-specs/list/http-request.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curation-specs/list/response-body.adoc[] + +''' + +=== 큐레이션 스펙 상세 조회 === + +- 특정 큐레이션 스펙의 requestSpec, responseSpec 상세를 조회합니다. + +[source] +---- +GET /admin/api/v2/curation-specs/{specId} +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curation-specs/detail/curl-request.adoc[] +include::{snippets}/admin/v2/curation-specs/detail/http-request.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curation-specs/detail/response-body.adoc[] + +''' + +=== Spec 기반 큐레이션 목록 조회 === + +- spec 기반 큐레이션 목록을 페이지네이션으로 조회합니다. +- 키워드와 활성 상태 조건으로 필터링할 수 있습니다. + +[source] +---- +GET /admin/api/v2/curations +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curations/list/curl-request.adoc[] +include::{snippets}/admin/v2/curations/list/http-request.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curations/list/response-body.adoc[] + +''' + +=== Spec 기반 큐레이션 상세 조회 === + +- 특정 spec 기반 큐레이션의 메타 정보, 스펙, 원본 payload를 조회합니다. + +[source] +---- +GET /admin/api/v2/curations/{curationId} +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curations/detail/curl-request.adoc[] +include::{snippets}/admin/v2/curations/detail/http-request.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curations/detail/response-body.adoc[] + +''' + +=== Spec 기반 큐레이션 생성 === + +- 선택한 spec의 requestSpec 기준으로 payload를 검증한 뒤 큐레이션을 생성합니다. + +[source] +---- +POST /admin/api/v2/curations +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curations/create/curl-request.adoc[] +include::{snippets}/admin/v2/curations/create/http-request.adoc[] +include::{snippets}/admin/v2/curations/create/request-body.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curations/create/response-body.adoc[] + +''' + +=== Spec 기반 큐레이션 수정 === + +- 기존 spec 기반 큐레이션의 메타 정보와 payload를 수정합니다. +- 수정 payload도 requestSpec 기준으로 검증됩니다. + +[source] +---- +PUT /admin/api/v2/curations/{curationId} +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/admin/v2/curations/update/curl-request.adoc[] +include::{snippets}/admin/v2/curations/update/http-request.adoc[] +include::{snippets}/admin/v2/curations/update/request-body.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/admin/v2/curations/update/response-body.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-review-sort-type.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-review-sort-type.adoc new file mode 100644 index 000000000..4306714b0 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-review-sort-type.adoc @@ -0,0 +1,10 @@ +[discrete] +==== AdminReviewSortType (관리자 리뷰 정렬 기준) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`CREATED_AT` |리뷰 작성일시 기준 정렬 +|`REPLY_COUNT` |리뷰 댓글 수 기준 정렬 +|`UPDATED_AT` |리뷰 최종 수정일시 기준 정렬 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/sort-order.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/sort-order.adoc new file mode 100644 index 000000000..56cefe8e9 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/sort-order.adoc @@ -0,0 +1,9 @@ +[discrete] +==== SortOrder (정렬 방향) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`DESC` |내림차순 정렬 +|`ASC` |오름차순 정렬 +|=== diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt new file mode 100644 index 000000000..3d2f5d4f1 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt @@ -0,0 +1,32 @@ +package app.bottlenote.curation.config + +import app.bottlenote.curation.service.CurationSpecResourceSyncService +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "curation.spec-sync", + name = ["enabled"], + havingValue = "true", + matchIfMissing = true +) +class CurationSpecResourceSyncRunner( + private val curationSpecResourceSyncService: CurationSpecResourceSyncService +) { + private val log = LoggerFactory.getLogger(javaClass) + + @EventListener(ApplicationReadyEvent::class) + fun sync() { + val result = curationSpecResourceSyncService.sync() + log.info( + "큐레이션 스펙 리소스 동기화 완료: created={}, updated={}, total={}", + result.createdCount(), + result.updatedCount(), + result.totalCount() + ) + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminCurationSpecController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminCurationSpecController.kt new file mode 100644 index 000000000..309163f93 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminCurationSpecController.kt @@ -0,0 +1,23 @@ +package app.bottlenote.curation.presentation + +import app.bottlenote.curation.service.AdminSpecBasedCurationService +import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v2/curation-specs") +class AdminCurationSpecController( + private val adminSpecBasedCurationService: AdminSpecBasedCurationService +) { + @GetMapping + fun list(): ResponseEntity<*> = GlobalResponse.ok(adminSpecBasedCurationService.listSpecs()) + + @GetMapping("/{specId}") + fun detail( + @PathVariable specId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminSpecBasedCurationService.getSpecDetail(specId)) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminSpecBasedCurationController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminSpecBasedCurationController.kt new file mode 100644 index 000000000..b8f988b8f --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminSpecBasedCurationController.kt @@ -0,0 +1,44 @@ +package app.bottlenote.curation.presentation + +import app.bottlenote.curation.dto.request.CurationCreateRequest +import app.bottlenote.curation.dto.request.CurationSearchRequest +import app.bottlenote.curation.dto.request.CurationUpdateRequest +import app.bottlenote.curation.service.AdminSpecBasedCurationService +import app.bottlenote.global.data.response.GlobalResponse +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v2/curations") +class AdminSpecBasedCurationController( + private val adminSpecBasedCurationService: AdminSpecBasedCurationService +) { + @GetMapping + fun list( + @ModelAttribute request: CurationSearchRequest + ): ResponseEntity = ResponseEntity.ok(adminSpecBasedCurationService.search(request)) + + @GetMapping("/{curationId}") + fun detail( + @PathVariable curationId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminSpecBasedCurationService.getDetail(curationId)) + + @PostMapping + fun create( + @RequestBody @Valid request: CurationCreateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminSpecBasedCurationService.create(request)) + + @PutMapping("/{curationId}") + fun update( + @PathVariable curationId: Long, + @RequestBody @Valid request: CurationUpdateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminSpecBasedCurationService.update(curationId, request)) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/review/presentation/AdminReviewController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/review/presentation/AdminReviewController.kt new file mode 100644 index 000000000..5627481e6 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/review/presentation/AdminReviewController.kt @@ -0,0 +1,22 @@ +package app.bottlenote.review.presentation + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.review.dto.request.AdminReviewSearchRequest +import app.bottlenote.review.service.AdminReviewQueryService +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/reviews") +class AdminReviewController( + private val adminReviewQueryService: AdminReviewQueryService +) { + @GetMapping + fun list( + @Valid @ModelAttribute request: AdminReviewSearchRequest + ): ResponseEntity = ResponseEntity.ok(adminReviewQueryService.searchReviews(request)) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/config/AdminApiVersionConfig.kt b/bottlenote-admin-api/src/main/kotlin/app/global/config/AdminApiVersionConfig.kt new file mode 100644 index 000000000..dff9bf084 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/global/config/AdminApiVersionConfig.kt @@ -0,0 +1,28 @@ +package app.global.config + +import app.bottlenote.curation.presentation.AdminCurationSpecController +import app.bottlenote.curation.presentation.AdminSpecBasedCurationController +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class AdminApiVersionConfig : WebMvcConfigurer { + override fun configurePathMatch(configurer: PathMatchConfigurer) { + configurer.addPathPrefix("/v1") { controllerType -> + isLegacyAdminController(controllerType) + } + } + + private fun isLegacyAdminController(controllerType: Class<*>): Boolean = controllerType.packageName.startsWith("app.bottlenote") && + controllerType.packageName.contains(".presentation") && + controllerType.simpleName.endsWith("Controller") && + !excludedControllers.contains(controllerType) + + private companion object { + private val excludedControllers = setOf( + AdminCurationSpecController::class.java, + AdminSpecBasedCurationController::class.java + ) + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt index dba37f817..11ea4cee8 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt @@ -31,7 +31,7 @@ class SecurityConfig( auth .requestMatchers(*MaliciousPathPattern.getAllPatterns()) .denyAll() - .requestMatchers("/auth/login", "/auth/refresh") + .requestMatchers("/v1/auth/login", "/v1/auth/refresh") .permitAll() .requestMatchers("/actuator/**") .permitAll() diff --git a/bottlenote-admin-api/src/main/resources/application.yml b/bottlenote-admin-api/src/main/resources/application.yml index 49e516919..0c8fd9ede 100644 --- a/bottlenote-admin-api/src/main/resources/application.yml +++ b/bottlenote-admin-api/src/main/resources/application.yml @@ -10,7 +10,7 @@ app: # 애플리케이션 및 배포 정보 server: port: ${SERVER_PORT:8080} servlet: - context-path: /admin/api/v1 + context-path: /admin/api encoding: charset: UTF-8 compression: @@ -62,6 +62,10 @@ schedules: sync: enable: false +curation: + spec-sync: + enabled: ${CURATION_SPEC_SYNC_ENABLED:true} + root: admin: email: ${ROOT_ADMIN_EMAIL:email@email.com} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index dfe76ceb2..31d1f0338 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -14,6 +14,7 @@ import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.BDDMockito.given import org.mockito.Mockito.any @@ -36,6 +37,7 @@ import org.springframework.test.web.servlet.assertj.MockMvcTester excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Alcohol 컨트롤러 RestDocs 테스트") class AdminAlcoholsControllerDocsTest { @@ -63,7 +65,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.get().uri("/alcohols") + mvc.get().uri("/v1/alcohols") .param("keyword", "글렌") .param("category", AlcoholCategoryGroup.SINGLE_MALT.name) .param("regionId", "1") @@ -77,7 +79,7 @@ class AdminAlcoholsControllerDocsTest { .extractingPath("$.success").isEqualTo(true) assertThat( - mvc.get().uri("/alcohols") + mvc.get().uri("/v1/alcohols") .param("keyword", "글렌") .param("category", AlcoholCategoryGroup.SINGLE_MALT.name) .param("regionId", "1") @@ -141,7 +143,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.get().uri("/alcohols/{alcoholId}", 1L) + mvc.get().uri("/v1/alcohols/{alcoholId}", 1L) ) .hasStatusOk() .apply( @@ -211,7 +213,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.post().uri("/alcohols") + mvc.post().uri("/v1/alcohols") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -276,7 +278,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.put().uri("/alcohols/{alcoholId}", 1L) + mvc.put().uri("/v1/alcohols/{alcoholId}", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -354,7 +356,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.get().uri("/alcohols/categories/reference") + mvc.get().uri("/v1/alcohols/categories/reference") ) .hasStatusOk() .apply( @@ -407,7 +409,7 @@ class AdminAlcoholsControllerDocsTest { // when & then assertThat( - mvc.delete().uri("/alcohols/{alcoholId}", 1L) + mvc.delete().uri("/v1/alcohols/{alcoholId}", 1L) ) .hasStatusOk() .apply( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt index 65ed9acdd..8c17cbe5e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -14,6 +14,7 @@ import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -42,6 +43,7 @@ import java.time.LocalDateTime excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Distillery 컨트롤러 RestDocs 테스트") class AdminDistilleryControllerDocsTest { @@ -68,7 +70,7 @@ class AdminDistilleryControllerDocsTest { .willReturn(response) assertThat( - mvc.get().uri("/distilleries?keyword=&page=0&size=20&sortOrder=ASC") + mvc.get().uri("/v1/distilleries?keyword=&page=0&size=20&sortOrder=ASC") ).hasStatusOk() .apply( document( @@ -123,7 +125,7 @@ class AdminDistilleryControllerDocsTest { given(distilleryService.getDetail(anyLong())).willReturn(item) assertThat( - mvc.get().uri("/distilleries/{distilleryId}", 1L) + mvc.get().uri("/v1/distilleries/{distilleryId}", 1L) ).hasStatusOk() .apply( document( @@ -164,7 +166,7 @@ class AdminDistilleryControllerDocsTest { ) assertThat( - mvc.post().uri("/distilleries") + mvc.post().uri("/v1/distilleries") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -196,7 +198,7 @@ class AdminDistilleryControllerDocsTest { ) assertThat( - mvc.put().uri("/distilleries/{distilleryId}", 1L) + mvc.put().uri("/v1/distilleries/{distilleryId}", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -225,7 +227,7 @@ class AdminDistilleryControllerDocsTest { val request = mapOf("sortOrder" to 5) assertThat( - mvc.patch().uri("/distilleries/{distilleryId}/sort-order", 1L) + mvc.patch().uri("/v1/distilleries/{distilleryId}/sort-order", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -250,7 +252,7 @@ class AdminDistilleryControllerDocsTest { given(distilleryService.delete(anyLong())).willReturn(result) assertThat( - mvc.delete().uri("/distilleries/{distilleryId}", 1L) + mvc.delete().uri("/v1/distilleries/{distilleryId}", 1L) ).hasStatusOk() .apply( document( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index eaf776773..918b8d395 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -8,6 +8,7 @@ import app.bottlenote.global.data.response.GlobalResponse import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.BDDMockito.given @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.assertj.MockMvcTester excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Region 컨트롤러 RestDocs 테스트") class AdminRegionControllerDocsTest { @@ -56,7 +58,7 @@ class AdminRegionControllerDocsTest { // when & then assertThat( - mvc.get().uri("/regions?keyword=&page=0&size=20&sortOrder=ASC") + mvc.get().uri("/v1/regions?keyword=&page=0&size=20&sortOrder=ASC") ) .hasStatusOk() .apply( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 3cfa10f2b..15a237d72 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -39,6 +40,7 @@ import java.time.LocalDateTime excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin TastingTag 컨트롤러 RestDocs 테스트") class AdminTastingTagControllerDocsTest { @@ -71,7 +73,7 @@ class AdminTastingTagControllerDocsTest { // when & then assertThat( - mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") + mvc.get().uri("/v1/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") ) .hasStatusOk() .apply( @@ -150,7 +152,7 @@ class AdminTastingTagControllerDocsTest { given(tastingTagService.getTagDetail(anyLong())).willReturn(response) // when & then - assertThat(mvc.get().uri("/tasting-tags/{tagId}", 1L)) + assertThat(mvc.get().uri("/v1/tasting-tags/{tagId}", 1L)) .hasStatusOk() .apply( document( @@ -223,7 +225,7 @@ class AdminTastingTagControllerDocsTest { // when & then assertThat( - mvc.post().uri("/tasting-tags") + mvc.post().uri("/v1/tasting-tags") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -281,7 +283,7 @@ class AdminTastingTagControllerDocsTest { // when & then assertThat( - mvc.put().uri("/tasting-tags/{tagId}", 1L) + mvc.put().uri("/v1/tasting-tags/{tagId}", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -334,7 +336,7 @@ class AdminTastingTagControllerDocsTest { given(tastingTagService.deleteTag(anyLong())).willReturn(response) // when & then - assertThat(mvc.delete().uri("/tasting-tags/{tagId}", 1L)) + assertThat(mvc.delete().uri("/v1/tasting-tags/{tagId}", 1L)) .hasStatusOk() .apply( document( @@ -380,7 +382,7 @@ class AdminTastingTagControllerDocsTest { // when & then assertThat( - mvc.post().uri("/tasting-tags/{tagId}/alcohols", 1L) + mvc.post().uri("/v1/tasting-tags/{tagId}/alcohols", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -427,7 +429,7 @@ class AdminTastingTagControllerDocsTest { // when & then assertThat( - mvc.delete().uri("/tasting-tags/{tagId}/alcohols", 1L) + mvc.delete().uri("/v1/tasting-tags/{tagId}/alcohols", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt index b1bd14ea1..a25104b65 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt @@ -11,6 +11,7 @@ import app.helper.auth.AuthHelper import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.anyLong import org.mockito.BDDMockito.given @@ -38,6 +39,7 @@ import java.util.* excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Auth 컨트롤러 RestDocs 테스트") class AuthControllerDocsTest { @@ -67,7 +69,7 @@ class AuthControllerDocsTest { // when & then assertThat( - mvc.post().uri("/auth/login") + mvc.post().uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) @@ -107,7 +109,7 @@ class AuthControllerDocsTest { // when & then assertThat( - mvc.post().uri("/auth/refresh") + mvc.post().uri("/v1/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) @@ -145,7 +147,7 @@ class AuthControllerDocsTest { // when & then assertThat( - mvc.delete().uri("/auth/withdraw") + mvc.delete().uri("/v1/auth/withdraw") .header("Authorization", "Bearer test_access_token") ) .hasStatusOk() @@ -197,7 +199,7 @@ class AuthControllerDocsTest { // when & then assertThat( - mvc.post().uri("/auth/signup") + mvc.post().uri("/v1/auth/signup") .header("Authorization", "Bearer test_access_token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt index b62355e80..a204bbf5f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -37,6 +38,7 @@ import org.springframework.test.web.servlet.assertj.MockMvcTester excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Banner 컨트롤러 RestDocs 테스트") class AdminBannerControllerDocsTest { @@ -66,7 +68,7 @@ class AdminBannerControllerDocsTest { // when & then assertThat( - mvc.get().uri("/banners?keyword=&isActive=true&bannerType=CURATION&page=0&size=20") + mvc.get().uri("/v1/banners?keyword=&isActive=true&bannerType=CURATION&page=0&size=20") ) .hasStatusOk() .apply( @@ -124,7 +126,7 @@ class AdminBannerControllerDocsTest { given(adminBannerService.getDetail(anyLong())).willReturn(response) // when & then - assertThat(mvc.get().uri("/banners/{bannerId}", 1L)) + assertThat(mvc.get().uri("/v1/banners/{bannerId}", 1L)) .hasStatusOk() .apply( document( @@ -184,7 +186,7 @@ class AdminBannerControllerDocsTest { // when & then assertThat( - mvc.post().uri("/banners") + mvc.post().uri("/v1/banners") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -246,7 +248,7 @@ class AdminBannerControllerDocsTest { // when & then assertThat( - mvc.put().uri("/banners/{bannerId}", 1L) + mvc.put().uri("/v1/banners/{bannerId}", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -309,7 +311,7 @@ class AdminBannerControllerDocsTest { given(adminBannerService.delete(anyLong())).willReturn(response) // when & then - assertThat(mvc.delete().uri("/banners/{bannerId}", 1L)) + assertThat(mvc.delete().uri("/v1/banners/{bannerId}", 1L)) .hasStatusOk() .apply( document( @@ -355,7 +357,7 @@ class AdminBannerControllerDocsTest { // when & then assertThat( - mvc.patch().uri("/banners/{bannerId}/status", 1L) + mvc.patch().uri("/v1/banners/{bannerId}/status", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -407,7 +409,7 @@ class AdminBannerControllerDocsTest { // when & then assertThat( - mvc.patch().uri("/banners/{bannerId}/sort-order", 1L) + mvc.patch().uri("/v1/banners/{bannerId}/sort-order", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt index 6bcc14c5d..3c0d8662a 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -33,6 +34,7 @@ import org.springframework.test.web.servlet.assertj.MockMvcTester excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Curation 컨트롤러 RestDocs 테스트") class AdminCurationControllerDocsTest { @@ -62,7 +64,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.get().uri("/curations?keyword=&isActive=true&page=0&size=20") + mvc.get().uri("/v1/curations?keyword=&isActive=true&page=0&size=20") ) .hasStatusOk() .apply( @@ -116,7 +118,7 @@ class AdminCurationControllerDocsTest { given(adminCurationService.getDetail(anyLong())).willReturn(response) // when & then - assertThat(mvc.get().uri("/curations/{curationId}", 1L)) + assertThat(mvc.get().uri("/v1/curations/{curationId}", 1L)) .hasStatusOk() .apply( document( @@ -176,7 +178,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.post().uri("/curations") + mvc.post().uri("/v1/curations") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -229,7 +231,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.put().uri("/curations/{curationId}", 1L) + mvc.put().uri("/v1/curations/{curationId}", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -283,7 +285,7 @@ class AdminCurationControllerDocsTest { given(adminCurationService.delete(anyLong())).willReturn(response) // when & then - assertThat(mvc.delete().uri("/curations/{curationId}", 1L)) + assertThat(mvc.delete().uri("/v1/curations/{curationId}", 1L)) .hasStatusOk() .apply( document( @@ -329,7 +331,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.patch().uri("/curations/{curationId}/status", 1L) + mvc.patch().uri("/v1/curations/{curationId}/status", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -381,7 +383,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.patch().uri("/curations/{curationId}/display-order", 1L) + mvc.patch().uri("/v1/curations/{curationId}/display-order", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -433,7 +435,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.post().uri("/curations/{curationId}/alcohols", 1L) + mvc.post().uri("/v1/curations/{curationId}/alcohols", 1L) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ) @@ -478,7 +480,7 @@ class AdminCurationControllerDocsTest { // when & then assertThat( - mvc.delete().uri("/curations/{curationId}/alcohols/{alcoholId}", 1L, 5L) + mvc.delete().uri("/v1/curations/{curationId}/alcohols/{alcoholId}", 1L, 5L) ) .hasStatusOk() .apply( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt new file mode 100644 index 000000000..52e42422e --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt @@ -0,0 +1,216 @@ +package app.docs.curation + +import app.bottlenote.curation.dto.request.CurationCreateRequest +import app.bottlenote.curation.dto.request.CurationSearchRequest +import app.bottlenote.curation.dto.request.CurationUpdateRequest +import app.bottlenote.curation.dto.response.AdminSpecBasedCurationDetailResponse +import app.bottlenote.curation.dto.response.AdminSpecBasedCurationListResponse +import app.bottlenote.curation.dto.response.CurationSpecResponse +import app.bottlenote.curation.presentation.AdminCurationSpecController +import app.bottlenote.curation.presentation.AdminSpecBasedCurationController +import app.bottlenote.curation.service.AdminSpecBasedCurationService +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.data.domain.PageImpl +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.LocalDate +import java.time.LocalDateTime + +@WebMvcTest( + controllers = [ + AdminSpecBasedCurationController::class, + AdminCurationSpecController::class + ], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@Tag("restdocs") +@DisplayName("Admin Spec Based Curation 컨트롤러 RestDocs 테스트") +class AdminSpecBasedCurationControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var mapper: ObjectMapper + + @MockitoBean + private lateinit var adminSpecBasedCurationService: AdminSpecBasedCurationService + + @Test + @DisplayName("큐레이션 스펙 목록을 조회할 수 있다") + fun listSpecs() { + given(adminSpecBasedCurationService.listSpecs()).willReturn(listOf(specResponse())) + + assertThat(mvc.get().uri("/v2/curation-specs")) + .hasStatusOk() + .apply( + document( + "admin/v2/curation-specs/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + @Test + @DisplayName("큐레이션 스펙 상세를 조회할 수 있다") + fun getSpecDetail() { + given(adminSpecBasedCurationService.getSpecDetail(anyLong())).willReturn(specResponse()) + + assertThat(mvc.get().uri("/v2/curation-specs/{specId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/v2/curation-specs/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + @Test + @DisplayName("spec 기반 큐레이션 목록을 조회할 수 있다") + fun listCurations() { + given(adminSpecBasedCurationService.search(any(CurationSearchRequest::class.java))) + .willReturn(GlobalResponse.fromPage(PageImpl(listOf(listResponse())))) + + assertThat(mvc.get().uri("/v2/curations?keyword=&isActive=true&page=0&size=20")) + .hasStatusOk() + .apply( + document( + "admin/v2/curations/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + @Test + @DisplayName("spec 기반 큐레이션 상세를 조회할 수 있다") + fun getDetail() { + given(adminSpecBasedCurationService.getDetail(anyLong())).willReturn(detailResponse()) + + assertThat(mvc.get().uri("/v2/curations/{curationId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/v2/curations/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + @Test + @DisplayName("spec 기반 큐레이션을 등록할 수 있다") + fun create() { + given(adminSpecBasedCurationService.create(any(CurationCreateRequest::class.java))) + .willReturn(AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_CREATED, 1L)) + + assertThat( + mvc.post().uri("/v2/curations") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest())) + ) + .hasStatusOk() + .apply( + document( + "admin/v2/curations/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + @Test + @DisplayName("spec 기반 큐레이션을 수정할 수 있다") + fun update() { + given(adminSpecBasedCurationService.update(anyLong(), any(CurationUpdateRequest::class.java))) + .willReturn(AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_UPDATED, 1L)) + + assertThat( + mvc.put().uri("/v2/curations/{curationId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(updateRequest())) + ) + .hasStatusOk() + .apply( + document( + "admin/v2/curations/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ) + } + + private fun specResponse(): CurationSpecResponse = CurationSpecResponse( + 1L, + "RECOMMENDED_WHISKY", + "추천 위스키", + "추천 위스키 카드 목록", + "alcohol", + 1, + true, + mapOf("type" to "object", "required" to listOf("source", "alcohol")), + mapOf("type" to "object") + ) + + private fun listResponse(): AdminSpecBasedCurationListResponse = AdminSpecBasedCurationListResponse( + 1L, + 1L, + "RECOMMENDED_WHISKY", + "비 오는 날 위스키", + 1, + true, + LocalDateTime.of(2026, 5, 15, 0, 0) + ) + + private fun detailResponse(): AdminSpecBasedCurationDetailResponse = AdminSpecBasedCurationDetailResponse( + 1L, + "비 오는 날 위스키", + "스모키 위스키 추천", + "https://cdn.example.com/cover.jpg", + listOf("https://cdn.example.com/cover.jpg"), + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30), + 1, + true, + LocalDateTime.of(2026, 5, 15, 0, 0), + LocalDateTime.of(2026, 5, 15, 0, 0), + specResponse(), + mapOf("source" to "BOTTLE_NOTE", "alcohol" to mapOf("korName" to "테스트 위스키")) + ) + + private fun createRequest(): Map = mapOf( + "specId" to 1L, + "name" to "비 오는 날 위스키", + "description" to "스모키 위스키 추천", + "imageUrls" to listOf("https://cdn.example.com/cover.jpg"), + "exposureStartDate" to "2026-06-01", + "exposureEndDate" to "2026-06-30", + "displayOrder" to 1, + "isActive" to true, + "payload" to mapOf("source" to "BOTTLE_NOTE", "alcohol" to mapOf("korName" to "테스트 위스키")) + ) + + private fun updateRequest(): Map = createRequest() + mapOf("name" to "수정된 큐레이션") +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt index 9489151db..4b3d8bcec 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt @@ -8,6 +8,7 @@ import app.bottlenote.common.file.service.ImageUploadService import app.bottlenote.global.security.SecurityContextUtil import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -34,6 +35,7 @@ import java.util.* excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin 이미지 업로드 컨트롤러 RestDocs 테스트") class AdminImageUploadControllerDocsTest { @@ -76,7 +78,7 @@ class AdminImageUploadControllerDocsTest { // when & then assertThat( - mvc.get().uri("/s3/presign-url") + mvc.get().uri("/v1/s3/presign-url") .header("Authorization", "Bearer test_access_token") .param("rootPath", "admin/banner") .param("uploadSize", "2") diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt index f61ce7526..ec74e3e42 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt @@ -14,6 +14,7 @@ import app.bottlenote.support.help.service.AdminHelpService import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyLong @@ -42,6 +43,7 @@ import java.util.* excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Help 컨트롤러 RestDocs 테스트") class AdminHelpControllerDocsTest { @@ -82,7 +84,7 @@ class AdminHelpControllerDocsTest { // when & then assertThat( - mvc.get().uri("/helps") + mvc.get().uri("/v1/helps") .header("Authorization", "Bearer test_access_token") .param("status", StatusType.WAITING.name) .param("type", HelpType.WHISKEY.name) @@ -152,7 +154,7 @@ class AdminHelpControllerDocsTest { // when & then assertThat( - mvc.get().uri("/helps/{helpId}", 1L) + mvc.get().uri("/v1/helps/{helpId}", 1L) .header("Authorization", "Bearer test_access_token") ) .hasStatusOk() @@ -212,7 +214,7 @@ class AdminHelpControllerDocsTest { // when & then assertThat( - mvc.post().uri("/helps/{helpId}/answer", 1L) + mvc.post().uri("/v1/helps/{helpId}/answer", 1L) .header("Authorization", "Bearer test_access_token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/review/AdminReviewControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/review/AdminReviewControllerDocsTest.kt new file mode 100644 index 000000000..a04fd7f21 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/review/AdminReviewControllerDocsTest.kt @@ -0,0 +1,151 @@ +package app.docs.review + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.service.cursor.SortOrder +import app.bottlenote.review.constant.AdminReviewSortType +import app.bottlenote.review.constant.ReviewActiveStatus +import app.bottlenote.review.constant.ReviewDisplayStatus +import app.bottlenote.review.dto.request.AdminReviewSearchRequest +import app.bottlenote.review.dto.response.AdminReviewListResponse +import app.bottlenote.review.presentation.AdminReviewController +import app.bottlenote.review.service.AdminReviewQueryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.data.domain.PageImpl +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.LocalDateTime + +@WebMvcTest( + controllers = [AdminReviewController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Review 컨트롤러 RestDocs 테스트") +@Tag("restdocs") +class AdminReviewControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var adminReviewQueryService: AdminReviewQueryService + + @Test + @DisplayName("관리자용 리뷰 목록을 조회할 수 있다") + fun listReviews() { + // given + val items = listOf( + AdminReviewListResponse( + 1L, + 101L, + "글렌피딕 12년", + 1001L, + "reviewer1", + "향이 풍부하고 균형감이 좋습니다.", + 4.5, + ReviewActiveStatus.ACTIVE, + ReviewDisplayStatus.PUBLIC, + 3L, + LocalDateTime.of(2026, 1, 10, 10, 30), + LocalDateTime.of(2026, 1, 12, 9, 15) + ), + AdminReviewListResponse( + 2L, + 102L, + "맥켈란 12년", + 1002L, + "reviewer2", + "비공개 처리된 운영 확인 대상 리뷰입니다.", + 3.0, + ReviewActiveStatus.DISABLED, + ReviewDisplayStatus.PRIVATE, + 0L, + LocalDateTime.of(2026, 1, 9, 18, 0), + LocalDateTime.of(2026, 1, 9, 18, 0) + ) + ) + val response = GlobalResponse.fromPage(PageImpl(items)) + + given(adminReviewQueryService.searchReviews(any(AdminReviewSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/v1/reviews") + .param("alcoholId", "101") + .param("userId", "1001") + .param("activeStatus", ReviewActiveStatus.ACTIVE.name) + .param("displayStatus", ReviewDisplayStatus.PUBLIC.name) + .param("keyword", "글렌") + .param("createdFrom", "2026-01-01T00:00:00") + .param("createdTo", "2026-01-31T23:59:59") + .param("sortType", AdminReviewSortType.CREATED_AT.name) + .param("sortOrder", SortOrder.DESC.name) + .param("page", "0") + .param("size", "20") + ) + .hasStatusOk() + .apply( + document( + "admin/reviews/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("alcoholId").description("술 ID 필터").optional(), + parameterWithName("userId").description("작성자 유저 ID 필터").optional(), + parameterWithName("activeStatus").description("리뷰 활성 상태 필터 (ACTIVE/DELETED/DISABLED)").optional(), + parameterWithName("displayStatus").description("리뷰 노출 상태 필터 (PUBLIC/PRIVATE)").optional(), + parameterWithName("keyword").description("검색어 (리뷰 본문/작성자 닉네임/작성자 이메일/술 한글명/술 영문명)").optional(), + parameterWithName("createdFrom").description("작성일시 시작 범위 (ISO-8601 LocalDateTime)").optional(), + parameterWithName("createdTo").description("작성일시 종료 범위 (ISO-8601 LocalDateTime)").optional(), + parameterWithName("sortType").description("정렬 기준 (기본값: CREATED_AT, 상세 값은 AdminReviewSortType 표 참조)").optional(), + parameterWithName("sortOrder").description("정렬 방향 (기본값: DESC, 상세 값은 SortOrder 표 참조)").optional(), + parameterWithName("page").description("페이지 번호 (0 이상, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (1~100, 기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("리뷰 목록"), + fieldWithPath("data[].reviewId").type(JsonFieldType.NUMBER).description("리뷰 ID"), + fieldWithPath("data[].alcoholId").type(JsonFieldType.NUMBER).description("술 ID"), + fieldWithPath("data[].alcoholName").type(JsonFieldType.STRING).description("술 이름"), + fieldWithPath("data[].userId").type(JsonFieldType.NUMBER).description("작성자 유저 ID"), + fieldWithPath("data[].userNickname").type(JsonFieldType.STRING).description("작성자 닉네임"), + fieldWithPath("data[].content").type(JsonFieldType.STRING).description("리뷰 본문"), + fieldWithPath("data[].reviewRating").type(JsonFieldType.NUMBER).description("리뷰 평점"), + fieldWithPath("data[].activeStatus").type(JsonFieldType.STRING).description("리뷰 활성 상태 (ACTIVE/DELETED/DISABLED)"), + fieldWithPath("data[].displayStatus").type(JsonFieldType.STRING).description("리뷰 노출 상태 (PUBLIC/PRIVATE)"), + fieldWithPath("data[].replyCount").type(JsonFieldType.NUMBER).description("댓글 수"), + fieldWithPath("data[].createAt").type(JsonFieldType.STRING).description("리뷰 작성일시"), + fieldWithPath("data[].lastModifyAt").type(JsonFieldType.STRING).description("리뷰 최종 수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt index abd0d3ef0..d3ab12e4b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt @@ -10,6 +10,7 @@ import app.bottlenote.user.presentation.AdminUsersController import app.bottlenote.user.service.AdminUserService import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.BDDMockito.given @@ -32,6 +33,7 @@ import java.time.LocalDateTime excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs +@Tag("restdocs") @DisplayName("Admin Users 컨트롤러 RestDocs 테스트") class AdminUsersControllerDocsTest { @@ -67,7 +69,7 @@ class AdminUsersControllerDocsTest { // when & then assertThat( - mvc.get().uri("/users?keyword=&status=ACTIVE&sortType=CREATED_AT&sortOrder=DESC&page=0&size=20") + mvc.get().uri("/v1/users?keyword=&status=ACTIVE&sortType=CREATED_AT&sortOrder=DESC&page=0&size=20") ) .hasStatusOk() .apply( diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index a678a6f1f..101c7a18a 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -80,7 +80,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -104,7 +104,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .param("keyword", keyword) ).hasStatusOk() @@ -124,7 +124,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .param("category", category.name) ).hasStatusOk() @@ -148,7 +148,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .param("sortType", sortType.name) .param("sortOrder", sortOrder.name) @@ -177,7 +177,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .param("page", page.toString()) .param("size", size.toString()) @@ -201,7 +201,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -220,7 +220,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .param("includeDeleted", "true") ).hasStatusOk() @@ -245,7 +245,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/categories/reference") + .uri("/v1/alcohols/categories/reference") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -264,7 +264,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/categories/reference") + .uri("/v1/alcohols/categories/reference") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -286,7 +286,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -297,7 +297,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -315,7 +315,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") assertThat(result) @@ -328,7 +328,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/999999") + .uri("/v1/alcohols/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -366,7 +366,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -408,7 +408,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val createResult = mockMvcTester .post() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -425,7 +425,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/$alcoholId") + .uri("/v1/alcohols/$alcoholId") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -463,7 +463,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -483,7 +483,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -518,7 +518,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/alcohols") + .uri("/v1/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -559,7 +559,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -604,7 +604,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -617,7 +617,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -657,7 +657,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -667,7 +667,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -708,7 +708,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -718,7 +718,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -755,7 +755,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/alcohols/999999") + .uri("/v1/alcohols/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -776,7 +776,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -791,7 +791,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/alcohols/999999") + .uri("/v1/alcohols/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -808,7 +808,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -825,7 +825,7 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/alcohols/${alcohol.id}") + .uri("/v1/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminDistilleryIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminDistilleryIntegrationTest.kt index 1489139f7..868cb715f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminDistilleryIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminDistilleryIntegrationTest.kt @@ -41,7 +41,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/distilleries/${distillery.id}") + .uri("/v1/distilleries/${distillery.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -55,7 +55,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/distilleries/999999") + .uri("/v1/distilleries/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() .bodyJson() @@ -79,7 +79,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/distilleries") + .uri("/v1/distilleries") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -101,7 +101,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/distilleries") + .uri("/v1/distilleries") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -122,7 +122,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/distilleries") + .uri("/v1/distilleries") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -148,7 +148,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/distilleries/${distillery.id}") + .uri("/v1/distilleries/${distillery.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -169,7 +169,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/distilleries/999999") + .uri("/v1/distilleries/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -192,7 +192,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/distilleries/${distillery.id}/sort-order") + .uri("/v1/distilleries/${distillery.id}/sort-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -211,7 +211,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/distilleries/${distillery.id}/sort-order") + .uri("/v1/distilleries/${distillery.id}/sort-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -230,7 +230,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/distilleries/${distillery.id}/sort-order") + .uri("/v1/distilleries/${distillery.id}/sort-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -252,7 +252,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/distilleries/${distillery.id}") + .uri("/v1/distilleries/${distillery.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -266,7 +266,7 @@ class AdminDistilleryIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/distilleries/999999") + .uri("/v1/distilleries/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() .bodyJson() diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt index 0853ac063..30e460666 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -30,7 +30,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags?page=0&size=20") + .uri("/v1/tasting-tags?page=0&size=20") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -41,7 +41,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags?page=0&size=10") + .uri("/v1/tasting-tags?page=0&size=10") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -54,7 +54,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { fun getTastingTagsWithoutAuth() { // when & then - 방어로직: 인증 없이 요청 시 실패 assertThat( - mockMvcTester.get().uri("/tasting-tags") + mockMvcTester.get().uri("/v1/tasting-tags") ).hasStatus4xxClientError() } } @@ -72,7 +72,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/regions?page=0&size=20") + .uri("/v1/regions?page=0&size=20") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -90,7 +90,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/regions?page=0&size=10") + .uri("/v1/regions?page=0&size=10") .header("Authorization", "Bearer $accessToken") assertThat(result) @@ -106,7 +106,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { // 방어로직: 인증 없이 요청 시 실패 assertThat( - mockMvcTester.get().uri("/regions") + mockMvcTester.get().uri("/v1/regions") ).hasStatus4xxClientError() } @@ -120,7 +120,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/regions?page=0&size=10") + .uri("/v1/regions?page=0&size=10") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -142,7 +142,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/distilleries?page=0&size=20") + .uri("/v1/distilleries?page=0&size=20") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -160,7 +160,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/distilleries?keyword=&page=0&size=20") + .uri("/v1/distilleries?keyword=&page=0&size=20") .header("Authorization", "Bearer $accessToken") assertThat(result) @@ -171,7 +171,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { // 방어로직: 인증 없이 요청 시 실패 assertThat( - mockMvcTester.get().uri("/distilleries") + mockMvcTester.get().uri("/v1/distilleries") ).hasStatus4xxClientError() } @@ -185,7 +185,7 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/distilleries?page=0&size=20") + .uri("/v1/distilleries?page=0&size=20") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt index e78ae78fa..af07ba931 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -42,7 +42,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -52,7 +52,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -71,7 +71,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/${leafTag.id}") + .uri("/v1/tasting-tags/${leafTag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -81,7 +81,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/${leafTag.id}") + .uri("/v1/tasting-tags/${leafTag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -101,7 +101,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -116,7 +116,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/tasting-tags/999999") + .uri("/v1/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -140,7 +140,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags") + .uri("/v1/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -166,7 +166,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags") + .uri("/v1/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -191,7 +191,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags") + .uri("/v1/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -216,7 +216,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags") + .uri("/v1/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -243,7 +243,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -270,7 +270,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/tasting-tags/${targetTag.id}") + .uri("/v1/tasting-tags/${targetTag.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -291,7 +291,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/tasting-tags/999999") + .uri("/v1/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -312,7 +312,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -331,7 +331,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/tasting-tags/${parent.id}") + .uri("/v1/tasting-tags/${parent.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -348,7 +348,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/tasting-tags/${tag.id}") + .uri("/v1/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -360,7 +360,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/tasting-tags/999999") + .uri("/v1/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -383,7 +383,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags/${tag.id}/alcohols") + .uri("/v1/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -409,7 +409,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/tasting-tags/${tag.id}/alcohols") + .uri("/v1/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -430,7 +430,7 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/tasting-tags/${tag.id}/alcohols") + .uri("/v1/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -445,13 +445,13 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청 시 실패한다") fun requestWithoutAuth() { // when & then - assertThat(mockMvcTester.get().uri("/tasting-tags/1")) + assertThat(mockMvcTester.get().uri("/v1/tasting-tags/1")) .hasStatus4xxClientError() assertThat( mockMvcTester .post() - .uri("/tasting-tags") + .uri("/v1/tasting-tags") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(mapOf("korName" to "테스트", "engName" to "Test"))) ).hasStatus4xxClientError() diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt index 37280ee08..6a18a651d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt @@ -29,7 +29,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -40,7 +40,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -63,7 +63,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatus4xxClientError() @@ -82,7 +82,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatus4xxClientError() @@ -105,7 +105,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatus4xxClientError() @@ -127,7 +127,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -153,7 +153,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val loginResult = mockMvcTester .post() - .uri("/auth/login") + .uri("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(loginRequest)) .exchange() @@ -167,7 +167,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/refresh") + .uri("/v1/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatusOk() @@ -186,7 +186,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/refresh") + .uri("/v1/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatus4xxClientError() @@ -210,7 +210,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/auth/withdraw") + .uri("/v1/auth/withdraw") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -229,7 +229,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/auth/withdraw") + .uri("/v1/auth/withdraw") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() .bodyJson() @@ -260,7 +260,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -272,7 +272,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request.plus("email" to "another@bottlenote.com"))) @@ -301,7 +301,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -330,7 +330,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -359,7 +359,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -385,7 +385,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) ).hasStatus4xxClientError() @@ -413,7 +413,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -442,7 +442,7 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/auth/signup") + .uri("/v1/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt index 968deb575..0216a39fd 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt @@ -39,7 +39,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -57,7 +57,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners") + .uri("/v1/banners") .param("keyword", "특별") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -76,7 +76,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners") + .uri("/v1/banners") .param("isActive", "true") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -95,7 +95,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners") + .uri("/v1/banners") .param("bannerType", "CURATION") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -108,7 +108,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청하면 401을 반환한다") fun listUnauthorized() { assertThat( - mockMvcTester.get().uri("/banners") + mockMvcTester.get().uri("/v1/banners") ).hasStatus4xxClientError() } } @@ -126,7 +126,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners/${banner.id}") + .uri("/v1/banners/${banner.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -141,7 +141,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/banners/999999") + .uri("/v1/banners/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -163,7 +163,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -187,7 +187,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -208,7 +208,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -230,7 +230,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -252,7 +252,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -273,7 +273,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -298,7 +298,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/banners/${banner.id}") + .uri("/v1/banners/${banner.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -318,7 +318,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/banners/999999") + .uri("/v1/banners/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -339,7 +339,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/banners/${banner.id}") + .uri("/v1/banners/${banner.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -354,7 +354,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/banners/999999") + .uri("/v1/banners/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -374,7 +374,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/banners/${banner.id}/status") + .uri("/v1/banners/${banner.id}/status") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -394,7 +394,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/banners/999999/status") + .uri("/v1/banners/999999/status") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -416,7 +416,7 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/banners/${banner.id}/sort-order") + .uri("/v1/banners/${banner.id}/sort-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -434,16 +434,16 @@ class AdminBannerIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청 시 실패한다") fun requestWithoutAuth() { // when & then - assertThat(mockMvcTester.get().uri("/banners")) + assertThat(mockMvcTester.get().uri("/v1/banners")) .hasStatus4xxClientError() - assertThat(mockMvcTester.get().uri("/banners/1")) + assertThat(mockMvcTester.get().uri("/v1/banners/1")) .hasStatus4xxClientError() assertThat( mockMvcTester .post() - .uri("/banners") + .uri("/v1/banners") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(BannerHelper.createBannerCreateRequest())) ).hasStatus4xxClientError() diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt index 140fc1d82..26fe38cd1 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt @@ -40,7 +40,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/curations") + .uri("/v1/curations") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -58,7 +58,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/curations") + .uri("/v1/curations") .param("keyword", "테스트") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -77,7 +77,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/curations") + .uri("/v1/curations") .param("isActive", "true") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -90,7 +90,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청하면 401을 반환한다") fun listUnauthorized() { assertThat( - mockMvcTester.get().uri("/curations") + mockMvcTester.get().uri("/v1/curations") ).hasStatus4xxClientError() } } @@ -108,7 +108,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/curations/${curation.id}") + .uri("/v1/curations/${curation.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -123,7 +123,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/curations/999999") + .uri("/v1/curations/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -145,7 +145,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/curations") + .uri("/v1/curations") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -172,7 +172,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/curations") + .uri("/v1/curations") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -196,7 +196,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/curations") + .uri("/v1/curations") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -217,7 +217,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/curations") + .uri("/v1/curations") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -243,7 +243,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/curations/${curation.id}") + .uri("/v1/curations/${curation.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -263,7 +263,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/curations/999999") + .uri("/v1/curations/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -284,7 +284,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/curations/${curation.id}") + .uri("/v1/curations/${curation.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -299,7 +299,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/curations/999999") + .uri("/v1/curations/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -319,7 +319,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/curations/${curation.id}/status") + .uri("/v1/curations/${curation.id}/status") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -339,7 +339,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/curations/999999/status") + .uri("/v1/curations/999999/status") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -361,7 +361,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/curations/${curation.id}/display-order") + .uri("/v1/curations/${curation.id}/display-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -391,7 +391,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/curations/${curation.id}/alcohols") + .uri("/v1/curations/${curation.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -412,7 +412,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .uri("/v1/curations/${curation.id}/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -431,7 +431,7 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .uri("/v1/curations/${curation.id}/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() } @@ -444,16 +444,16 @@ class AdminCurationIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청 시 실패한다") fun requestWithoutAuth() { // when & then - assertThat(mockMvcTester.get().uri("/curations")) + assertThat(mockMvcTester.get().uri("/v1/curations")) .hasStatus4xxClientError() - assertThat(mockMvcTester.get().uri("/curations/1")) + assertThat(mockMvcTester.get().uri("/v1/curations/1")) .hasStatus4xxClientError() assertThat( mockMvcTester .post() - .uri("/curations") + .uri("/v1/curations") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(CurationHelper.createCurationCreateRequest())) ).hasStatus4xxClientError() diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt new file mode 100644 index 000000000..50a8dc2a7 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt @@ -0,0 +1,249 @@ +package app.integration.curation + +import app.IntegrationTestSupport +import app.bottlenote.curation.domain.CurationSpecRepository +import app.bottlenote.curation.service.CurationSpecResourceSyncService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType + +@Tag("admin_integration") +@DisplayName("[integration] Admin Spec Based Curation API 통합 테스트") +class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var curationSpecResourceSyncService: CurationSpecResourceSyncService + + @Autowired + private lateinit var curationSpecRepository: CurationSpecRepository + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + curationSpecResourceSyncService.sync() + } + + @Nested + @DisplayName("큐레이션 스펙 조회 API") + inner class CurationSpecs { + @Test + @DisplayName("활성 큐레이션 스펙 목록을 조회할 수 있다") + fun listSpecsSuccess() { + val result = mockMvcTester + .get() + .uri("/v2/curation-specs") + .header("Authorization", "Bearer $accessToken") + .exchange() + + assertThat(result).hasStatusOk() + assertThat(dataNode(result).map { it.path("code").asText() }) + .contains("RECOMMENDED_WHISKY") + } + + @Test + @DisplayName("Admin v2에서 인증 없이 /v2/curation-specs를 요청할 경우 4xx를 반환한다") + fun listSpecs_whenUnauthenticated_returnsUnauthorized() { + assertThat( + mockMvcTester + .get() + .uri("/v2/curation-specs") + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("큐레이션 스펙 상세에서 requestSpec과 responseSpec을 조회할 수 있다") + fun getSpecDetailSuccess() { + val specId = recommendedSpecId() + + assertThat( + mockMvcTester + .get() + .uri("/v2/curation-specs/$specId") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.responseSpec.properties.stats.x-graphql.query") + .isEqualTo("alcohols") + } + } + + @Nested + @DisplayName("spec 기반 큐레이션 생성 API") + inner class CreateSpecBasedCuration { + @Test + @DisplayName("request spec의 maxItems 경계값 이내 payload는 생성할 수 있다") + fun create_whenPayloadMatchesRequestSpec_returnsCreated() { + val request = createRequest(validPayload(selectedTags = List(12) { index -> "태그$index" })) + + assertThat( + mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_CREATED") + } + + @Test + @DisplayName("Admin v2에서 존재하지 않는 specId로 생성할 경우 404를 반환한다") + fun create_whenSpecIdDoesNotExist_returnsNotFound() { + val request = createRequest(validPayload()) + ("specId" to 999999L) + + assertNotFound( + mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .exchange() + ) + } + + @Test + @DisplayName("Admin v2에서 인증 없이 /v2/curations를 요청할 경우 4xx를 반환한다") + fun listCurations_whenUnauthenticated_returnsUnauthorized() { + assertThat( + mockMvcTester + .get() + .uri("/v2/curations") + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("request spec의 required 필드가 없으면 400을 반환한다") + fun create_whenRequiredFieldMissing_returnsBadRequest() { + val payload = mapOf( + "alcohol" to mapOf( + "korName" to "필수값 테스트", + "selectedTags" to listOf("오크") + ) + ) + + assertBadRequest(createRequest(payload)) + } + + @Test + @DisplayName("요청 DTO 필수 필드가 없으면 400을 반환한다") + fun create_whenRequestRequiredFieldMissing_returnsBadRequest() { + val request = createRequest(validPayload()) - "name" + + assertBadRequest(request) + } + + @Test + @DisplayName("request spec의 enum 값이 아니면 400을 반환한다") + fun create_whenEnumValueInvalid_returnsBadRequest() { + val payload = validPayload(source = "UNKNOWN_SOURCE") + + assertBadRequest(createRequest(payload)) + } + + @Test + @DisplayName("request spec의 maxItems를 초과하면 400을 반환한다") + fun create_whenSelectedTagsExceedsMaxItems_returnsBadRequest() { + val payload = validPayload(selectedTags = List(13) { index -> "태그$index" }) + + assertBadRequest(createRequest(payload)) + } + + @Test + @DisplayName("request spec의 minLength보다 짧은 배열 항목이면 400을 반환한다") + fun create_whenSelectedTagBelowMinLength_returnsBadRequest() { + val payload = validPayload(selectedTags = listOf("")) + + assertBadRequest(createRequest(payload)) + } + } + + @Nested + @DisplayName("spec 기반 큐레이션 수정 API") + inner class UpdateSpecBasedCuration { + @Test + @DisplayName("Admin v2에서 존재하지 않는 curationId로 수정할 경우 404를 반환한다") + fun update_whenCurationIdDoesNotExist_returnsNotFound() { + assertNotFound( + mockMvcTester + .put() + .uri("/v2/curations/{curationId}", 999999L) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(updateRequest(validPayload()))) + .exchange() + ) + } + } + + private fun assertBadRequest(request: Map) { + assertThat( + mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.code") + .isEqualTo(400) + } + + private fun assertNotFound(result: org.springframework.test.web.servlet.assertj.MvcTestResult) { + assertThat(result).hasStatus(404) + .bodyJson() + .extractingPath("$.code") + .isEqualTo(404) + } + + private fun createRequest(payload: Any): Map = mapOf( + "specId" to recommendedSpecId(), + "name" to "통합 테스트 큐레이션", + "description" to "request spec 검증 테스트", + "imageUrls" to listOf("https://cdn.example.com/cover.jpg"), + "exposureStartDate" to "2026-06-01", + "exposureEndDate" to "2026-06-30", + "displayOrder" to 1, + "isActive" to true, + "payload" to listOf(payload) + ) + + private fun updateRequest(payload: Any): Map = mapOf( + "specId" to recommendedSpecId(), + "name" to "수정 테스트 큐레이션", + "description" to "존재하지 않는 큐레이션 수정 테스트", + "imageUrls" to listOf("https://cdn.example.com/cover.jpg"), + "exposureStartDate" to "2026-06-01", + "exposureEndDate" to "2026-06-30", + "displayOrder" to 1, + "isActive" to true, + "payload" to listOf(payload) + ) + + private fun validPayload( + source: String = "BOTTLE_NOTE", + selectedTags: List = listOf("셰리", "오크") + ): Map = mapOf( + "source" to source, + "alcohol" to mapOf( + "alcoholId" to 1L, + "korName" to "검증 위스키", + "selectedTags" to selectedTags + ), + "comment" to "테스트 코멘트" + ) + + private fun dataNode(result: org.springframework.test.web.servlet.assertj.MvcTestResult) = mapper.valueToTree(parseResponse(result).data) + + private fun recommendedSpecId(): Long = curationSpecRepository.findByCode("RECOMMENDED_WHISKY").orElseThrow().id +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt index ff65ef61b..8a3752bb8 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -38,7 +38,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "1") @@ -55,7 +55,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "3") @@ -72,7 +72,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "2") @@ -103,7 +103,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "1") @@ -134,7 +134,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .param("rootPath", "admin/test") .param("uploadSize", "1") ).hasStatus4xxClientError() @@ -151,7 +151,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/upload-test") .param("uploadSize", "1") @@ -200,7 +200,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { val result = mockMvcTester .get() - .uri("/s3/presign-url") + .uri("/v1/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/multi-upload") .param("uploadSize", "3") diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt index 1c1a56c8f..180adcb62 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt @@ -45,7 +45,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps") + .uri("/v1/helps") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -64,7 +64,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps") + .uri("/v1/helps") .header("Authorization", "Bearer $accessToken") .param("status", StatusType.WAITING.name) ).hasStatusOk() @@ -85,7 +85,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps") + .uri("/v1/helps") .header("Authorization", "Bearer $accessToken") .param("type", HelpType.WHISKEY.name) ).hasStatusOk() @@ -99,7 +99,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { fun getHelpListWithoutAuth() { // when & then assertThat( - mockMvcTester.get().uri("/helps") + mockMvcTester.get().uri("/v1/helps") ).hasStatus4xxClientError() } } @@ -118,7 +118,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps/${help.id}") + .uri("/v1/helps/${help.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -128,7 +128,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps/${help.id}") + .uri("/v1/helps/${help.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -143,7 +143,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/helps/99999") + .uri("/v1/helps/99999") .header("Authorization", "Bearer $accessToken") ).hasStatus4xxClientError() .bodyJson() @@ -172,7 +172,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/helps/${help.id}/answer") + .uri("/v1/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -184,7 +184,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/helps/${help.id}/answer") + .uri("/v1/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -211,7 +211,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/helps/${help.id}/answer") + .uri("/v1/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -235,7 +235,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/helps/99999/answer") + .uri("/v1/helps/99999/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -262,7 +262,7 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/helps/${help.id}/answer") + .uri("/v1/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt index b467f1921..fe5cb75d9 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/region/AdminRegionIntegrationTest.kt @@ -40,7 +40,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/regions/${region.id}") + .uri("/v1/regions/${region.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -53,7 +53,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/regions/999999") + .uri("/v1/regions/999999") .header("Authorization", "Bearer $accessToken") ).hasStatus(404) } @@ -76,7 +76,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/regions") + .uri("/v1/regions") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -98,7 +98,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .post() - .uri("/regions") + .uri("/v1/regions") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -125,7 +125,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .put() - .uri("/regions/${region.id}") + .uri("/v1/regions/${region.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) @@ -146,7 +146,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/regions/${region.id}") + .uri("/v1/regions/${region.id}") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -162,7 +162,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .delete() - .uri("/regions/${root.id}") + .uri("/v1/regions/${root.id}") .header("Authorization", "Bearer $accessToken") ).hasStatus(409) } @@ -181,7 +181,7 @@ class AdminRegionIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .patch() - .uri("/regions/${region.id}/sort-order") + .uri("/v1/regions/${region.id}/sort-order") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/review/AdminReviewIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/review/AdminReviewIntegrationTest.kt new file mode 100644 index 000000000..d4226f91d --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/review/AdminReviewIntegrationTest.kt @@ -0,0 +1,242 @@ +package app.integration.review + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.domain.Alcohol +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.global.service.cursor.SortOrder +import app.bottlenote.review.constant.AdminReviewSortType +import app.bottlenote.review.constant.ReviewActiveStatus +import app.bottlenote.review.constant.ReviewDisplayStatus +import app.bottlenote.review.domain.Review +import app.bottlenote.review.fixture.ReviewTestFactory +import app.bottlenote.user.domain.User +import app.bottlenote.user.fixture.UserTestFactory +import com.fasterxml.jackson.databind.JsonNode +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime + +@Tag("admin_integration") +@DisplayName("[integration] Admin Review API 통합 테스트") +class AdminReviewIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + @Autowired + private lateinit var userTestFactory: UserTestFactory + + @Autowired + private lateinit var reviewTestFactory: ReviewTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Test + @DisplayName("기본 목록을 조회할 때 응답 필드와 전체 상태를 노출한다") + fun searchAdminReviews_returnsFieldsAndAllStatuses() { + // given + seedReviews() + + // when + val response = getReviews() + + // then + assertThat(response.get("success").asBoolean()).isTrue() + assertThat(response.get("data")).hasSize(3) + assertThat(response.get("data").flatMap { it.fieldNames().asSequence().toList() }) + .contains( + "reviewId", + "alcoholId", + "alcoholName", + "userId", + "userNickname", + "content", + "reviewRating", + "activeStatus", + "displayStatus", + "replyCount", + "createAt", + "lastModifyAt" + ) + assertThat(values(response, "activeStatus")) + .containsExactlyInAnyOrder("ACTIVE", "DELETED", "DISABLED") + assertThat(values(response, "displayStatus")) + .containsExactlyInAnyOrder("PUBLIC", "PRIVATE", "PUBLIC") + } + + @Test + @DisplayName("필터 7종을 단독 적용할 때 결과를 좁힌다") + fun searchAdminReviews_filtersBySingleCondition() { + // given + val fixture = seedReviews() + + // when & then + assertReviewIds(getReviews("alcoholId" to fixture.alcoholA.id.toString()), fixture.activePublic.id, fixture.disabledPublic.id) + assertReviewIds(getReviews("userId" to fixture.userB.id.toString()), fixture.deletedPrivate.id) + assertReviewIds(getReviews("activeStatus" to "DELETED"), fixture.deletedPrivate.id) + assertReviewIds(getReviews("displayStatus" to "PRIVATE"), fixture.deletedPrivate.id) + assertReviewIds(getReviews("keyword" to "AlphaReviewer"), fixture.activePublic.id) + assertReviewIds(getReviews("createdFrom" to "2026-01-02T00:00:00"), fixture.activePublic.id, fixture.deletedPrivate.id) + assertReviewIds(getReviews("createdTo" to "2026-01-02T23:59:59"), fixture.deletedPrivate.id, fixture.disabledPublic.id) + } + + @Test + @DisplayName("키워드를 리뷰 본문, 작성자 이메일, 주류명에 적용한다") + fun searchAdminReviews_filtersByKeywordTargets() { + // given + val fixture = seedReviews() + + // when & then + assertReviewIds(getReviews("keyword" to "smoky keyword"), fixture.activePublic.id) + assertReviewIds(getReviews("keyword" to "beta-admin"), fixture.deletedPrivate.id) + assertReviewIds(getReviews("keyword" to "Admin Macallan"), fixture.deletedPrivate.id) + assertReviewIds(getReviews("keyword" to "Admin Ardbeg"), fixture.activePublic.id, fixture.disabledPublic.id) + } + + @Test + @DisplayName("정렬 조건과 방향을 적용한다") + fun searchAdminReviews_sortsByTypeAndOrder() { + // given + val fixture = seedReviews() + + // when & then + assertReviewIds(sorted(AdminReviewSortType.CREATED_AT, SortOrder.DESC), fixture.activePublic.id, fixture.deletedPrivate.id, fixture.disabledPublic.id) + assertReviewIds(sorted(AdminReviewSortType.CREATED_AT, SortOrder.ASC), fixture.disabledPublic.id, fixture.deletedPrivate.id, fixture.activePublic.id) + assertReviewIds(sorted(AdminReviewSortType.REPLY_COUNT, SortOrder.DESC), fixture.activePublic.id, fixture.disabledPublic.id, fixture.deletedPrivate.id) + assertReviewIds(sorted(AdminReviewSortType.REPLY_COUNT, SortOrder.ASC), fixture.deletedPrivate.id, fixture.disabledPublic.id, fixture.activePublic.id) + assertReviewIds(sorted(AdminReviewSortType.UPDATED_AT, SortOrder.DESC), fixture.deletedPrivate.id, fixture.activePublic.id, fixture.disabledPublic.id) + assertReviewIds(sorted(AdminReviewSortType.UPDATED_AT, SortOrder.ASC), fixture.disabledPublic.id, fixture.activePublic.id, fixture.deletedPrivate.id) + } + + @Test + @DisplayName("페이지 메타 정보를 반환한다") + fun searchAdminReviews_returnsPageMeta() { + // given + seedReviews() + + // when + val response = getReviews("page" to "0", "size" to "2") + + // then + assertThat(response.get("data")).hasSize(2) + assertThat(response.at("/meta/page").asInt()).isEqualTo(0) + assertThat(response.at("/meta/size").asInt()).isEqualTo(2) + assertThat(response.at("/meta/totalElements").asLong()).isEqualTo(3) + assertThat(response.at("/meta/totalPages").asInt()).isEqualTo(2) + assertThat(response.at("/meta/hasNext").asBoolean()).isTrue() + } + + @Test + @DisplayName("페이지 요청 값이 범위를 벗어나면 400을 반환한다") + fun searchAdminReviews_whenPageRequestOutOfRange_returnsBadRequest() { + // when & then + assertInvalidReviewSearch("page" to "-1") + assertInvalidReviewSearch("size" to "0") + assertInvalidReviewSearch("size" to "101") + } + + private fun sorted( + sortType: AdminReviewSortType, + sortOrder: SortOrder + ): JsonNode = getReviews("sortType" to sortType.name, "sortOrder" to sortOrder.name) + + private fun getReviews(vararg params: Pair): JsonNode { + val request = mockMvcTester + .get() + .uri("/v1/reviews") + .header("Authorization", "Bearer $accessToken") + params.forEach { request.param(it.first, it.second) } + + val result = request.exchange() + assertThat(result).hasStatusOk() + return mapper.readTree(result.response.contentAsString) + } + + private fun assertInvalidReviewSearch(vararg params: Pair) { + val request = mockMvcTester + .get() + .uri("/v1/reviews") + .header("Authorization", "Bearer $accessToken") + params.forEach { request.param(it.first, it.second) } + + assertThat(request.exchange()).hasStatus(400) + } + + private fun assertReviewIds( + response: JsonNode, + vararg expected: Long + ) { + assertThat(reviewIds(response)).containsExactly(*expected.toTypedArray()) + } + + private fun reviewIds(response: JsonNode): List = response.get("data").map { it.get("reviewId").asLong() } + + private fun values( + response: JsonNode, + fieldName: String + ): List = response.get("data").map { it.get(fieldName).asText() } + + private fun seedReviews(): SeededReviews { + val userA = userTestFactory.persistUser("alpha-admin", "AlphaReviewer") + val userB = userTestFactory.persistUser("beta-admin", "BetaReviewer") + val userC = userTestFactory.persistUser("gamma-admin", "GammaReviewer") + val alcoholA = alcoholTestFactory.persistAlcoholWithName("어드민 아드벡", "Admin Ardbeg") + val alcoholB = alcoholTestFactory.persistAlcoholWithName("어드민 맥캘란", "Admin Macallan") + + val disabledPublic = reviewTestFactory.persistAdminReview( + userC, + alcoholA, + "disabled oak note", + ReviewActiveStatus.DISABLED, + ReviewDisplayStatus.PUBLIC, + 3.0, + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 4, 10, 0) + ) + val deletedPrivate = reviewTestFactory.persistAdminReview( + userB, + alcoholB, + "deleted private peat note", + ReviewActiveStatus.DELETED, + ReviewDisplayStatus.PRIVATE, + 4.0, + LocalDateTime.of(2026, 1, 2, 10, 0), + LocalDateTime.of(2026, 1, 6, 10, 0) + ) + val activePublic = reviewTestFactory.persistAdminReview( + userA, + alcoholA, + "active smoky keyword note", + ReviewActiveStatus.ACTIVE, + ReviewDisplayStatus.PUBLIC, + 4.5, + LocalDateTime.of(2026, 1, 3, 10, 0), + LocalDateTime.of(2026, 1, 5, 10, 0) + ) + reviewTestFactory.persistReviewReply(activePublic, userB) + reviewTestFactory.persistReviewReply(activePublic, userC) + reviewTestFactory.persistReviewReply(disabledPublic, userA) + + return SeededReviews(userA, userB, userC, alcoholA, alcoholB, activePublic, deletedPrivate, disabledPublic) + } + + private data class SeededReviews( + val userA: User, + val userB: User, + val userC: User, + val alcoholA: Alcohol, + val alcoholB: Alcohol, + val activePublic: Review, + val deletedPrivate: Review, + val disabledPublic: Review + ) +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt index 107390eb1..4610bc42f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt @@ -56,7 +56,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() .bodyJson() @@ -75,7 +75,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("keyword", "검색대상") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -95,7 +95,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("keyword", "searchtarget") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -116,7 +116,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("status", "ACTIVE") .header("Authorization", "Bearer $accessToken") ).hasStatusOk() @@ -140,7 +140,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { // when val result = mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("keyword", user.nickName) .header("Authorization", "Bearer $accessToken") .exchange() @@ -175,7 +175,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { // when & then (리뷰 많은 순 DESC -> userB가 먼저) val result = mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("sortType", "REVIEW_COUNT") .param("sortOrder", "DESC") .header("Authorization", "Bearer $accessToken") @@ -198,7 +198,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { assertThat( mockMvcTester .get() - .uri("/users") + .uri("/v1/users") .param("page", "0") .param("size", "2") .header("Authorization", "Bearer $accessToken") @@ -216,7 +216,7 @@ class AdminUsersIntegrationTest : IntegrationTestSupport() { @DisplayName("인증 없이 요청하면 실패한다") fun requestWithoutAuth() { assertThat( - mockMvcTester.get().uri("/users") + mockMvcTester.get().uri("/v1/users") ).hasStatus4xxClientError() } } diff --git a/bottlenote-batch/src/main/resources/application.yml b/bottlenote-batch/src/main/resources/application.yml index eb51386a1..ffeeb09e8 100644 --- a/bottlenote-batch/src/main/resources/application.yml +++ b/bottlenote-batch/src/main/resources/application.yml @@ -80,4 +80,4 @@ security: jwt: secret-key: ${JWT_SECRET_KEY} nonce: - salt: ${NONCE_SALT} + salt: ${NONCE_SALT:bottle-note-secure-salt-2024} diff --git a/bottlenote-mono/build.gradle b/bottlenote-mono/build.gradle index 1c84e6120..4564a4363 100644 --- a/bottlenote-mono/build.gradle +++ b/bottlenote-mono/build.gradle @@ -4,6 +4,7 @@ dependencies { // ===== Core ===== implementation libs.spring.boot.starter.web + implementation libs.spring.boot.starter.graphql implementation libs.spring.boot.starter.validation // ===== Database ===== diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java index 60185e559..b249a052d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java @@ -12,6 +12,10 @@ public interface JpaAlcoholQueryRepository extends AlcoholQueryRepository, JpaRepository, CustomAlcoholQueryRepository { + @Override + @Query("select distinct a from alcohol a left join fetch a.region where a.id in :ids") + List findAllByIdIn(@Param("ids") List ids); + @Override @Query( """ diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/Curation.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/Curation.java new file mode 100644 index 000000000..4ff794e11 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/Curation.java @@ -0,0 +1,103 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("spec 기반 큐레이션") +@Entity(name = "curation") +@Table(name = "curation") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Curation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("큐레이션 스펙 ID") + @Column(name = "spec_id", nullable = false) + private Long specId; + + @Comment("큐레이션명") + @Column(name = "name", nullable = false, length = 120) + private String name; + + @Comment("큐레이션 설명") + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Comment("대표 이미지 URL") + @Column(name = "cover_image_url", nullable = false, length = 2048) + private String coverImageUrl; + + @Comment("추가 이미지 URL 2") + @Column(name = "image_url_2", length = 2048) + private String imageUrl2; + + @Comment("추가 이미지 URL 3") + @Column(name = "image_url_3", length = 2048) + private String imageUrl3; + + @Comment("노출 시작일") + @Column(name = "exposure_start_date") + private LocalDate exposureStartDate; + + @Comment("노출 종료일") + @Column(name = "exposure_end_date") + private LocalDate exposureEndDate; + + @Builder.Default + @Comment("노출 순서") + @Column(name = "display_order", nullable = false) + private Integer displayOrder = 0; + + @Builder.Default + @Comment("활성화 여부") + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + public void update( + Long specId, + String name, + String description, + String coverImageUrl, + String imageUrl2, + String imageUrl3, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + Integer displayOrder, + Boolean isActive) { + this.specId = specId; + this.name = name; + this.description = description; + this.coverImageUrl = coverImageUrl; + this.imageUrl2 = imageUrl2; + this.imageUrl3 = imageUrl3; + this.exposureStartDate = exposureStartDate; + this.exposureEndDate = exposureEndDate; + this.displayOrder = displayOrder != null ? displayOrder : this.displayOrder; + this.isActive = isActive != null ? isActive : this.isActive; + } + + public void updateStatus(Boolean isActive) { + this.isActive = isActive; + } + + public void updateDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java new file mode 100644 index 000000000..9d04615cf --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java @@ -0,0 +1,44 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.domain.BaseEntity; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.Type; + +@Comment("spec 기반 큐레이션 payload") +@Entity(name = "curation_extension") +@Table(name = "curation_extension") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CurationExtension extends BaseEntity { + + @Id + @Comment("큐레이션 ID") + @Column(name = "curation_id") + private Long curationId; + + @Comment("큐레이션 스펙 ID") + @Column(name = "spec_id", nullable = false) + private Long specId; + + @Comment("request spec 검증을 통과한 payload") + @Column(name = "payload", nullable = false, columnDefinition = "json") + @Type(JsonType.class) + private Object payload; + + public void update(Long specId, Object payload) { + this.specId = specId; + this.payload = payload; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java new file mode 100644 index 000000000..bf79b8edd --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java @@ -0,0 +1,14 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.annotation.DomainRepository; +import java.util.Optional; + +@DomainRepository +public interface CurationExtensionRepository { + + Optional findByCurationId(Long curationId); + + CurationExtension save(CurationExtension curationExtension); + + void deleteByCurationId(Long curationId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java new file mode 100644 index 000000000..cb24aecef --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java @@ -0,0 +1,26 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.annotation.DomainRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@DomainRepository +public interface CurationRepository { + + Optional findById(Long id); + + List findAllByIsActiveTrueOrderByDisplayOrderAscIdAsc(); + + List findAllVisibleOn(LocalDate today); + + Optional findVisibleById(Long id, LocalDate today); + + Page searchForAdmin(String keyword, Boolean isActive, Pageable pageable); + + Curation save(Curation curation); + + void delete(Curation curation); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpec.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpec.java new file mode 100644 index 000000000..fe3f455a6 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpec.java @@ -0,0 +1,92 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.domain.BaseEntity; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.Type; + +@Comment("spec 기반 큐레이션 스펙") +@Entity(name = "curation_spec") +@Table(name = "curation_spec") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CurationSpec extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("스펙 코드") + @Column(name = "code", nullable = false, length = 80) + private String code; + + @Comment("스펙 표시명") + @Column(name = "name", nullable = false, length = 120) + private String name; + + @Comment("스펙 설명") + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Builder.Default + @Comment("OpenAPI request schema") + @Column(name = "request_spec", nullable = false, columnDefinition = "json") + @Type(JsonType.class) + private Map requestSpec = new LinkedHashMap<>(); + + @Builder.Default + @Comment("OpenAPI response schema") + @Column(name = "response_spec", nullable = false, columnDefinition = "json") + @Type(JsonType.class) + private Map responseSpec = new LinkedHashMap<>(); + + @Comment("GraphQL hydration 식별자") + @Column(name = "hydrator_key", nullable = false, length = 80) + private String hydratorKey; + + @Builder.Default + @Comment("스펙 버전") + @Column(name = "version", nullable = false) + private Integer version = 1; + + @Builder.Default + @Comment("활성화 여부") + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + public void update( + String name, + String description, + Map requestSpec, + Map responseSpec, + String hydratorKey, + Integer version, + Boolean isActive) { + this.name = name; + this.description = description; + this.requestSpec = copyOf(requestSpec); + this.responseSpec = copyOf(responseSpec); + this.hydratorKey = hydratorKey; + this.version = version != null ? version : this.version; + this.isActive = isActive != null ? isActive : this.isActive; + } + + private static Map copyOf(Map value) { + return value != null ? new LinkedHashMap<>(value) : new LinkedHashMap<>(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpecRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpecRepository.java new file mode 100644 index 000000000..18a91eeb5 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationSpecRepository.java @@ -0,0 +1,22 @@ +package app.bottlenote.curation.domain; + +import app.bottlenote.common.annotation.DomainRepository; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@DomainRepository +public interface CurationSpecRepository { + + Optional findById(Long id); + + Optional findByCode(String code); + + List findAllByIsActiveTrueOrderByIdAsc(); + + List findAllByIdIn(Collection ids); + + boolean existsByCode(String code); + + CurationSpec save(CurationSpec curationSpec); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationCreateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationCreateRequest.java new file mode 100644 index 000000000..584d6f1ce --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationCreateRequest.java @@ -0,0 +1,27 @@ +package app.bottlenote.curation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; + +public record CurationCreateRequest( + @NotNull(message = "CURATION_SPEC_ID_REQUIRED") Long specId, + @NotBlank(message = "CURATION_NAME_REQUIRED") String name, + String description, + @NotEmpty(message = "CURATION_IMAGE_URLS_REQUIRED") + @Size(max = 3, message = "CURATION_IMAGE_URLS_MAX_SIZE") + List imageUrls, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + Integer displayOrder, + Boolean isActive, + @NotNull(message = "CURATION_PAYLOAD_REQUIRED") Object payload) { + + public CurationCreateRequest { + displayOrder = displayOrder != null ? displayOrder : 0; + isActive = isActive != null ? isActive : true; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java new file mode 100644 index 000000000..7f82f2322 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java @@ -0,0 +1,12 @@ +package app.bottlenote.curation.dto.request; + +import lombok.Builder; + +public record CurationSearchRequest(String keyword, Boolean isActive, Integer page, Integer size) { + + @Builder + public CurationSearchRequest { + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationUpdateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationUpdateRequest.java new file mode 100644 index 000000000..642232651 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationUpdateRequest.java @@ -0,0 +1,21 @@ +package app.bottlenote.curation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; + +public record CurationUpdateRequest( + @NotNull(message = "CURATION_SPEC_ID_REQUIRED") Long specId, + @NotBlank(message = "CURATION_NAME_REQUIRED") String name, + String description, + @NotEmpty(message = "CURATION_IMAGE_URLS_REQUIRED") + @Size(max = 3, message = "CURATION_IMAGE_URLS_MAX_SIZE") + List imageUrls, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + @NotNull(message = "CURATION_DISPLAY_ORDER_REQUIRED") Integer displayOrder, + @NotNull(message = "CURATION_IS_ACTIVE_REQUIRED") Boolean isActive, + @NotNull(message = "CURATION_PAYLOAD_REQUIRED") Object payload) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationDetailResponse.java new file mode 100644 index 000000000..ba849897e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationDetailResponse.java @@ -0,0 +1,20 @@ +package app.bottlenote.curation.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminSpecBasedCurationDetailResponse( + Long id, + String name, + String description, + String coverImageUrl, + List imageUrls, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + Integer displayOrder, + Boolean isActive, + LocalDateTime createdAt, + LocalDateTime modifiedAt, + CurationSpecResponse spec, + Object payload) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationListResponse.java new file mode 100644 index 000000000..ebd1ecf16 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/AdminSpecBasedCurationListResponse.java @@ -0,0 +1,12 @@ +package app.bottlenote.curation.dto.response; + +import java.time.LocalDateTime; + +public record AdminSpecBasedCurationListResponse( + Long id, + Long specId, + String specCode, + String name, + Integer displayOrder, + Boolean isActive, + LocalDateTime createdAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecResponse.java new file mode 100644 index 000000000..33e756aa2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecResponse.java @@ -0,0 +1,14 @@ +package app.bottlenote.curation.dto.response; + +import java.util.Map; + +public record CurationSpecResponse( + Long id, + String code, + String name, + String description, + String hydratorKey, + Integer version, + Boolean isActive, + Map requestSpec, + Map responseSpec) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java new file mode 100644 index 000000000..a41e3de5a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java @@ -0,0 +1,8 @@ +package app.bottlenote.curation.dto.response; + +public record CurationSpecSyncResponse(int createdCount, int updatedCount) { + + public int totalCount() { + return createdCount + updatedCount; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationDetailResponse.java new file mode 100644 index 000000000..3fe512960 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationDetailResponse.java @@ -0,0 +1,23 @@ +package app.bottlenote.curation.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public record ProductSpecBasedCurationDetailResponse( + Long id, + String name, + String description, + String coverImageUrl, + List imageUrls, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + Integer displayOrder, + LocalDateTime createAt, + SpecMeta spec, + Object payload) { + + public record SpecMeta( + Long id, String code, String name, String container, Map responseSpec) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationListResponse.java new file mode 100644 index 000000000..c835b487b --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/ProductSpecBasedCurationListResponse.java @@ -0,0 +1,19 @@ +package app.bottlenote.curation.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record ProductSpecBasedCurationListResponse( + Long id, + Long specId, + String specCode, + String specName, + String name, + String description, + String coverImageUrl, + List imageUrls, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + Integer displayOrder, + LocalDateTime createAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationException.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationException.java new file mode 100644 index 000000000..ef22b7140 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationException.java @@ -0,0 +1,12 @@ +package app.bottlenote.curation.exception; + +import app.bottlenote.global.exception.custom.AbstractCustomException; +import lombok.Getter; + +@Getter +public class CurationException extends AbstractCustomException { + + public CurationException(CurationExceptionCode code) { + super(code); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationExceptionCode.java new file mode 100644 index 000000000..151571522 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/exception/CurationExceptionCode.java @@ -0,0 +1,31 @@ +package app.bottlenote.curation.exception; + +import app.bottlenote.global.exception.custom.code.ExceptionCode; +import org.springframework.http.HttpStatus; + +public enum CurationExceptionCode implements ExceptionCode { + CURATION_SPEC_NOT_FOUND(HttpStatus.NOT_FOUND, "큐레이션 스펙을 찾을 수 없습니다."), + CURATION_SPEC_DUPLICATE_CODE(HttpStatus.CONFLICT, "동일한 큐레이션 스펙 코드가 이미 존재합니다."), + CURATION_NOT_FOUND(HttpStatus.NOT_FOUND, "큐레이션을 찾을 수 없습니다."), + CURATION_PAYLOAD_INVALID(HttpStatus.BAD_REQUEST, "큐레이션 payload가 스펙과 일치하지 않습니다."), + CURATION_RESPONSE_INVALID(HttpStatus.INTERNAL_SERVER_ERROR, "큐레이션 응답이 스펙과 일치하지 않습니다."), + CURATION_GRAPHQL_EXECUTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "큐레이션 GraphQL 보강에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String message; + + CurationExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolver.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolver.java new file mode 100644 index 000000000..b882cfc4f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolver.java @@ -0,0 +1,83 @@ +package app.bottlenote.curation.graphql; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.curation.service.GraphQLCurationAlcoholService; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class GraphQLCurationAlcoholResolver { + + private final GraphQLCurationAlcoholService curationAlcoholGraphQLService; + + @QueryMapping + public List alcohols(@Argument List ids) { + return curationAlcoholGraphQLService.findAlcohols(ids); + } + + @SchemaMapping(typeName = "Alcohol", field = "alcoholId") + public Long alcoholId(Alcohol alcohol) { + return alcohol.getId(); + } + + @SchemaMapping(typeName = "Alcohol", field = "regionName") + public String regionName(Alcohol alcohol) { + return curationAlcoholGraphQLService.regionName(alcohol); + } + + @BatchMapping(typeName = "Alcohol", field = "rating") + public Map ratings(List alcohols) { + Map stats = + curationAlcoholGraphQLService.ratingStats(alcohols); + return mapByAlcohol(alcohols, alcohol -> ratingStatsOf(stats, alcohol).rating()); + } + + @BatchMapping(typeName = "Alcohol", field = "totalRatingsCount") + public Map totalRatingsCounts(List alcohols) { + Map stats = + curationAlcoholGraphQLService.ratingStats(alcohols); + return mapByAlcohol(alcohols, alcohol -> ratingStatsOf(stats, alcohol).totalRatingsCount()); + } + + @BatchMapping(typeName = "Alcohol", field = "reviewCount") + public Map reviewCounts(List alcohols) { + Map counts = curationAlcoholGraphQLService.reviewCounts(alcohols); + return mapByAlcohol(alcohols, alcohol -> counts.getOrDefault(alcohol.getId(), 0L)); + } + + @BatchMapping(typeName = "Alcohol", field = "totalPickCount") + public Map totalPickCounts(List alcohols) { + Map counts = curationAlcoholGraphQLService.pickCounts(alcohols); + return mapByAlcohol(alcohols, alcohol -> counts.getOrDefault(alcohol.getId(), 0L)); + } + + private Map mapByAlcohol(List alcohols, Function mapper) { + if (alcohols == null || alcohols.isEmpty()) { + return Map.of(); + } + return alcohols.stream() + .collect( + Collectors.toMap( + Function.identity(), mapper, (left, right) -> left, LinkedHashMap::new)); + } + + private AlcoholRatingStatsResponse ratingStatsOf( + Map stats, Alcohol alcohol) { + if (alcohol == null || alcohol.getId() == null) { + return new AlcoholRatingStatsResponse(null, 0.0, 0L); + } + return stats.getOrDefault( + alcohol.getId(), new AlcoholRatingStatsResponse(alcohol.getId(), 0.0, 0L)); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java new file mode 100644 index 000000000..d1013796a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java @@ -0,0 +1,16 @@ +package app.bottlenote.curation.repository; + +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +@JpaRepositoryImpl +public interface JpaCurationExtensionRepository + extends CurationExtensionRepository, JpaRepository { + + Optional findByCurationId(Long curationId); + + void deleteByCurationId(Long curationId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java new file mode 100644 index 000000000..c6f6971ae --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java @@ -0,0 +1,53 @@ +package app.bottlenote.curation.repository; + +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.curation.domain.Curation; +import app.bottlenote.curation.domain.CurationRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@JpaRepositoryImpl +public interface JpaCurationRepository extends CurationRepository, JpaRepository { + + List findAllByIsActiveTrueOrderByDisplayOrderAscIdAsc(); + + @Override + @Query( + """ + SELECT c + FROM curation c + WHERE c.isActive = true + AND (c.exposureStartDate IS NULL OR c.exposureStartDate <= :today) + AND (c.exposureEndDate IS NULL OR c.exposureEndDate >= :today) + ORDER BY c.displayOrder ASC, c.id ASC + """) + List findAllVisibleOn(@Param("today") LocalDate today); + + @Override + @Query( + """ + SELECT c + FROM curation c + WHERE c.id = :id + AND c.isActive = true + AND (c.exposureStartDate IS NULL OR c.exposureStartDate <= :today) + AND (c.exposureEndDate IS NULL OR c.exposureEndDate >= :today) + """) + Optional findVisibleById(@Param("id") Long id, @Param("today") LocalDate today); + + @Query( + """ + SELECT c + FROM curation c + WHERE (:keyword IS NULL OR :keyword = '' OR c.name LIKE CONCAT('%', :keyword, '%')) + AND (:isActive IS NULL OR c.isActive = :isActive) + ORDER BY c.displayOrder ASC, c.id ASC + """) + Page searchForAdmin(String keyword, Boolean isActive, Pageable pageable); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationSpecRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationSpecRepository.java new file mode 100644 index 000000000..96acc0db5 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationSpecRepository.java @@ -0,0 +1,22 @@ +package app.bottlenote.curation.repository; + +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +@JpaRepositoryImpl +public interface JpaCurationSpecRepository + extends CurationSpecRepository, JpaRepository { + + Optional findByCode(String code); + + List findAllByIsActiveTrueOrderByIdAsc(); + + List findAllByIdIn(Collection ids); + + boolean existsByCode(String code); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java new file mode 100644 index 000000000..0b25e116e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java @@ -0,0 +1,204 @@ +package app.bottlenote.curation.service; + +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_NOT_FOUND; +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_PAYLOAD_INVALID; +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_SPEC_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_UPDATED; + +import app.bottlenote.curation.domain.Curation; +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import app.bottlenote.curation.domain.CurationRepository; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import app.bottlenote.curation.dto.request.CurationCreateRequest; +import app.bottlenote.curation.dto.request.CurationSearchRequest; +import app.bottlenote.curation.dto.request.CurationUpdateRequest; +import app.bottlenote.curation.dto.response.AdminSpecBasedCurationDetailResponse; +import app.bottlenote.curation.dto.response.AdminSpecBasedCurationListResponse; +import app.bottlenote.curation.dto.response.CurationSpecResponse; +import app.bottlenote.curation.exception.CurationException; +import app.bottlenote.curation.service.CurationPayloadValidator.MapBackedSchema; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.dto.response.AdminResultResponse; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminSpecBasedCurationService { + + private final CurationSpecRepository curationSpecRepository; + private final CurationRepository curationRepository; + private final CurationExtensionRepository curationExtensionRepository; + private final CurationPayloadValidator curationPayloadValidator; + + @Transactional(readOnly = true) + public List listSpecs() { + return curationSpecRepository.findAllByIsActiveTrueOrderByIdAsc().stream() + .map(this::toSpecResponse) + .toList(); + } + + @Transactional(readOnly = true) + public CurationSpecResponse getSpecDetail(Long specId) { + return toSpecResponse(getSpec(specId)); + } + + @Transactional(readOnly = true) + public GlobalResponse search(CurationSearchRequest request) { + PageRequest pageable = PageRequest.of(request.page(), request.size()); + Page page = + curationRepository.searchForAdmin(request.keyword(), request.isActive(), pageable); + Map specMap = + curationSpecRepository + .findAllByIdIn( + page.getContent().stream().map(Curation::getSpecId).collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + return GlobalResponse.fromPage( + page.map(curation -> toListResponse(curation, specMap.get(curation.getSpecId())))); + } + + @Transactional(readOnly = true) + public AdminSpecBasedCurationDetailResponse getDetail(Long curationId) { + Curation curation = getCuration(curationId); + CurationSpec spec = getSpec(curation.getSpecId()); + CurationExtension extension = getExtension(curationId); + return toDetailResponse(curation, spec, extension); + } + + @Transactional + public AdminResultResponse create(CurationCreateRequest request) { + CurationSpec spec = getSpec(request.specId()); + validatePayload(spec, request.payload()); + Curation saved = curationRepository.save(toCuration(request, spec)); + curationExtensionRepository.save( + CurationExtension.builder() + .curationId(saved.getId()) + .specId(spec.getId()) + .payload(request.payload()) + .build()); + return AdminResultResponse.of(CURATION_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse update(Long curationId, CurationUpdateRequest request) { + Curation curation = getCuration(curationId); + CurationSpec spec = getSpec(request.specId()); + validatePayload(spec, request.payload()); + curation.update( + spec.getId(), + request.name(), + request.description(), + request.imageUrls().get(0), + request.imageUrls().size() > 1 ? request.imageUrls().get(1) : null, + request.imageUrls().size() > 2 ? request.imageUrls().get(2) : null, + request.exposureStartDate(), + request.exposureEndDate(), + request.displayOrder(), + request.isActive()); + getExtension(curationId).update(spec.getId(), request.payload()); + return AdminResultResponse.of(CURATION_UPDATED, curationId); + } + + private Curation toCuration(CurationCreateRequest request, CurationSpec spec) { + return Curation.builder() + .specId(spec.getId()) + .name(request.name()) + .description(request.description()) + .coverImageUrl(request.imageUrls().get(0)) + .imageUrl2(request.imageUrls().size() > 1 ? request.imageUrls().get(1) : null) + .imageUrl3(request.imageUrls().size() > 2 ? request.imageUrls().get(2) : null) + .exposureStartDate(request.exposureStartDate()) + .exposureEndDate(request.exposureEndDate()) + .displayOrder(request.displayOrder()) + .isActive(request.isActive()) + .build(); + } + + private Curation getCuration(Long curationId) { + return curationRepository + .findById(curationId) + .orElseThrow(() -> new CurationException(CURATION_NOT_FOUND)); + } + + private CurationSpec getSpec(Long specId) { + return curationSpecRepository + .findById(specId) + .orElseThrow(() -> new CurationException(CURATION_SPEC_NOT_FOUND)); + } + + private CurationExtension getExtension(Long curationId) { + return curationExtensionRepository + .findByCurationId(curationId) + .orElseThrow(() -> new CurationException(CURATION_NOT_FOUND)); + } + + private void validatePayload(CurationSpec spec, Object payload) { + List errors = + curationPayloadValidator.validate(new MapBackedSchema(spec.getRequestSpec()), payload); + if (!errors.isEmpty()) { + throw new CurationException(CURATION_PAYLOAD_INVALID); + } + } + + private AdminSpecBasedCurationListResponse toListResponse(Curation curation, CurationSpec spec) { + return new AdminSpecBasedCurationListResponse( + curation.getId(), + curation.getSpecId(), + spec != null ? spec.getCode() : null, + curation.getName(), + curation.getDisplayOrder(), + curation.getIsActive(), + curation.getCreateAt()); + } + + private AdminSpecBasedCurationDetailResponse toDetailResponse( + Curation curation, CurationSpec spec, CurationExtension extension) { + return new AdminSpecBasedCurationDetailResponse( + curation.getId(), + curation.getName(), + curation.getDescription(), + curation.getCoverImageUrl(), + imageUrls(curation), + curation.getExposureStartDate(), + curation.getExposureEndDate(), + curation.getDisplayOrder(), + curation.getIsActive(), + curation.getCreateAt(), + curation.getLastModifyAt(), + toSpecResponse(spec), + extension.getPayload()); + } + + private List imageUrls(Curation curation) { + return java.util.stream.Stream.of( + curation.getCoverImageUrl(), curation.getImageUrl2(), curation.getImageUrl3()) + .filter(Objects::nonNull) + .filter(imageUrl -> !imageUrl.isBlank()) + .toList(); + } + + private CurationSpecResponse toSpecResponse(CurationSpec spec) { + return new CurationSpecResponse( + spec.getId(), + spec.getCode(), + spec.getName(), + spec.getDescription(), + spec.getHydratorKey(), + spec.getVersion(), + spec.getIsActive(), + spec.getRequestSpec(), + spec.getResponseSpec()); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java new file mode 100644 index 000000000..72793031c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java @@ -0,0 +1,188 @@ +package app.bottlenote.curation.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class CurationPayloadValidator { + + private static final int MAX_PAYLOAD_BYTES = 128 * 1024; + + private final ObjectMapper objectMapper; + + public CurationPayloadValidator(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public List validate(MapBackedSchema requestSpec, Object payload) { + JsonNode schema = objectMapper.valueToTree(requestSpec.value()); + JsonNode payloadNode = objectMapper.valueToTree(payload); + + if (payloadNode == null || payloadNode.isNull()) { + return List.of("payload가 null입니다."); + } + int payloadBytes = payloadNode.toString().getBytes(StandardCharsets.UTF_8).length; + if (payloadBytes > MAX_PAYLOAD_BYTES) { + return List.of("payload size must be <= " + MAX_PAYLOAD_BYTES + ", actual=" + payloadBytes); + } + + if ("array".equals(schema.path("x-container").asText())) { + if (!payloadNode.isArray()) { + return List.of("$ payload는 array여야 합니다."); + } + if (payloadNode.isEmpty()) { + return List.of("payload 배열은 비어 있을 수 없습니다."); + } + List errors = new ArrayList<>(); + for (int i = 0; i < payloadNode.size(); i++) { + validateNode(schema, payloadNode.get(i), "$[" + i + "]", true, errors); + } + return errors; + } + + List errors = new ArrayList<>(); + validateNode(schema, payloadNode, "$", true, errors); + return errors; + } + + private void validateNode( + JsonNode schema, JsonNode payload, String path, boolean required, List errors) { + if (payload == null || payload.isMissingNode()) { + if (required) { + errors.add(path + " 필드는 필수입니다."); + } + return; + } + + boolean nullable = schema.path("nullable").asBoolean(false); + if (payload.isNull()) { + if (required && !nullable) { + errors.add(path + " 필드는 null일 수 없습니다."); + } + return; + } + + String type = schema.path("type").asText(""); + if (!matchesType(type, payload)) { + errors.add(path + " 타입이 " + type + "이어야 합니다."); + return; + } + + validateEnum(schema, payload, path, errors); + validateString(schema, payload, path, errors); + validateNumber(schema, payload, path, errors); + + if (payload.isObject()) { + validateObject(schema, payload, path, errors); + } + if (payload.isArray()) { + validateArray(schema, payload, path, errors); + } + } + + private void validateObject(JsonNode schema, JsonNode payload, String path, List errors) { + JsonNode requiredFields = schema.path("required"); + JsonNode properties = schema.path("properties"); + + if (requiredFields.isArray()) { + for (JsonNode field : requiredFields) { + String fieldName = field.asText(); + JsonNode value = payload.get(fieldName); + if (value == null || value.isNull()) { + errors.add(path + "." + fieldName + " 필드는 필수입니다."); + } + } + } + + if (!properties.isObject()) { + return; + } + + Iterator names = properties.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + JsonNode value = payload.get(name); + if (value == null) { + continue; + } + validateNode(properties.get(name), value, path + "." + name, false, errors); + } + } + + private void validateArray(JsonNode schema, JsonNode payload, String path, List errors) { + if (schema.has("minItems") && payload.size() < schema.get("minItems").asInt()) { + errors.add(path + " 배열 크기는 최소 " + schema.get("minItems").asInt() + "개여야 합니다."); + } + if (schema.has("maxItems") && payload.size() > schema.get("maxItems").asInt()) { + errors.add(path + " 배열 크기는 최대 " + schema.get("maxItems").asInt() + "개여야 합니다."); + } + + JsonNode itemSchema = schema.path("items"); + if (!itemSchema.isObject()) { + return; + } + for (int i = 0; i < payload.size(); i++) { + validateNode(itemSchema, payload.get(i), path + "[" + i + "]", true, errors); + } + } + + private void validateEnum(JsonNode schema, JsonNode payload, String path, List errors) { + JsonNode enumValues = schema.path("enum"); + if (!enumValues.isArray()) { + return; + } + for (JsonNode enumValue : enumValues) { + if (enumValue.equals(payload)) { + return; + } + } + errors.add(path + " 값이 허용된 enum이 아닙니다."); + } + + private void validateString(JsonNode schema, JsonNode payload, String path, List errors) { + if (!payload.isTextual()) { + return; + } + String value = payload.asText(); + if (schema.has("minLength") && value.length() < schema.get("minLength").asInt()) { + errors.add(path + " 문자열 길이는 최소 " + schema.get("minLength").asInt() + "자여야 합니다."); + } + if (schema.has("maxLength") && value.length() > schema.get("maxLength").asInt()) { + errors.add(path + " 문자열 길이는 최대 " + schema.get("maxLength").asInt() + "자여야 합니다."); + } + } + + private void validateNumber(JsonNode schema, JsonNode payload, String path, List errors) { + if (!payload.isNumber()) { + return; + } + BigDecimal value = payload.decimalValue(); + if (schema.has("minimum") && value.compareTo(schema.get("minimum").decimalValue()) < 0) { + errors.add(path + " 값은 최소 " + schema.get("minimum").asText() + " 이상이어야 합니다."); + } + if (schema.has("maximum") && value.compareTo(schema.get("maximum").decimalValue()) > 0) { + errors.add(path + " 값은 최대 " + schema.get("maximum").asText() + " 이하여야 합니다."); + } + } + + private boolean matchesType(String type, JsonNode payload) { + return switch (type) { + case "", "any" -> true; + case "object" -> payload.isObject(); + case "array" -> payload.isArray(); + case "string" -> payload.isTextual(); + case "integer" -> payload.isIntegralNumber(); + case "number" -> payload.isNumber(); + case "boolean" -> payload.isBoolean(); + default -> true; + }; + } + + public record MapBackedSchema(Object value) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java new file mode 100644 index 000000000..76f59a156 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java @@ -0,0 +1,283 @@ +package app.bottlenote.curation.service; + +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_RESPONSE_INVALID; + +import app.bottlenote.curation.exception.CurationException; +import app.bottlenote.curation.service.CurationPayloadValidator.MapBackedSchema; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CurationResponseMaterializer { + + private static final String JSON_PATH_ROOT = "$"; + + private final ObjectMapper objectMapper; + private final GraphQLCurationQueryBuilder queryBuilder; + private final GraphQLCurationExecutor graphqlExecutor; + private final CurationPayloadValidator payloadValidator; + + public Object materialize( + Long curationId, String specCode, Map responseSpec, Object payload) { + JsonNode payloadNode = objectMapper.valueToTree(payload); + JsonNode responseSpecNode = objectMapper.valueToTree(responseSpec); + List queries = + queryBuilder.build(responseSpecNode, payloadNode); + + JsonNode hydrated = payloadNode; + for (int i = 0; i < queries.size(); i++) { + GraphQLCurationQueryBuilder.Result query = queries.get(i); + if (hasNoArgumentValues(query)) { + hydrated = applyHydration(hydrated, query, emptyHydrationResult(query)); + continue; + } + + Map result = graphqlExecutor.execute(curationId, i, query); + assertNoGraphQLErrors(curationId, specCode, query, result); + hydrated = applyHydration(hydrated, query, result); + } + + Object materialized = objectMapper.convertValue(hydrated, Object.class); + List errors = + payloadValidator.validate(new MapBackedSchema(responseSpec), materialized); + if (!errors.isEmpty()) { + log.error( + "Curation responseSpec mismatch. curationId={}, specCode={}, errors={}", + curationId, + specCode, + errors); + throw new CurationException(CURATION_RESPONSE_INVALID); + } + return materialized; + } + + private boolean hasNoArgumentValues(GraphQLCurationQueryBuilder.Result query) { + return query.variables().values().stream().allMatch(this::isEmptyArgumentValue); + } + + private boolean isEmptyArgumentValue(Object value) { + if (value == null) { + return true; + } + return value instanceof List list && list.isEmpty(); + } + + private Map emptyHydrationResult(GraphQLCurationQueryBuilder.Result query) { + return Map.of("data", Map.of(query.entryField(), List.of())); + } + + private void assertNoGraphQLErrors( + Long curationId, + String specCode, + GraphQLCurationQueryBuilder.Result query, + Map result) { + if (result == null || !(result.get("errors") instanceof List errors) || errors.isEmpty()) { + return; + } + log.error( + "Curation GraphQL hydration failed. curationId={}, specCode={}, payloadPath={}, entryField={}, errors={}", + curationId, + specCode, + query.payloadPath(), + query.entryField(), + errors); + throw new CurationException( + app.bottlenote.curation.exception.CurationExceptionCode.CURATION_GRAPHQL_EXECUTION_FAILED); + } + + @SuppressWarnings("unchecked") + private JsonNode applyHydration( + JsonNode payload, GraphQLCurationQueryBuilder.Result query, Map result) { + if (payload == null || result == null || !(result.get("data") instanceof Map data)) { + return payload; + } + + Object raw = data.get(query.entryField()); + List hydrationList = normalizeHydration(raw); + Map> byKey = indexByResultKey(hydrationList, query.resultKey()); + if (JSON_PATH_ROOT.equals(query.payloadPath())) { + return mergeSubtree(payload, query, byKey); + } + + JsonNode rootCopy = payload.deepCopy(); + JsonNode subtree = GraphQLCurationQueryBuilder.navigate(rootCopy, query.payloadPath()); + JsonNode merged = mergeSubtree(subtree, query, byKey); + if (merged != null) { + setAtPath(rootCopy, query.payloadPath(), merged); + } + return rootCopy; + } + + private List normalizeHydration(Object raw) { + if (raw instanceof List list) { + return list; + } + if (raw instanceof Map map) { + return List.of(map); + } + return List.of(); + } + + @SuppressWarnings("unchecked") + private Map> indexByResultKey(List list, String resultKey) { + Map> indexed = new HashMap<>(); + for (Object item : list) { + if (item instanceof Map map) { + Object key = map.get(resultKey); + if (key != null) { + indexed.put(normalizeKey(key), (Map) map); + } + } + } + return indexed; + } + + private JsonNode mergeSubtree( + JsonNode subtree, + GraphQLCurationQueryBuilder.Result query, + Map> byKey) { + if (subtree == null) { + return null; + } + if (subtree.isArray()) { + ArrayNode array = objectMapper.createArrayNode(); + subtree.forEach(element -> array.add(mergeElement(element, query, byKey))); + return array; + } + if (subtree.isObject()) { + return mergeElement(subtree, query, byKey); + } + return subtree; + } + + private JsonNode mergeElement( + JsonNode source, + GraphQLCurationQueryBuilder.Result query, + Map> byKey) { + if (!source.isObject()) { + return source; + } + ObjectNode node = ((ObjectNode) source).deepCopy(); + JsonNode joinNode = GraphQLCurationQueryBuilder.navigate(node, query.joinPath()); + if (joinNode == null || joinNode.isNull()) { + if (query.writeTo() != null && !node.has(query.writeTo())) { + node.set(query.writeTo(), objectMapper.nullNode()); + } + return node; + } + + if (query.writeTo() != null) { + node.set( + query.writeTo(), pickHydration(joinNode, byKey, query.writeMode(), query.resultKey())); + return node; + } + + Map hit = byKey.get(normalizeKey(jsonScalar(joinNode))); + if (hit != null) { + hit.forEach((key, value) -> node.set(key, objectMapper.valueToTree(value))); + } + return node; + } + + private JsonNode pickHydration( + JsonNode joinNode, + Map> byKey, + String writeMode, + String resultKey) { + if (GraphQLCurationQueryBuilder.WRITE_MODE_SINGLE.equals(writeMode)) { + Object key = joinNode.isArray() ? firstScalar(joinNode) : jsonScalar(joinNode); + Map hit = key == null ? null : byKey.get(normalizeKey(key)); + return hit == null + ? objectMapper.nullNode() + : objectMapper.valueToTree(withoutResultKey(hit, resultKey)); + } + + ArrayNode array = objectMapper.createArrayNode(); + if (joinNode.isArray()) { + joinNode.forEach(value -> appendIfHit(array, byKey, jsonScalar(value), resultKey)); + } else { + appendIfHit(array, byKey, jsonScalar(joinNode), resultKey); + } + return array; + } + + private Object firstScalar(JsonNode node) { + return node.isEmpty() ? null : jsonScalar(node.get(0)); + } + + private void appendIfHit( + ArrayNode array, Map> byKey, Object key, String resultKey) { + Map hit = byKey.get(normalizeKey(key)); + if (hit != null) { + array.add(objectMapper.valueToTree(withoutResultKey(hit, resultKey))); + } + } + + private Map withoutResultKey(Map hit, String resultKey) { + Map copy = new HashMap<>(hit); + copy.remove(resultKey); + return copy; + } + + private void setAtPath(JsonNode root, String path, JsonNode value) { + String trimmed = stripPathPrefix(path); + if (trimmed.isEmpty()) { + return; + } + String[] segments = trimmed.split("\\."); + JsonNode current = root; + for (int i = 0; i < segments.length - 1; i++) { + current = current.get(segments[i]); + if (current == null) { + return; + } + } + if (current instanceof ObjectNode objectNode) { + objectNode.set(segments[segments.length - 1], value); + } + } + + private String stripPathPrefix(String path) { + if (path.startsWith("$.")) { + return path.substring(2); + } + return JSON_PATH_ROOT.equals(path) ? "" : path; + } + + private Object normalizeKey(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value == null) { + return null; + } + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException e) { + return value.toString(); + } + } + + private Object jsonScalar(JsonNode node) { + if (node.isIntegralNumber()) { + return node.asLong(); + } + if (node.isFloatingPointNumber()) { + return node.asDouble(); + } + if (node.isBoolean()) { + return node.asBoolean(); + } + return node.asText(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java new file mode 100644 index 000000000..3220aa060 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java @@ -0,0 +1,64 @@ +package app.bottlenote.curation.service; + +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import app.bottlenote.curation.dto.response.CurationSpecSyncResponse; +import app.bottlenote.curation.support.CurationSpecResourceReader; +import app.bottlenote.curation.support.CurationSpecResourceReader.CurationSpecResourceDocument; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CurationSpecResourceSyncService { + + private final CurationSpecRepository curationSpecRepository; + private final CurationSpecResourceReader curationSpecResourceReader; + + @Transactional + public CurationSpecSyncResponse sync() { + int createdCount = 0; + int updatedCount = 0; + + for (CurationSpecResourceDocument specDocument : curationSpecResourceReader.readAll()) { + Optional existingSpec = curationSpecRepository.findByCode(specDocument.code()); + if (existingSpec.isPresent()) { + curationSpecRepository.save(update(existingSpec.get(), specDocument)); + updatedCount++; + } else { + curationSpecRepository.save(create(specDocument)); + createdCount++; + } + } + + return new CurationSpecSyncResponse(createdCount, updatedCount); + } + + private CurationSpec update( + CurationSpec curationSpec, CurationSpecResourceDocument specDocument) { + curationSpec.update( + specDocument.name(), + specDocument.description(), + specDocument.requestSpec(), + specDocument.responseSpec(), + specDocument.hydratorKey(), + specDocument.version(), + true); + return curationSpec; + } + + private CurationSpec create(CurationSpecResourceDocument specDocument) { + return CurationSpec.builder() + .code(specDocument.code()) + .name(specDocument.name()) + .description(specDocument.description()) + .requestSpec(specDocument.requestSpec()) + .responseSpec(specDocument.responseSpec()) + .hydratorKey(specDocument.hydratorKey()) + .version(specDocument.version()) + .isActive(true) + .build(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationAlcoholService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationAlcoholService.java new file mode 100644 index 000000000..d77c7072a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationAlcoholService.java @@ -0,0 +1,108 @@ +package app.bottlenote.curation.service; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.picks.constant.PicksStatus; +import app.bottlenote.picks.domain.PicksRepository; +import app.bottlenote.picks.dto.response.AlcoholPicksCountResponse; +import app.bottlenote.rating.domain.RatingRepository; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import app.bottlenote.review.domain.ReviewRepository; +import app.bottlenote.review.dto.response.AlcoholReviewCountResponse; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GraphQLCurationAlcoholService { + + private final AlcoholQueryRepository alcoholQueryRepository; + private final RatingRepository ratingRepository; + private final ReviewRepository reviewRepository; + private final PicksRepository picksRepository; + + @Transactional(readOnly = true) + public List findAlcohols(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + + List alcoholIds = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (alcoholIds.isEmpty()) { + return List.of(); + } + + Map alcoholsById = + alcoholQueryRepository.findAllByIdIn(alcoholIds).stream() + .collect(Collectors.toMap(Alcohol::getId, Function.identity(), (left, right) -> left)); + + return alcoholIds.stream().map(alcoholsById::get).filter(Objects::nonNull).toList(); + } + + @Transactional(readOnly = true) + public String regionName(Alcohol alcohol) { + if (alcohol == null || alcohol.getRegion() == null) { + return null; + } + return alcohol.getRegion().getKorName(); + } + + @Transactional(readOnly = true) + public Map ratingStats(List alcohols) { + List alcoholIds = alcoholIdsOf(alcohols); + if (alcoholIds.isEmpty()) { + return Map.of(); + } + return ratingRepository.findStatsByAlcoholIds(alcoholIds).stream() + .collect(Collectors.toMap(AlcoholRatingStatsResponse::alcoholId, Function.identity())); + } + + @Transactional(readOnly = true) + public Map reviewCounts(List alcohols) { + List alcoholIds = alcoholIdsOf(alcohols); + if (alcoholIds.isEmpty()) { + return Map.of(); + } + return reviewRepository + .countByAlcoholIdsAndActiveStatusAndStatus( + alcoholIds, ReviewActiveStatus.ACTIVE, ReviewDisplayStatus.PUBLIC) + .stream() + .collect( + Collectors.toMap( + AlcoholReviewCountResponse::alcoholId, AlcoholReviewCountResponse::reviewCount)); + } + + @Transactional(readOnly = true) + public Map pickCounts(List alcohols) { + List alcoholIds = alcoholIdsOf(alcohols); + if (alcoholIds.isEmpty()) { + return Map.of(); + } + return picksRepository.countByAlcoholIdsAndStatus(alcoholIds, PicksStatus.PICK).stream() + .collect( + Collectors.toMap( + AlcoholPicksCountResponse::alcoholId, AlcoholPicksCountResponse::totalPickCount)); + } + + private List alcoholIdsOf(List alcohols) { + return alcohols == null + ? List.of() + : alcohols.stream().map(this::alcoholIdOf).filter(Objects::nonNull).distinct().toList(); + } + + private Long alcoholIdOf(Alcohol alcohol) { + if (alcohol == null) { + return null; + } + return alcohol.getId(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationExecutor.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationExecutor.java new file mode 100644 index 000000000..eb56d31a4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationExecutor.java @@ -0,0 +1,8 @@ +package app.bottlenote.curation.service; + +import java.util.Map; + +public interface GraphQLCurationExecutor { + + Map execute(Long curationId, int index, GraphQLCurationQueryBuilder.Result query); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationQueryBuilder.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationQueryBuilder.java new file mode 100644 index 000000000..0807e2f8e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationQueryBuilder.java @@ -0,0 +1,231 @@ +package app.bottlenote.curation.service; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class GraphQLCurationQueryBuilder { + + static final String WRITE_MODE_ARRAY = "array"; + static final String WRITE_MODE_SINGLE = "single"; + + private static final String META_KEY = "x-graphql"; + private static final String JSON_PATH_ROOT = "$"; + private static final String JSON_PATH_PREFIX = "$."; + private static final String DEFAULT_ARG_NAME = "id"; + private static final String DEFAULT_ARG_TYPE = "ID!"; + private static final String DEFAULT_RESULT_KEY = "alcoholId"; + + public List build(JsonNode responseSpec, JsonNode payload) { + List results = new ArrayList<>(); + walk(responseSpec, payload, results); + return results; + } + + static JsonNode navigate(JsonNode root, String path) { + if (root == null || path == null || JSON_PATH_ROOT.equals(path)) { + return root; + } + String trimmed = stripPathPrefix(path); + if (trimmed.isEmpty()) { + return root; + } + JsonNode current = root; + for (String segment : trimmed.split("\\.")) { + if (current == null) { + return null; + } + current = current.get(segment); + } + return current; + } + + private void walk(JsonNode node, JsonNode payload, List results) { + if (node == null || !node.isObject()) { + return; + } + JsonNode meta = node.get(META_KEY); + if (isEntryPointMeta(meta)) { + results.add(buildOne(node, meta, payload)); + return; + } + JsonNode properties = node.get("properties"); + if (properties != null && properties.isObject()) { + properties.properties().forEach(entry -> walk(entry.getValue(), payload, results)); + } + JsonNode items = node.get("items"); + if (items != null) { + walk(items, payload, results); + } + } + + private boolean isEntryPointMeta(JsonNode meta) { + return meta != null && meta.isObject() && meta.has("query"); + } + + private Result buildOne(JsonNode entry, JsonNode meta, JsonNode payload) { + String queryName = meta.get("query").asText(); + String argName = meta.path("argName").asText(DEFAULT_ARG_NAME); + String argType = meta.path("argType").asText(DEFAULT_ARG_TYPE); + String argFrom = meta.path("argFrom").asText(JSON_PATH_ROOT); + String writeTo = meta.has("writeTo") ? meta.get("writeTo").asText(null) : null; + String resultKey = meta.path("resultKey").asText(DEFAULT_RESULT_KEY); + String payloadPath = meta.path("payloadPath").asText(JSON_PATH_ROOT); + + JsonNode subPayload = navigate(payload, payloadPath); + Map variables = new LinkedHashMap<>(); + variables.put(argName, extractArg(subPayload, argFrom)); + + List selectionFields = + new ArrayList<>(collectSelection(resolveSelectionRoot(entry, writeTo))); + if (!selectionFields.contains(resultKey)) { + selectionFields.add(0, resultKey); + } + String query = + String.format( + "query Q($%s: %s) { %s(%s: $%s) { %s } }", + argName, argType, queryName, argName, argName, String.join(" ", selectionFields)); + return new Result( + query, + variables, + queryName, + argFrom, + writeTo, + resolveWriteMode(entry, writeTo), + resultKey, + payloadPath); + } + + private JsonNode resolveSelectionRoot(JsonNode entry, String writeTo) { + if (writeTo != null) { + JsonNode target = entry.path("properties").path(writeTo); + if (target.isMissingNode()) { + return entry; + } + return target.has("items") ? target.get("items") : target; + } + return "array".equals(entry.path("type").asText()) && entry.has("items") + ? entry.get("items") + : entry; + } + + private String resolveWriteMode(JsonNode entry, String writeTo) { + if (writeTo == null) { + return WRITE_MODE_ARRAY; + } + String type = entry.path("properties").path(writeTo).path("type").asText(); + return WRITE_MODE_ARRAY.equals(type) ? WRITE_MODE_ARRAY : WRITE_MODE_SINGLE; + } + + private List collectSelection(JsonNode node) { + List selections = new ArrayList<>(); + if (node == null || !node.path("properties").isObject()) { + return selections; + } + for (Map.Entry entry : node.path("properties").properties()) { + JsonNode meta = entry.getValue().get(META_KEY); + if (meta == null || isEntryPointMeta(meta)) { + continue; + } + selections.add(resolveFieldName(entry.getKey(), meta)); + } + return selections; + } + + private String resolveFieldName(String key, JsonNode meta) { + if (meta.isTextual()) { + return meta.asText(); + } + if (meta.isObject() && meta.has("field")) { + return meta.get("field").asText(key); + } + return key; + } + + private Object extractArg(JsonNode payload, String argFrom) { + if (payload == null) { + return null; + } + if (payload.isArray()) { + Set values = new LinkedHashSet<>(); + payload.forEach(element -> addFlat(values, readPath(element, argFrom))); + values.remove(null); + return new ArrayList<>(values); + } + Object value = readPath(payload, argFrom); + if (value instanceof List list) { + Set values = new LinkedHashSet<>(list); + values.remove(null); + return new ArrayList<>(values); + } + return value; + } + + private void addFlat(Set values, Object value) { + if (value == null) { + return; + } + if (value instanceof List list) { + list.forEach(item -> addFlat(values, item)); + return; + } + values.add(value); + } + + private Object readPath(JsonNode node, String path) { + JsonNode current = navigate(node, path); + return jsonToJava(current); + } + + private Object jsonToJava(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isIntegralNumber()) { + return node.asLong(); + } + if (node.isFloatingPointNumber()) { + return node.asDouble(); + } + if (node.isBoolean()) { + return node.asBoolean(); + } + if (node.isTextual()) { + return node.asText(); + } + if (node.isArray()) { + List values = new ArrayList<>(); + node.forEach(child -> values.add(jsonToJava(child))); + return values; + } + if (node.isObject()) { + Map values = new LinkedHashMap<>(); + node.properties().forEach(entry -> values.put(entry.getKey(), jsonToJava(entry.getValue()))); + return values; + } + return null; + } + + private static String stripPathPrefix(String path) { + if (path.startsWith(JSON_PATH_PREFIX)) { + return path.substring(2); + } + return JSON_PATH_ROOT.equals(path) ? "" : path; + } + + public record Result( + String query, + Map variables, + String entryField, + String joinPath, + String writeTo, + String writeMode, + String resultKey, + String payloadPath) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLSpringCurationExecutor.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLSpringCurationExecutor.java new file mode 100644 index 000000000..143a450b2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLSpringCurationExecutor.java @@ -0,0 +1,73 @@ +package app.bottlenote.curation.service; + +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_GRAPHQL_EXECUTION_FAILED; + +import app.bottlenote.curation.exception.CurationException; +import java.time.Duration; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.graphql.ExecutionGraphQlRequest; +import org.springframework.graphql.ExecutionGraphQlResponse; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.support.DefaultExecutionGraphQlRequest; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +@Slf4j +public class GraphQLSpringCurationExecutor implements GraphQLCurationExecutor { + + private static final Duration GRAPHQL_EXECUTION_TIMEOUT = Duration.ofSeconds(3); + + private final ExecutionGraphQlService executionGraphQlService; + + @Override + public Map execute( + Long curationId, int index, GraphQLCurationQueryBuilder.Result query) { + ExecutionGraphQlRequest request = + new DefaultExecutionGraphQlRequest( + query.query(), + "Q", + query.variables(), + Map.of(), + "curation-" + curationId + "-" + index, + null); + ExecutionGraphQlResponse response; + try { + response = + Mono.from(executionGraphQlService.execute(request)).block(GRAPHQL_EXECUTION_TIMEOUT); + } catch (RuntimeException e) { + log.error( + "Curation GraphQL hydration execution failed. curationId={}, queryIndex={}, payloadPath={}, entryField={}, timeout={}", + curationId, + index, + query.payloadPath(), + query.entryField(), + GRAPHQL_EXECUTION_TIMEOUT, + e); + throw new CurationException(CURATION_GRAPHQL_EXECUTION_FAILED); + } + if (response == null) { + log.error( + "Curation GraphQL hydration returned null response. curationId={}, queryIndex={}, payloadPath={}, entryField={}", + curationId, + index, + query.payloadPath(), + query.entryField()); + throw new CurationException(CURATION_GRAPHQL_EXECUTION_FAILED); + } + if (!response.getExecutionResult().getErrors().isEmpty()) { + log.error( + "Curation GraphQL hydration returned errors. curationId={}, queryIndex={}, payloadPath={}, entryField={}, errors={}", + curationId, + index, + query.payloadPath(), + query.entryField(), + response.getExecutionResult().getErrors()); + throw new CurationException(CURATION_GRAPHQL_EXECUTION_FAILED); + } + return response.getExecutionResult().toSpecification(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java new file mode 100644 index 000000000..acfb24ce1 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java @@ -0,0 +1,119 @@ +package app.bottlenote.curation.service; + +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_NOT_FOUND; +import static app.bottlenote.curation.exception.CurationExceptionCode.CURATION_SPEC_NOT_FOUND; + +import app.bottlenote.curation.domain.Curation; +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import app.bottlenote.curation.domain.CurationRepository; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import app.bottlenote.curation.dto.response.ProductSpecBasedCurationDetailResponse; +import app.bottlenote.curation.dto.response.ProductSpecBasedCurationListResponse; +import app.bottlenote.curation.exception.CurationException; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductSpecBasedCurationService { + + private static final String DEFAULT_CONTAINER = "object"; + + private final CurationRepository curationRepository; + private final CurationSpecRepository curationSpecRepository; + private final CurationExtensionRepository curationExtensionRepository; + private final CurationResponseMaterializer responseMaterializer; + + @Transactional(readOnly = true) + public List listActiveCurations() { + List curations = curationRepository.findAllVisibleOn(LocalDate.now()); + Map specMap = + curationSpecRepository + .findAllByIdIn(curations.stream().map(Curation::getSpecId).collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + return curations.stream() + .map(curation -> toListResponse(curation, specMap.get(curation.getSpecId()))) + .toList(); + } + + @Transactional(readOnly = true) + public ProductSpecBasedCurationDetailResponse getDetail(Long curationId) { + Curation curation = + curationRepository + .findVisibleById(curationId, LocalDate.now()) + .orElseThrow(() -> new CurationException(CURATION_NOT_FOUND)); + CurationSpec spec = + curationSpecRepository + .findById(curation.getSpecId()) + .orElseThrow(() -> new CurationException(CURATION_SPEC_NOT_FOUND)); + CurationExtension extension = + curationExtensionRepository + .findByCurationId(curationId) + .orElseThrow(() -> new CurationException(CURATION_NOT_FOUND)); + Object materialized = + responseMaterializer.materialize( + curationId, spec.getCode(), spec.getResponseSpec(), extension.getPayload()); + return toDetailResponse(curation, spec, materialized); + } + + private ProductSpecBasedCurationListResponse toListResponse( + Curation curation, CurationSpec spec) { + return new ProductSpecBasedCurationListResponse( + curation.getId(), + curation.getSpecId(), + spec != null ? spec.getCode() : null, + spec != null ? spec.getName() : null, + curation.getName(), + curation.getDescription(), + curation.getCoverImageUrl(), + imageUrls(curation), + curation.getExposureStartDate(), + curation.getExposureEndDate(), + curation.getDisplayOrder(), + curation.getCreateAt()); + } + + private ProductSpecBasedCurationDetailResponse toDetailResponse( + Curation curation, CurationSpec spec, Object payload) { + return new ProductSpecBasedCurationDetailResponse( + curation.getId(), + curation.getName(), + curation.getDescription(), + curation.getCoverImageUrl(), + imageUrls(curation), + curation.getExposureStartDate(), + curation.getExposureEndDate(), + curation.getDisplayOrder(), + curation.getCreateAt(), + new ProductSpecBasedCurationDetailResponse.SpecMeta( + spec.getId(), spec.getCode(), spec.getName(), container(spec), spec.getResponseSpec()), + payload); + } + + private List imageUrls(Curation curation) { + return java.util.stream.Stream.of( + curation.getCoverImageUrl(), curation.getImageUrl2(), curation.getImageUrl3()) + .filter(Objects::nonNull) + .filter(imageUrl -> !imageUrl.isBlank()) + .toList(); + } + + private String container(CurationSpec spec) { + Object requestContainer = spec.getRequestSpec().get("x-container"); + if (requestContainer != null) { + return requestContainer.toString(); + } + Object responseContainer = spec.getResponseSpec().get("x-container"); + return responseContainer != null ? responseContainer.toString() : DEFAULT_CONTAINER; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecResourceReader.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecResourceReader.java new file mode 100644 index 000000000..32e3aeef7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecResourceReader.java @@ -0,0 +1,80 @@ +package app.bottlenote.curation.support; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CurationSpecResourceReader { + + private static final String SPEC_RESOURCE_PATTERN = "classpath*:openapi/curation/*.json"; + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private final ResourcePatternResolver resourcePatternResolver; + private final ObjectMapper objectMapper; + + public List readAll() { + return Arrays.stream(loadResources()).map(this::readSpec).toList(); + } + + private Resource[] loadResources() { + try { + Resource[] resources = resourcePatternResolver.getResources(SPEC_RESOURCE_PATTERN); + Arrays.sort(resources, Comparator.comparing(Resource::getFilename)); + return resources; + } catch (IOException e) { + throw new IllegalStateException("큐레이션 스펙 리소스를 읽을 수 없습니다.", e); + } + } + + private CurationSpecResourceDocument readSpec(Resource resource) { + try { + JsonNode root = objectMapper.readTree(resource.getInputStream()); + JsonNode schemas = root.path("components").path("schemas"); + JsonNode requestSpec = findSchema(schemas, "Request"); + JsonNode responseSpec = findSchema(schemas, "Response"); + return new CurationSpecResourceDocument( + root.path("x-curation").path("code").asText(), + root.path("info").path("title").asText(), + root.path("info").path("description").asText(null), + objectMapper.convertValue(requestSpec, MAP_TYPE), + objectMapper.convertValue(responseSpec, MAP_TYPE), + root.path("x-curation").path("hydratorKey").asText(), + parseMajorVersion(root.path("info").path("version").asText("1"))); + } catch (IOException e) { + throw new IllegalStateException("큐레이션 스펙 리소스 파싱에 실패했습니다: " + resource.getFilename(), e); + } + } + + private JsonNode findSchema(JsonNode schemas, String suffix) { + return schemas.properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(() -> new IllegalStateException(suffix + " schema를 찾을 수 없습니다.")); + } + + private int parseMajorVersion(String version) { + String major = version.split("\\.")[0]; + return Integer.parseInt(major); + } + + public record CurationSpecResourceDocument( + String code, + String name, + String description, + Map requestSpec, + Map responseSpec, + String hydratorKey, + Integer version) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java index 184ea2019..2d40a5ddd 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java @@ -98,6 +98,15 @@ public enum ValidExceptionCode implements ExceptionCode { BANNER_SORT_ORDER_MINIMUM(HttpStatus.BAD_REQUEST, "정렬 순서는 0 이상이어야 합니다."), BANNER_IS_ACTIVE_REQUIRED(HttpStatus.BAD_REQUEST, "활성화 상태는 필수입니다."), + // CURATION + CURATION_SPEC_ID_REQUIRED(HttpStatus.BAD_REQUEST, "큐레이션 스펙 ID는 필수입니다."), + CURATION_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "큐레이션 이름은 필수입니다."), + CURATION_IMAGE_URLS_REQUIRED(HttpStatus.BAD_REQUEST, "큐레이션 이미지는 최소 1개 이상이어야 합니다."), + CURATION_IMAGE_URLS_MAX_SIZE(HttpStatus.BAD_REQUEST, "큐레이션 이미지는 최대 3개까지 등록할 수 있습니다."), + CURATION_DISPLAY_ORDER_REQUIRED(HttpStatus.BAD_REQUEST, "노출 순서는 필수입니다."), + CURATION_IS_ACTIVE_REQUIRED(HttpStatus.BAD_REQUEST, "활성화 상태는 필수입니다."), + CURATION_PAYLOAD_REQUIRED(HttpStatus.BAD_REQUEST, "payload는 필수입니다."), + // REGION REGION_KOR_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "지역 한글명은 필수입니다."), REGION_ENG_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "지역 영문명은 필수입니다."), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java index ab4037430..98157f628 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java @@ -40,6 +40,11 @@ public enum MaliciousPathPattern { SERVER_STATUS("/server-status", "Apache 서버 상태"), SERVER_INFO("/server-info", "Apache 서버 정보"), + // 내부 전용 엔드포인트 + GRAPHQL("/graphql", "내부 GraphQL 엔드포인트"), + GRAPHIQL("/graphiql", "GraphiQL 탐색 UI"), + GRAPHIQL_ASSETS("/graphiql/**", "GraphiQL 탐색 UI 리소스"), + // 백업/덤프 파일 (루트 경로만 차단, ** 뒤에 패턴 불가) SQL_FILE("/*.sql", "루트 SQL 덤프 파일"), BAK_FILE("/*.bak", "루트 백업 파일"), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/picks/domain/PicksRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/picks/domain/PicksRepository.java index 96ab623ce..2fc0ee42a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/picks/domain/PicksRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/picks/domain/PicksRepository.java @@ -1,10 +1,18 @@ package app.bottlenote.picks.domain; +import app.bottlenote.picks.constant.PicksStatus; +import app.bottlenote.picks.dto.response.AlcoholPicksCountResponse; +import java.util.List; import java.util.Optional; public interface PicksRepository { Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId); + Long countByAlcoholIdAndStatus(Long alcoholId, PicksStatus status); + + List countByAlcoholIdsAndStatus( + List alcoholIds, PicksStatus status); + Picks save(Picks picks); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/picks/dto/response/AlcoholPicksCountResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/picks/dto/response/AlcoholPicksCountResponse.java new file mode 100644 index 000000000..61164407f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/picks/dto/response/AlcoholPicksCountResponse.java @@ -0,0 +1,3 @@ +package app.bottlenote.picks.dto.response; + +public record AlcoholPicksCountResponse(Long alcoholId, Long totalPickCount) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/picks/repository/JpaPicksRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/picks/repository/JpaPicksRepository.java index d8850f981..347751e44 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/picks/repository/JpaPicksRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/picks/repository/JpaPicksRepository.java @@ -1,12 +1,29 @@ package app.bottlenote.picks.repository; import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.picks.constant.PicksStatus; import app.bottlenote.picks.domain.Picks; import app.bottlenote.picks.domain.PicksRepository; +import app.bottlenote.picks.dto.response.AlcoholPicksCountResponse; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; @JpaRepositoryImpl public interface JpaPicksRepository extends PicksRepository, JpaRepository { Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId); + + @Override + @Query( + """ + select new app.bottlenote.picks.dto.response.AlcoholPicksCountResponse(p.alcoholId, count(p)) + from picks p + where p.alcoholId in :alcoholIds + and p.status = :status + group by p.alcoholId + """) + List countByAlcoholIdsAndStatus( + @Param("alcoholIds") List alcoholIds, @Param("status") PicksStatus status); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java index d3639db6d..00ef5c1e6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java @@ -3,6 +3,7 @@ import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.rating.domain.Rating.RatingId; import app.bottlenote.rating.dto.dsl.RatingListFetchCriteria; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; import app.bottlenote.rating.dto.response.RatingListFetchResponse; import app.bottlenote.rating.dto.response.UserRatingResponse; import java.util.List; @@ -23,5 +24,11 @@ public interface RatingRepository { Optional fetchUserRating(Long alcoholId, Long userId); + Double findAverageRatingByAlcoholId(Long alcoholId); + + Long countByAlcoholId(Long alcoholId); + + List findStatsByAlcoholIds(List alcoholIds); + boolean existsByAlcoholId(Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/dto/response/AlcoholRatingStatsResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/dto/response/AlcoholRatingStatsResponse.java new file mode 100644 index 000000000..e8864d9c4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/dto/response/AlcoholRatingStatsResponse.java @@ -0,0 +1,3 @@ +package app.bottlenote.rating.dto.response; + +public record AlcoholRatingStatsResponse(Long alcoholId, Double rating, Long totalRatingsCount) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java index fe8cf809f..7da308102 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java @@ -4,7 +4,9 @@ import app.bottlenote.rating.domain.Rating; import app.bottlenote.rating.domain.Rating.RatingId; import app.bottlenote.rating.domain.RatingRepository; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; import app.bottlenote.rating.dto.response.UserRatingResponse; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -32,6 +34,32 @@ Optional findByAlcoholIdAndUserId( Optional fetchUserRating( @Param("alcoholId") Long alcoholId, @Param("userId") Long userId); + @Override + @Query( + "select coalesce(avg(r.ratingPoint.rating), 0.0) from rating r where r.id.alcoholId = :alcoholId and r.ratingPoint.rating > 0.0") + Double findAverageRatingByAlcoholId(@Param("alcoholId") Long alcoholId); + + @Override + @Query( + "select count(r) from rating r where r.id.alcoholId = :alcoholId and r.ratingPoint.rating > 0.0") + Long countByAlcoholId(@Param("alcoholId") Long alcoholId); + + @Override + @Query( + """ + select new app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse( + r.id.alcoholId, + coalesce(avg(r.ratingPoint.rating), 0.0), + count(r) + ) + from rating r + where r.id.alcoholId in :alcoholIds + and r.ratingPoint.rating > 0.0 + group by r.id.alcoholId + """) + List findStatsByAlcoholIds( + @Param("alcoholIds") List alcoholIds); + @Override @Query( "select case when count(r) > 0 then true else false end from rating r where r.id.alcoholId = :alcoholId") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/constant/AdminReviewSortType.java b/bottlenote-mono/src/main/java/app/bottlenote/review/constant/AdminReviewSortType.java new file mode 100644 index 000000000..cb4b3adc1 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/constant/AdminReviewSortType.java @@ -0,0 +1,14 @@ +package app.bottlenote.review.constant; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AdminReviewSortType { + CREATED_AT("작성일"), + REPLY_COUNT("댓글 수"), + UPDATED_AT("수정일"); + + private final String description; +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java index 436725c1c..c9c0e422e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java @@ -2,13 +2,19 @@ import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; +import app.bottlenote.review.dto.response.AdminReviewListResponse; +import app.bottlenote.review.dto.response.AlcoholReviewCountResponse; import app.bottlenote.review.dto.response.ReviewExploreItem; import app.bottlenote.review.dto.response.ReviewListResponse; import app.bottlenote.review.facade.payload.ReviewInfo; import java.util.List; import java.util.Optional; import org.apache.commons.lang3.tuple.Pair; +import org.springframework.data.domain.Page; public interface ReviewRepository { @@ -26,10 +32,18 @@ PageResponse getReviews( PageResponse getReviewsByMe( Long alcoholId, ReviewPageableRequest reviewPageableRequest, Long userId); + Page searchAdminReviews(AdminReviewSearchRequest request); + Optional findByIdAndUserId(Long reviewId, Long userId); List findByUserId(Long userId); + Long countByAlcoholIdAndActiveStatusAndStatus( + Long alcoholId, ReviewActiveStatus activeStatus, ReviewDisplayStatus status); + + List countByAlcoholIdsAndActiveStatusAndStatus( + List alcoholIds, ReviewActiveStatus activeStatus, ReviewDisplayStatus status); + boolean existsById(Long reviewId); boolean existsByAlcoholId(Long alcoholId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/dto/request/AdminReviewSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/request/AdminReviewSearchRequest.java new file mode 100644 index 000000000..5d09628c0 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/request/AdminReviewSearchRequest.java @@ -0,0 +1,32 @@ +package app.bottlenote.review.dto.request; + +import app.bottlenote.global.service.cursor.SortOrder; +import app.bottlenote.review.constant.AdminReviewSortType; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; + +public record AdminReviewSearchRequest( + Long alcoholId, + Long userId, + ReviewActiveStatus activeStatus, + ReviewDisplayStatus displayStatus, + String keyword, + LocalDateTime createdFrom, + LocalDateTime createdTo, + AdminReviewSortType sortType, + SortOrder sortOrder, + @Min(value = 0, message = "ADMIN_REVIEW_PAGE_MINIMUM") Integer page, + @Min(value = 1, message = "ADMIN_REVIEW_SIZE_MINIMUM") + @Max(value = 100, message = "ADMIN_REVIEW_SIZE_MAXIMUM") + Integer size) { + + public AdminReviewSearchRequest { + sortType = sortType != null ? sortType : AdminReviewSortType.CREATED_AT; + sortOrder = sortOrder != null ? sortOrder : SortOrder.DESC; + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AdminReviewListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AdminReviewListResponse.java new file mode 100644 index 000000000..65e7b8da3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AdminReviewListResponse.java @@ -0,0 +1,19 @@ +package app.bottlenote.review.dto.response; + +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import java.time.LocalDateTime; + +public record AdminReviewListResponse( + Long reviewId, + Long alcoholId, + String alcoholName, + Long userId, + String userNickname, + String content, + Double reviewRating, + ReviewActiveStatus activeStatus, + ReviewDisplayStatus displayStatus, + Long replyCount, + LocalDateTime createAt, + LocalDateTime lastModifyAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AlcoholReviewCountResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AlcoholReviewCountResponse.java new file mode 100644 index 000000000..d382c8fb6 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AlcoholReviewCountResponse.java @@ -0,0 +1,3 @@ +package app.bottlenote.review.dto.response; + +public record AlcoholReviewCountResponse(Long alcoholId, Long reviewCount) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepository.java index ba1dd866d..2497db0a5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepository.java @@ -2,12 +2,15 @@ import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; +import app.bottlenote.review.dto.response.AdminReviewListResponse; import app.bottlenote.review.dto.response.ReviewExploreItem; import app.bottlenote.review.dto.response.ReviewListResponse; import app.bottlenote.review.facade.payload.ReviewInfo; import java.util.List; import org.apache.commons.lang3.tuple.Pair; +import org.springframework.data.domain.Page; public interface CustomReviewRepository { @@ -42,6 +45,8 @@ PageResponse getReviews( PageResponse getReviewsByMe( Long alcoholId, ReviewPageableRequest reviewPageableRequest, Long userId); + Page searchAdminReviews(AdminReviewSearchRequest request); + Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java index 9ce329991..864276f8e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java @@ -8,6 +8,8 @@ import static app.bottlenote.review.domain.QReviewImage.reviewImage; import static app.bottlenote.review.domain.QReviewReply.reviewReply; import static app.bottlenote.review.domain.QReviewTastingTag.reviewTastingTag; +import static app.bottlenote.review.repository.ReviewQuerySupporter.adminReviewFilters; +import static app.bottlenote.review.repository.ReviewQuerySupporter.adminReviewSortBy; import static app.bottlenote.review.repository.ReviewQuerySupporter.containsKeywordInAll; import static app.bottlenote.review.repository.ReviewQuerySupporter.getCursorPageable; import static app.bottlenote.review.repository.ReviewQuerySupporter.getTastingTag; @@ -22,7 +24,9 @@ import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.like.constant.LikeStatus; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; +import app.bottlenote.review.dto.response.AdminReviewListResponse; import app.bottlenote.review.dto.response.ReviewExploreItem; import app.bottlenote.review.dto.response.ReviewListResponse; import app.bottlenote.review.facade.payload.ReviewInfo; @@ -40,6 +44,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; @Slf4j @RequiredArgsConstructor @@ -207,6 +214,67 @@ public PageResponse getReviewsByMe( return PageResponse.of(ReviewListResponse.of(totalCount, fetch), cursorPageable); } + @Override + public Page searchAdminReviews(AdminReviewSearchRequest request) { + List content = + queryFactory + .select( + Projections.constructor( + AdminReviewListResponse.class, + review.id, + alcohol.id, + alcohol.korName, + user.id, + user.nickName, + review.content, + review.reviewRating, + review.activeStatus, + review.status, + reviewReply.id.countDistinct(), + review.createAt, + review.lastModifyAt)) + .from(review) + .join(user) + .on(review.userId.eq(user.id)) + .leftJoin(alcohol) + .on(alcohol.id.eq(review.alcoholId)) + .leftJoin(reviewReply) + .on(review.id.eq(reviewReply.reviewId)) + .where(adminReviewFilters(request)) + .groupBy( + review.id, + alcohol.id, + alcohol.korName, + user.id, + user.nickName, + review.content, + review.reviewRating, + review.activeStatus, + review.status, + review.createAt, + review.lastModifyAt) + .orderBy( + adminReviewSortBy(request.sortType(), request.sortOrder()) + .toArray(new OrderSpecifier[0])) + .offset((long) request.page() * request.size()) + .limit(request.size()) + .fetch(); + + Long total = + queryFactory + .select(review.id.countDistinct()) + .from(review) + .join(user) + .on(review.userId.eq(user.id)) + .leftJoin(alcohol) + .on(alcohol.id.eq(review.alcoholId)) + .where(adminReviewFilters(request)) + .fetchOne(); + + return new PageImpl<>( + content, PageRequest.of(request.page(), request.size()), total != null ? total : 0L); + } + @Override public Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java index b39f64f23..69c213b84 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java @@ -1,7 +1,10 @@ package app.bottlenote.review.repository; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewRepository; +import app.bottlenote.review.dto.response.AlcoholReviewCountResponse; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -23,6 +26,21 @@ public interface JpaReviewRepository @Query("select r from review r where r.userId = :userId") List findByUserId(@Param("userId") Long userId); + @Override + @Query( + """ + select new app.bottlenote.review.dto.response.AlcoholReviewCountResponse(r.alcoholId, count(r)) + from review r + where r.alcoholId in :alcoholIds + and r.activeStatus = :activeStatus + and r.status = :status + group by r.alcoholId + """) + List countByAlcoholIdsAndActiveStatusAndStatus( + @Param("alcoholIds") List alcoholIds, + @Param("activeStatus") ReviewActiveStatus activeStatus, + @Param("status") ReviewDisplayStatus status); + @Override @Query( "select case when count(r) > 0 then true else false end from review r where r.alcoholId = :alcoholId") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java index cd86fd4f3..58497213b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java @@ -14,8 +14,11 @@ import app.bottlenote.global.service.cursor.CursorPageable; import app.bottlenote.global.service.cursor.SortOrder; +import app.bottlenote.review.constant.AdminReviewSortType; import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.constant.ReviewSortType; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; import app.bottlenote.review.facade.payload.ReviewInfo; import app.bottlenote.review.facade.payload.UserInfo; @@ -173,6 +176,73 @@ public static List> sortBy(ReviewSortType reviewSortType, Sort }; } + public static BooleanExpression[] adminReviewFilters(AdminReviewSearchRequest request) { + return new BooleanExpression[] { + alcoholIdEq(request.alcoholId()), + userIdEq(request.userId()), + activeStatusEq(request.activeStatus()), + displayStatusEq(request.displayStatus()), + adminKeywordContains(request.keyword()), + createdFromGoe(request.createdFrom()), + createdToLoe(request.createdTo()) + }; + } + + public static List> adminReviewSortBy( + AdminReviewSortType sortType, SortOrder sortOrder) { + Order order = sortOrder == SortOrder.ASC ? Order.ASC : Order.DESC; + NumberExpression replyCount = reviewReply.id.countDistinct(); + OrderSpecifier latestReview = review.createAt.desc(); + OrderSpecifier latestReviewId = review.id.desc(); + + OrderSpecifier primary = + switch (sortType) { + case CREATED_AT -> new OrderSpecifier<>(order, review.createAt); + case REPLY_COUNT -> new OrderSpecifier<>(order, replyCount); + case UPDATED_AT -> new OrderSpecifier<>(order, review.lastModifyAt); + }; + + return Arrays.asList(primary, latestReview, latestReviewId); + } + + private static BooleanExpression alcoholIdEq(Long alcoholId) { + return alcoholId != null ? review.alcoholId.eq(alcoholId) : null; + } + + private static BooleanExpression userIdEq(Long userId) { + return userId != null ? review.userId.eq(userId) : null; + } + + private static BooleanExpression activeStatusEq(ReviewActiveStatus activeStatus) { + return activeStatus != null ? review.activeStatus.eq(activeStatus) : null; + } + + private static BooleanExpression displayStatusEq(ReviewDisplayStatus displayStatus) { + return displayStatus != null ? review.status.eq(displayStatus) : null; + } + + private static BooleanExpression adminKeywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + String value = "%" + keyword.trim() + "%"; + return review + .content + .likeIgnoreCase(value) + .or(user.nickName.likeIgnoreCase(value)) + .or(user.email.likeIgnoreCase(value)) + .or(alcohol.korName.likeIgnoreCase(value)) + .or(alcohol.engName.likeIgnoreCase(value)); + } + + private static BooleanExpression createdFromGoe(java.time.LocalDateTime createdFrom) { + return createdFrom != null ? review.createAt.goe(createdFrom) : null; + } + + private static BooleanExpression createdToLoe(java.time.LocalDateTime createdTo) { + return createdTo != null ? review.createAt.loe(createdTo) : null; + } + /** 키워드를 이용해 작성자, 주류 정보, 리뷰 콘텐츠, 테이스팅 태그를 모두 검색하는 조건 생성 */ public static BooleanExpression containsKeywordInAll(List keywords) { if (keywords == null || keywords.isEmpty()) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/service/AdminReviewQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/review/service/AdminReviewQueryService.java new file mode 100644 index 000000000..1daac6ead --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/service/AdminReviewQueryService.java @@ -0,0 +1,20 @@ +package app.bottlenote.review.service; + +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.review.domain.ReviewRepository; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminReviewQueryService { + + private final ReviewRepository reviewRepository; + + @Transactional(readOnly = true) + public GlobalResponse searchReviews(AdminReviewSearchRequest request) { + return GlobalResponse.fromPage(reviewRepository.searchAdminReviews(request)); + } +} diff --git a/bottlenote-mono/src/main/resources/graphql/schema.graphqls b/bottlenote-mono/src/main/resources/graphql/schema.graphqls new file mode 100644 index 000000000..473efaa8c --- /dev/null +++ b/bottlenote-mono/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,19 @@ +type Query { + alcohols(ids: [ID!]!): [Alcohol!]! +} + +type Alcohol { + alcoholId: ID! + korName: String! + engName: String + imageUrl: String + regionName: String + korCategory: String + cask: String + abv: String + volume: String + rating: Float + totalRatingsCount: Int + reviewCount: Int + totalPickCount: Int +} diff --git a/bottlenote-mono/src/main/resources/openapi/curation/recommended_whisky.json b/bottlenote-mono/src/main/resources/openapi/curation/recommended_whisky.json new file mode 100644 index 000000000..d419c5647 --- /dev/null +++ b/bottlenote-mono/src/main/resources/openapi/curation/recommended_whisky.json @@ -0,0 +1,295 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "추천 큐레이션", + "description": "추천 위스키를 카드 목록으로 발행하는 큐레이션. 각 항목은 위스키 1건과 큐레이터 코멘트로 구성된다.", + "version": "2.0.0" + }, + "x-curation": { + "code": "RECOMMENDED_WHISKY", + "hydratorKey": "alcohol", + "container": "array" + }, + "paths": {}, + "components": { + "schemas": { + "RecommendedWhiskyItemRequest": { + "type": "object", + "description": "큐레이션 위스키 카드", + "x-field-style": "alcohol-card", + "x-display-name": "위스키", + "properties": { + "source": { + "type": "string", + "enum": [ + "BOTTLE_NOTE", + "MANUAL" + ], + "description": "위스키 데이터 출처", + "example": "BOTTLE_NOTE" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "큐레이터 코멘트", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "큐레이터 코멘트", + "nullable": true + } + }, + "required": [ + "source", + "alcohol" + ], + "x-form-style": "alcohol-list", + "x-container": "array" + }, + "RecommendedWhiskyItemResponse": { + "type": "object", + "description": "큐레이션 위스키 카드 조회 응답", + "x-field-style": "alcohol-card", + "x-display-name": "위스키", + "properties": { + "source": { + "type": "string", + "description": "위스키 데이터 출처" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "큐레이터 코멘트", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "큐레이터 코멘트", + "nullable": true + }, + "stats": { + "type": "object", + "nullable": true, + "description": "DB alcoholId가 있을 때만 조회 시 보강되는 통계", + "x-graphql": { + "query": "alcohols", + "argFrom": "$.alcohol.alcoholId", + "argName": "ids", + "argType": "[ID!]!", + "writeTo": "stats", + "resultKey": "alcoholId" + }, + "properties": { + "rating": { + "type": "number", + "format": "double", + "nullable": true, + "x-graphql": true, + "x-field-style": "rating-display", + "x-display-name": "평균 별점" + }, + "totalRatingsCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "평점 수" + }, + "reviewCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "리뷰 수" + }, + "totalPickCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "찜 수" + } + } + } + }, + "required": [ + "source", + "alcohol" + ], + "x-form-style": "alcohol-list", + "x-container": "array" + } + } + } +} diff --git a/bottlenote-mono/src/main/resources/openapi/curation/whisky_pairing.json b/bottlenote-mono/src/main/resources/openapi/curation/whisky_pairing.json new file mode 100644 index 000000000..a60b0f7ea --- /dev/null +++ b/bottlenote-mono/src/main/resources/openapi/curation/whisky_pairing.json @@ -0,0 +1,409 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "위스키 페어링", + "description": "위스키 1건과 그 위스키에 어울리는 페어링 음식 N개를 묶어 소개하는 큐레이션.", + "version": "2.0.0" + }, + "x-curation": { + "code": "WHISKY_PAIRING", + "hydratorKey": "alcohol", + "container": "array" + }, + "paths": {}, + "components": { + "schemas": { + "WhiskyPairingItemRequest": { + "type": "object", + "description": "큐레이션 위스키 카드", + "x-field-style": "alcohol-card", + "x-display-name": "위스키", + "properties": { + "source": { + "type": "string", + "enum": [ + "BOTTLE_NOTE", + "MANUAL" + ], + "description": "위스키 데이터 출처", + "example": "BOTTLE_NOTE" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "위스키 기대평", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "위스키 기대평", + "nullable": true + }, + "pairings": { + "type": "array", + "description": "해당 위스키와 함께 보여줄 페어링 음식 목록", + "example": [ + { + "itemName": "부드러운 티라미수 초콜릿", + "itemImageUrl": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "pairingNote": "진한 초콜릿 향이 위스키의 단맛과 이어진다." + } + ], + "x-field-style": "pairing-food-list", + "x-display-name": "위스키와 페어링할 음식", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "description": "페어링 음식 한 항목", + "example": { + "itemName": "부드러운 티라미수 초콜릿", + "itemImageUrl": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "pairingNote": "진한 초콜릿 향이 위스키의 단맛과 이어진다." + }, + "x-field-style": "none", + "x-display-name": "페어링 음식", + "properties": { + "itemName": { + "type": "string", + "description": "페어링 음식명", + "example": "부드러운 티라미수 초콜릿", + "maxLength": 100, + "x-field-style": "plain-text", + "x-display-name": "음식명" + }, + "itemImageUrl": { + "type": "string", + "description": "페어링 음식 이미지 URL", + "example": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "음식 이미지" + }, + "pairingNote": { + "type": "string", + "description": "페어링 설명", + "example": "진한 초콜릿 향이 위스키의 단맛과 이어진다.", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "페어링 설명" + } + }, + "required": [ + "itemName", + "pairingNote" + ] + } + } + }, + "required": [ + "source", + "alcohol", + "pairings" + ], + "x-form-style": "pairing-list", + "x-container": "array" + }, + "WhiskyPairingItemResponse": { + "type": "object", + "description": "큐레이션 위스키 카드 조회 응답", + "x-field-style": "alcohol-card", + "x-display-name": "위스키", + "properties": { + "source": { + "type": "string", + "description": "위스키 데이터 출처" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "큐레이터 코멘트", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "큐레이터 코멘트", + "nullable": true + }, + "stats": { + "type": "object", + "nullable": true, + "description": "DB alcoholId가 있을 때만 조회 시 보강되는 통계", + "x-graphql": { + "query": "alcohols", + "argFrom": "$.alcohol.alcoholId", + "argName": "ids", + "argType": "[ID!]!", + "writeTo": "stats", + "resultKey": "alcoholId" + }, + "properties": { + "rating": { + "type": "number", + "format": "double", + "nullable": true, + "x-graphql": true, + "x-field-style": "rating-display", + "x-display-name": "평균 별점" + }, + "totalRatingsCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "평점 수" + }, + "reviewCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "리뷰 수" + }, + "totalPickCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "찜 수" + } + } + }, + "pairings": { + "type": "array", + "description": "해당 위스키와 함께 보여줄 페어링 음식 목록", + "example": [ + { + "itemName": "부드러운 티라미수 초콜릿", + "itemImageUrl": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "pairingNote": "진한 초콜릿 향이 위스키의 단맛과 이어진다." + } + ], + "x-field-style": "pairing-food-list", + "x-display-name": "페어링 음식", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "description": "페어링 음식 한 항목", + "example": { + "itemName": "부드러운 티라미수 초콜릿", + "itemImageUrl": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "pairingNote": "진한 초콜릿 향이 위스키의 단맛과 이어진다." + }, + "x-field-style": "none", + "x-display-name": "페어링 음식", + "properties": { + "itemName": { + "type": "string", + "description": "페어링 음식명", + "example": "부드러운 티라미수 초콜릿", + "maxLength": 100, + "x-field-style": "plain-text", + "x-display-name": "음식명" + }, + "itemImageUrl": { + "type": "string", + "description": "페어링 음식 이미지 URL", + "example": "https://images.unsplash.com/photo-1551024601-bec78aea704b?w=480", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "음식 이미지" + }, + "pairingNote": { + "type": "string", + "description": "페어링 설명", + "example": "진한 초콜릿 향이 위스키의 단맛과 이어진다.", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "페어링 설명" + } + }, + "required": [ + "itemName", + "pairingNote" + ] + } + } + }, + "required": [ + "source", + "alcohol", + "pairings" + ], + "x-form-style": "pairing-list", + "x-container": "array" + } + } + } +} diff --git a/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json new file mode 100644 index 000000000..61c482625 --- /dev/null +++ b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json @@ -0,0 +1,509 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "위스키 시음회", + "description": "시음회 날짜, 장소, 참가 정보와 시음 위스키 라인업을 함께 발행하는 이벤트형 큐레이션.", + "version": "2.0.0" + }, + "x-curation": { + "code": "WHISKY_TASTING_EVENT", + "hydratorKey": "alcohol", + "container": "object" + }, + "paths": {}, + "components": { + "schemas": { + "WhiskyTastingEventRequest": { + "type": "object", + "description": "위스키 시음회 큐레이션 등록 요청", + "x-form-style": "tasting-form", + "properties": { + "eventDate": { + "type": "string", + "format": "date", + "description": "시음회 날짜", + "example": "2025-06-15", + "x-field-style": "none", + "x-display-name": "시음회 날짜" + }, + "eventTime": { + "type": "string", + "description": "시음회 시간", + "example": "19:30", + "maxLength": 20, + "x-field-style": "none", + "x-display-name": "시음회 시간" + }, + "barAddress": { + "type": "string", + "description": "장소 및 바 주소", + "example": "서울 강남구 테헤란로 123", + "maxLength": 200, + "x-field-style": "plain-text", + "x-display-name": "장소 및 바(bar) 주소" + }, + "detailAddress": { + "type": "string", + "description": "상세 주소", + "example": "2층 도시남 바", + "maxLength": 200, + "x-field-style": "plain-text", + "x-display-name": "상세 주소" + }, + "isRecruiting": { + "type": "boolean", + "description": "시음회 참여자를 모집할지 여부", + "example": true, + "x-field-style": "none", + "x-display-name": "참여자 모집 여부" + }, + "entryFee": { + "type": "integer", + "minimum": 0, + "description": "참가비", + "example": 75000, + "x-field-style": "plain-number", + "x-display-name": "참가비(1인당)" + }, + "capacity": { + "type": "integer", + "minimum": 1, + "maximum": 999, + "description": "총 모집 인원수", + "example": 20, + "x-field-style": "plain-number", + "x-display-name": "총 모집 인원수" + }, + "applicationLink": { + "type": "string", + "description": "신청 링크", + "example": "https://forms.example.com/tasting", + "maxLength": 2048, + "x-field-style": "plain-text", + "x-display-name": "신청링크" + }, + "guideText": { + "type": "string", + "description": "시음회 안내사항", + "example": "시작 10분 전 입장해 주세요.", + "maxLength": 1000, + "x-field-style": "long-text", + "x-display-name": "안내사항" + }, + "alcohols": { + "type": "array", + "description": "시음 위스키 라인업", + "example": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "글렌드로낙 오리지널 12년", + "selectedTags": [ + "셰리" + ] + }, + "comment": "첫 잔으로 가볍게 시작하는 위스키" + } + ], + "x-field-style": "alcohol-card-list", + "x-display-name": "시음 위스키", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "description": "큐레이션 위스키 카드", + "x-field-style": "none", + "x-display-name": "시음 위스키", + "properties": { + "source": { + "type": "string", + "enum": [ + "BOTTLE_NOTE", + "MANUAL" + ], + "description": "위스키 데이터 출처", + "example": "BOTTLE_NOTE" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "위스키 기대평", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "위스키 기대평", + "nullable": true + } + }, + "required": [ + "source", + "alcohol" + ] + } + } + }, + "required": [ + "eventDate", + "eventTime", + "barAddress", + "detailAddress", + "entryFee", + "capacity", + "applicationLink", + "guideText", + "alcohols" + ] + }, + "WhiskyTastingEventResponse": { + "type": "object", + "description": "위스키 시음회 큐레이션 조회 응답", + "x-form-style": "tasting-form", + "properties": { + "eventDate": { + "type": "string", + "format": "date", + "description": "시음회 날짜", + "example": "2025-06-15", + "x-field-style": "none", + "x-display-name": "시음회 날짜" + }, + "eventTime": { + "type": "string", + "description": "시음회 시간", + "example": "19:30", + "maxLength": 20, + "x-field-style": "none", + "x-display-name": "시음회 시간" + }, + "barAddress": { + "type": "string", + "description": "장소 및 바 주소", + "example": "서울 강남구 테헤란로 123", + "maxLength": 200, + "x-field-style": "plain-text", + "x-display-name": "장소 및 바(bar) 주소" + }, + "detailAddress": { + "type": "string", + "description": "상세 주소", + "example": "2층 도시남 바", + "maxLength": 200, + "x-field-style": "plain-text", + "x-display-name": "상세 주소" + }, + "isRecruiting": { + "type": "boolean", + "description": "시음회 참여자 모집 여부", + "example": true, + "x-field-style": "none", + "x-display-name": "참여자 모집 여부" + }, + "entryFee": { + "type": "integer", + "description": "참가비", + "example": 75000, + "x-field-style": "plain-number", + "x-display-name": "참가비(1인당)" + }, + "capacity": { + "type": "integer", + "description": "총 모집 인원수", + "example": 20, + "x-field-style": "plain-number", + "x-display-name": "총 모집 인원수" + }, + "applicationLink": { + "type": "string", + "description": "신청 링크", + "example": "https://forms.example.com/tasting", + "maxLength": 2048, + "x-field-style": "plain-text", + "x-display-name": "신청링크" + }, + "guideText": { + "type": "string", + "description": "시음회 안내사항", + "example": "시작 10분 전 입장해 주세요.", + "maxLength": 1000, + "x-field-style": "long-text", + "x-display-name": "안내사항" + }, + "alcohols": { + "type": "array", + "description": "시음 위스키 라인업 조회 응답", + "example": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "글렌드로낙 오리지널 12년", + "selectedTags": [ + "셰리" + ] + }, + "comment": "첫 잔으로 가볍게 시작하는 위스키" + } + ], + "x-field-style": "alcohol-card-list", + "x-display-name": "시음 위스키", + "items": { + "type": "object", + "description": "큐레이션 위스키 카드 조회 응답", + "x-field-style": "alcohol-card", + "x-display-name": "시음 위스키", + "properties": { + "source": { + "type": "string", + "description": "위스키 데이터 출처" + }, + "alcohol": { + "type": "object", + "description": "발행 시점의 위스키 노출용 미러 데이터", + "properties": { + "alcoholId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "DB 알코올 ID. 직접 입력이면 null", + "example": 1 + }, + "korName": { + "type": "string", + "description": "위스키 한글명", + "example": "글렌드로낙 오리지널 12년", + "maxLength": 100 + }, + "engName": { + "type": "string", + "description": "위스키 영문명", + "example": "GLENDRONACH ORIGINAL 12Y", + "maxLength": 150 + }, + "imageUrl": { + "type": "string", + "description": "위스키 이미지 URL", + "example": "https://img.example.com/alcohols/1.png", + "maxLength": 2048, + "x-field-style": "image-url", + "x-display-name": "이미지" + }, + "regionName": { + "type": "string", + "description": "지역 또는 국가명", + "example": "스코틀랜드/하이랜드", + "maxLength": 80 + }, + "korCategory": { + "type": "string", + "description": "한글 카테고리명", + "example": "싱글 몰트", + "maxLength": 80 + }, + "cask": { + "type": "string", + "description": "캐스크 정보", + "example": "셰리 캐스크", + "maxLength": 120 + }, + "abv": { + "type": "string", + "description": "알코올 도수", + "example": "43", + "maxLength": 20 + }, + "volume": { + "type": "string", + "description": "용량", + "example": "700ml", + "maxLength": 20 + }, + "selectedTags": { + "type": "array", + "description": "최종 선택된 테이스팅 태그명 목록 배열 순서가 앱 노출 순서다.", + "example": [ + "셰리", + "건포도", + "오크" + ], + "maxItems": 12, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "x-field-style": "tag-list", + "x-display-name": "테이스팅 태그", + "x-ui-orderable": true + } + }, + "required": [ + "korName", + "selectedTags" + ], + "x-field-style": "alcohol-card", + "x-display-name": "위스키" + }, + "comment": { + "type": "string", + "description": "큐레이터 코멘트", + "example": "셰리 캐스크 입문용으로 추천", + "maxLength": 500, + "x-field-style": "long-text", + "x-display-name": "큐레이터 코멘트", + "nullable": true + }, + "stats": { + "type": "object", + "nullable": true, + "description": "DB alcoholId가 있을 때만 조회 시 보강되는 통계", + "x-graphql": { + "query": "alcohols", + "argFrom": "$.alcohol.alcoholId", + "argName": "ids", + "argType": "[ID!]!", + "writeTo": "stats", + "resultKey": "alcoholId", + "payloadPath": "$.alcohols" + }, + "properties": { + "rating": { + "type": "number", + "format": "double", + "nullable": true, + "x-graphql": true, + "x-field-style": "rating-display", + "x-display-name": "평균 별점" + }, + "totalRatingsCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "평점 수" + }, + "reviewCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "리뷰 수" + }, + "totalPickCount": { + "type": "integer", + "format": "int64", + "nullable": true, + "x-graphql": true, + "x-display-name": "찜 수" + } + } + } + }, + "required": [ + "source", + "alcohol" + ] + } + } + }, + "required": [ + "eventDate", + "eventTime", + "barAddress", + "detailAddress", + "entryFee", + "capacity", + "applicationLink", + "guideText", + "alcohols" + ] + } + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/CurationOpenApiSpecResourceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/CurationOpenApiSpecResourceTest.java new file mode 100644 index 000000000..c7c4699d7 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/CurationOpenApiSpecResourceTest.java @@ -0,0 +1,58 @@ +package app.bottlenote.curation; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +class CurationOpenApiSpecResourceTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + @DisplayName("큐레이션 OpenAPI 스펙 3종은 리소스에 포함되고 GraphQL 보강 메타를 가진다") + void curationOpenApiSpecs_whenLoaded_containCurationMetadata() throws IOException { + List specs = + List.of( + new SpecResource( + "openapi/curation/recommended_whisky.json", "RECOMMENDED_WHISKY", "array"), + new SpecResource("openapi/curation/whisky_pairing.json", "WHISKY_PAIRING", "array"), + new SpecResource( + "openapi/curation/whisky_tasting_event.json", "WHISKY_TASTING_EVENT", "object")); + + for (SpecResource spec : specs) { + ClassPathResource resource = new ClassPathResource(spec.path()); + + assertThat(resource.exists()).isTrue(); + + JsonNode root = OBJECT_MAPPER.readTree(resource.getInputStream()); + + assertThat(root.path("openapi").asText()).isEqualTo("3.0.3"); + assertThat(root.path("paths").isObject()).isTrue(); + assertThat(root.path("x-curation").path("code").asText()).isEqualTo(spec.code()); + assertThat(root.path("x-curation").path("hydratorKey").asText()).isEqualTo("alcohol"); + assertThat(root.path("x-curation").path("container").asText()).isEqualTo(spec.container()); + assertThat(root.path("components").path("schemas").isObject()).isTrue(); + assertThat(root.toString()) + .contains( + "\"source\"", + "\"BOTTLE_NOTE\"", + "\"MANUAL\"", + "\"alcoholId\"", + "\"stats\"", + "\"x-graphql\"", + "\"totalRatingsCount\"", + "\"reviewCount\"", + "\"totalPickCount\""); + } + } + + private record SpecResource(String path, String code, String container) {} +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java new file mode 100644 index 000000000..647258a5a --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java @@ -0,0 +1,76 @@ +package app.bottlenote.curation.fixture; + +import app.bottlenote.curation.domain.Curation; +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import app.bottlenote.curation.domain.CurationRepository; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import app.bottlenote.curation.dto.request.CurationCreateRequest; +import java.util.LinkedHashMap; +import java.util.Map; + +public class CurationFixtureFactory { + + private final CurationSpecRepository curationSpecRepository; + private final CurationRepository curationRepository; + private final CurationExtensionRepository curationExtensionRepository; + + public CurationFixtureFactory( + CurationSpecRepository curationSpecRepository, + CurationRepository curationRepository, + CurationExtensionRepository curationExtensionRepository) { + this.curationSpecRepository = curationSpecRepository; + this.curationRepository = curationRepository; + this.curationExtensionRepository = curationExtensionRepository; + } + + public CurationSpec saveSpec( + String code, + String name, + String description, + Map requestSpec, + Map responseSpec, + String hydratorKey, + Integer version) { + return curationSpecRepository.save( + CurationSpec.builder() + .code(code) + .name(name) + .description(description) + .requestSpec(copyOf(requestSpec)) + .responseSpec(copyOf(responseSpec)) + .hydratorKey(hydratorKey) + .version(version != null ? version : 1) + .isActive(true) + .build()); + } + + public Curation saveCuration(CurationCreateRequest request) { + Curation saved = + curationRepository.save( + Curation.builder() + .specId(request.specId()) + .name(request.name()) + .description(request.description()) + .coverImageUrl(request.imageUrls().get(0)) + .imageUrl2(request.imageUrls().size() > 1 ? request.imageUrls().get(1) : null) + .imageUrl3(request.imageUrls().size() > 2 ? request.imageUrls().get(2) : null) + .exposureStartDate(request.exposureStartDate()) + .exposureEndDate(request.exposureEndDate()) + .displayOrder(request.displayOrder()) + .isActive(request.isActive()) + .build()); + curationExtensionRepository.save( + CurationExtension.builder() + .curationId(saved.getId()) + .specId(request.specId()) + .payload(request.payload()) + .build()); + return saved; + } + + private static Map copyOf(Map value) { + return value != null ? new LinkedHashMap<>(value) : new LinkedHashMap<>(); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java new file mode 100644 index 000000000..6a8c91c47 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java @@ -0,0 +1,28 @@ +package app.bottlenote.curation.fixture; + +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class InMemoryCurationExtensionRepository implements CurationExtensionRepository { + + private final Map database = new HashMap<>(); + + @Override + public Optional findByCurationId(Long curationId) { + return Optional.ofNullable(database.get(curationId)); + } + + @Override + public CurationExtension save(CurationExtension curationExtension) { + database.put(curationExtension.getCurationId(), curationExtension); + return curationExtension; + } + + @Override + public void deleteByCurationId(Long curationId) { + database.remove(curationId); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java new file mode 100644 index 000000000..5b7fbb6be --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java @@ -0,0 +1,87 @@ +package app.bottlenote.curation.fixture; + +import app.bottlenote.curation.domain.Curation; +import app.bottlenote.curation.domain.CurationRepository; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryCurationRepository implements CurationRepository { + + private final Map database = new HashMap<>(); + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public List findAllByIsActiveTrueOrderByDisplayOrderAscIdAsc() { + return database.values().stream() + .filter(curation -> Boolean.TRUE.equals(curation.getIsActive())) + .sorted(Comparator.comparing(Curation::getDisplayOrder).thenComparing(Curation::getId)) + .toList(); + } + + @Override + public List findAllVisibleOn(LocalDate today) { + return database.values().stream() + .filter(curation -> Boolean.TRUE.equals(curation.getIsActive())) + .filter(curation -> isVisibleOn(curation, today)) + .sorted(Comparator.comparing(Curation::getDisplayOrder).thenComparing(Curation::getId)) + .toList(); + } + + @Override + public Optional findVisibleById(Long id, LocalDate today) { + return findById(id) + .filter(curation -> Boolean.TRUE.equals(curation.getIsActive())) + .filter(curation -> isVisibleOn(curation, today)); + } + + @Override + public Page searchForAdmin(String keyword, Boolean isActive, Pageable pageable) { + List all = + database.values().stream() + .filter( + curation -> + keyword == null || keyword.isBlank() || curation.getName().contains(keyword)) + .filter(curation -> isActive == null || curation.getIsActive().equals(isActive)) + .sorted(Comparator.comparing(Curation::getDisplayOrder).thenComparing(Curation::getId)) + .toList(); + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + List content = start < all.size() ? all.subList(start, end) : List.of(); + return new PageImpl<>(content, pageable, all.size()); + } + + @Override + public Curation save(Curation curation) { + Long id = curation.getId(); + if (id == null) { + id = database.size() + 1L; + ReflectionTestUtils.setField(curation, "id", id); + } + database.put(id, curation); + return curation; + } + + @Override + public void delete(Curation curation) { + database.remove(curation.getId()); + } + + private boolean isVisibleOn(Curation curation, LocalDate today) { + return (curation.getExposureStartDate() == null + || !curation.getExposureStartDate().isAfter(today)) + && (curation.getExposureEndDate() == null + || !curation.getExposureEndDate().isBefore(today)); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationSpecRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationSpecRepository.java new file mode 100644 index 000000000..2586d4d36 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/InMemoryCurationSpecRepository.java @@ -0,0 +1,54 @@ +package app.bottlenote.curation.fixture; + +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryCurationSpecRepository implements CurationSpecRepository { + + private final Map database = new HashMap<>(); + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public Optional findByCode(String code) { + return database.values().stream().filter(spec -> spec.getCode().equals(code)).findFirst(); + } + + @Override + public List findAllByIsActiveTrueOrderByIdAsc() { + return database.values().stream() + .filter(spec -> Boolean.TRUE.equals(spec.getIsActive())) + .sorted(java.util.Comparator.comparing(CurationSpec::getId)) + .toList(); + } + + @Override + public List findAllByIdIn(Collection ids) { + return database.values().stream().filter(spec -> ids.contains(spec.getId())).toList(); + } + + @Override + public boolean existsByCode(String code) { + return findByCode(code).isPresent(); + } + + @Override + public CurationSpec save(CurationSpec curationSpec) { + Long id = curationSpec.getId(); + if (id == null) { + id = database.size() + 1L; + ReflectionTestUtils.setField(curationSpec, "id", id); + } + database.put(id, curationSpec); + return curationSpec; + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolverTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolverTest.java new file mode 100644 index 000000000..30781cb85 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/graphql/GraphQLCurationAlcoholResolverTest.java @@ -0,0 +1,235 @@ +package app.bottlenote.curation.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.fixture.InMemoryAlcoholQueryRepository; +import app.bottlenote.curation.service.GraphQLCurationAlcoholService; +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.picks.constant.PicksStatus; +import app.bottlenote.picks.domain.Picks; +import app.bottlenote.picks.domain.PicksRepository; +import app.bottlenote.picks.dto.response.AlcoholPicksCountResponse; +import app.bottlenote.rating.domain.Rating; +import app.bottlenote.rating.domain.Rating.RatingId; +import app.bottlenote.rating.domain.RatingPoint; +import app.bottlenote.rating.domain.RatingRepository; +import app.bottlenote.rating.dto.dsl.RatingListFetchCriteria; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; +import app.bottlenote.rating.dto.response.RatingListFetchResponse; +import app.bottlenote.rating.dto.response.UserRatingResponse; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import app.bottlenote.review.domain.Review; +import app.bottlenote.review.fixture.InMemoryReviewRepository; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +class GraphQLCurationAlcoholResolverTest { + + @Test + @DisplayName("MANUAL 항목처럼 alcoholId가 null이면 GraphQL 조회에서 제외하고 실제 도메인 통계를 반환한다") + void alcohols_whenManualItemAlcoholIdIsNull_excludesNullAndMissingIdsAndResolvesStats() { + InMemoryAlcoholQueryRepository alcoholRepository = new InMemoryAlcoholQueryRepository(); + FakeRatingRepository ratingRepository = new FakeRatingRepository(); + InMemoryReviewRepository reviewRepository = new InMemoryReviewRepository(); + FakePicksRepository picksRepository = new FakePicksRepository(); + GraphQLCurationAlcoholResolver resolver = + new GraphQLCurationAlcoholResolver( + new GraphQLCurationAlcoholService( + alcoholRepository, ratingRepository, reviewRepository, picksRepository)); + + Alcohol alcohol = alcohol(1L); + alcoholRepository.save(alcohol); + ratingRepository.save(rating(10L, 1L, 4.5)); + ratingRepository.save(rating(11L, 1L, 3.5)); + ratingRepository.save(rating(12L, 1L, 0.0)); + reviewRepository.save(review(1L, ReviewActiveStatus.ACTIVE, ReviewDisplayStatus.PUBLIC)); + reviewRepository.save(review(1L, ReviewActiveStatus.ACTIVE, ReviewDisplayStatus.PRIVATE)); + reviewRepository.save(review(1L, ReviewActiveStatus.DELETED, ReviewDisplayStatus.PUBLIC)); + picksRepository.save(picks(1L, 20L, PicksStatus.PICK)); + picksRepository.save(picks(1L, 21L, PicksStatus.UNPICK)); + + List alcohols = resolver.alcohols(Arrays.asList(null, 1L, 999L)); + List manualOnly = resolver.alcohols(Collections.singletonList(null)); + + assertThat(alcohols).extracting(Alcohol::getId).containsExactly(1L); + assertThat(manualOnly).isEmpty(); + assertThat(resolver.alcoholId(alcohol)).isEqualTo(1L); + assertThat(resolver.regionName(alcohol)).isEqualTo("스코틀랜드"); + assertThat(resolver.ratings(alcohols)).containsEntry(alcohol, 4.0); + assertThat(resolver.totalRatingsCounts(alcohols)).containsEntry(alcohol, 2L); + assertThat(resolver.reviewCounts(alcohols)).containsEntry(alcohol, 1L); + assertThat(resolver.totalPickCounts(alcohols)).containsEntry(alcohol, 1L); + } + + private static Alcohol alcohol(Long alcoholId) { + Region region = Region.builder().id(1L).korName("스코틀랜드").engName("Scotland").build(); + return Alcohol.builder() + .id(alcoholId) + .korName("테스트 위스키") + .engName("Test Whisky") + .abv("40%") + .type(AlcoholType.WHISKY) + .korCategory("위스키") + .engCategory("Whisky") + .categoryGroup(AlcoholCategoryGroup.SINGLE_MALT) + .region(region) + .cask("Oak") + .imageUrl("https://example.com/test-whisky.jpg") + .volume("700ml") + .build(); + } + + private static Rating rating(Long userId, Long alcoholId, Double rating) { + return Rating.builder() + .id(RatingId.is(userId, alcoholId)) + .ratingPoint(RatingPoint.of(rating)) + .build(); + } + + private static Review review( + Long alcoholId, ReviewActiveStatus activeStatus, ReviewDisplayStatus displayStatus) { + return Review.builder() + .userId(1L) + .alcoholId(alcoholId) + .content("리뷰") + .activeStatus(activeStatus) + .status(displayStatus) + .build(); + } + + private static Picks picks(Long alcoholId, Long userId, PicksStatus status) { + return Picks.builder().alcoholId(alcoholId).userId(userId).status(status).build(); + } + + private static final class FakeRatingRepository implements RatingRepository { + + private final Map ratings = new HashMap<>(); + + @Override + public Rating save(Rating rating) { + ratings.put(rating.getId(), rating); + return rating; + } + + @Override + public Optional findById(RatingId ratingId) { + return Optional.ofNullable(ratings.get(ratingId)); + } + + @Override + public List findAll() { + return List.copyOf(ratings.values()); + } + + @Override + public List findAllByIdIn(List ids) { + return ids.stream().map(ratings::get).filter(Objects::nonNull).toList(); + } + + @Override + public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { + return findById(RatingId.is(userId, alcoholId)); + } + + @Override + public PageResponse fetchRatingList(RatingListFetchCriteria criteria) { + throw new UnsupportedOperationException("not used in GraphQL resolver test"); + } + + @Override + public Optional fetchUserRating(Long alcoholId, Long userId) { + throw new UnsupportedOperationException("not used in GraphQL resolver test"); + } + + @Override + public Double findAverageRatingByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .filter(rating -> Objects.equals(rating.getId().getAlcoholId(), alcoholId)) + .mapToDouble(rating -> rating.getRatingPoint().getRating()) + .filter(rating -> rating > 0.0) + .average() + .orElse(0.0); + } + + @Override + public Long countByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .filter(rating -> Objects.equals(rating.getId().getAlcoholId(), alcoholId)) + .map(rating -> rating.getRatingPoint().getRating()) + .filter(rating -> rating > 0.0) + .count(); + } + + @Override + public List findStatsByAlcoholIds(List alcoholIds) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholRatingStatsResponse( + alcoholId, + findAverageRatingByAlcoholId(alcoholId), + countByAlcoholId(alcoholId))) + .filter(stats -> stats.totalRatingsCount() > 0) + .toList(); + } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .anyMatch(rating -> Objects.equals(rating.getId().getAlcoholId(), alcoholId)); + } + } + + private static final class FakePicksRepository implements PicksRepository { + + private final List picks = new java.util.ArrayList<>(); + + @Override + public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { + return picks.stream() + .filter(pick -> Objects.equals(pick.getAlcoholId(), alcoholId)) + .filter(pick -> Objects.equals(pick.getUserId(), userId)) + .findFirst(); + } + + @Override + public Long countByAlcoholIdAndStatus(Long alcoholId, PicksStatus status) { + return picks.stream() + .filter(pick -> Objects.equals(pick.getAlcoholId(), alcoholId)) + .filter(pick -> pick.getStatus() == status) + .count(); + } + + @Override + public List countByAlcoholIdsAndStatus( + List alcoholIds, PicksStatus status) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholPicksCountResponse( + alcoholId, countByAlcoholIdAndStatus(alcoholId, status))) + .filter(count -> count.totalPickCount() > 0) + .toList(); + } + + @Override + public Picks save(Picks pick) { + picks.add(pick); + return pick; + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java new file mode 100644 index 000000000..42058a5ae --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java @@ -0,0 +1,170 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.dto.request.CurationCreateRequest; +import app.bottlenote.curation.dto.request.CurationSearchRequest; +import app.bottlenote.curation.dto.request.CurationUpdateRequest; +import app.bottlenote.curation.dto.response.AdminSpecBasedCurationDetailResponse; +import app.bottlenote.curation.exception.CurationException; +import app.bottlenote.curation.exception.CurationExceptionCode; +import app.bottlenote.curation.fixture.CurationFixtureFactory; +import app.bottlenote.curation.fixture.InMemoryCurationExtensionRepository; +import app.bottlenote.curation.fixture.InMemoryCurationRepository; +import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; +import app.bottlenote.global.data.response.GlobalResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("AdminSpecBasedCurationService 단위 테스트") +class AdminSpecBasedCurationServiceTest { + + InMemoryCurationSpecRepository curationSpecRepository; + InMemoryCurationRepository curationRepository; + InMemoryCurationExtensionRepository curationExtensionRepository; + CurationFixtureFactory curationFixtureFactory; + AdminSpecBasedCurationService adminSpecBasedCurationService; + + @BeforeEach + void setUp() { + curationSpecRepository = new InMemoryCurationSpecRepository(); + curationRepository = new InMemoryCurationRepository(); + curationExtensionRepository = new InMemoryCurationExtensionRepository(); + ObjectMapper objectMapper = new ObjectMapper(); + curationFixtureFactory = + new CurationFixtureFactory( + curationSpecRepository, curationRepository, curationExtensionRepository); + adminSpecBasedCurationService = + new AdminSpecBasedCurationService( + curationSpecRepository, + curationRepository, + curationExtensionRepository, + new CurationPayloadValidator(objectMapper)); + } + + @Test + @DisplayName("큐레이션을 생성하면 imageUrls 첫 번째 이미지를 coverImageUrl에 저장하고 payload를 함께 저장한다") + void create_이미지와_payload_저장() { + CurationSpec spec = createSpec(); + + var result = adminSpecBasedCurationService.create(createRequest(spec.getId())); + + AdminSpecBasedCurationDetailResponse detail = + adminSpecBasedCurationService.getDetail(result.targetId()); + assertThat(result.code()).isEqualTo("CURATION_CREATED"); + assertThat(detail.coverImageUrl()).isEqualTo("https://cdn.example.com/cover.jpg"); + assertThat(detail.imageUrls()) + .containsExactly("https://cdn.example.com/cover.jpg", "https://cdn.example.com/second.jpg"); + assertThat(detail.spec().code()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(new ObjectMapper().valueToTree(detail.payload()).path("source").asText()) + .isEqualTo("BOTTLE_NOTE"); + } + + @Test + @DisplayName("큐레이션 스펙 상세를 조회할 수 있다") + void getSpecDetail_스펙_상세_조회() { + CurationSpec spec = createSpec(); + + var result = adminSpecBasedCurationService.getSpecDetail(spec.getId()); + + assertThat(result.id()).isEqualTo(spec.getId()); + assertThat(result.code()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(result.requestSpec()).containsKey("required"); + } + + @Test + @DisplayName("payload가 requestSpec required 필드를 만족하지 않으면 예외가 발생한다") + void create_payload_검증_실패() { + CurationSpec spec = createSpec(); + + assertThatThrownBy( + () -> + adminSpecBasedCurationService.create( + new CurationCreateRequest( + spec.getId(), + "잘못된 큐레이션", + null, + List.of("https://cdn.example.com/cover.jpg"), + null, + null, + 0, + true, + Map.of("source", "BOTTLE_NOTE")))) + .isInstanceOf(CurationException.class) + .hasFieldOrPropertyWithValue( + "exceptionCode", CurationExceptionCode.CURATION_PAYLOAD_INVALID); + } + + @Test + @DisplayName("큐레이션을 수정하면 본문과 extension payload를 함께 갱신한다") + void update_본문과_payload_수정() { + CurationSpec spec = createSpec(); + Long curationId = adminSpecBasedCurationService.create(createRequest(spec.getId())).targetId(); + + var result = + adminSpecBasedCurationService.update( + curationId, + new CurationUpdateRequest( + spec.getId(), + "수정된 큐레이션", + "수정된 설명", + List.of("https://cdn.example.com/updated.jpg"), + LocalDate.of(2026, 7, 1), + LocalDate.of(2026, 7, 31), + 5, + false, + Map.of("source", "MANUAL", "alcohol", Map.of("korName", "수동 입력")))); + + AdminSpecBasedCurationDetailResponse detail = + adminSpecBasedCurationService.getDetail(curationId); + assertThat(result.code()).isEqualTo("CURATION_UPDATED"); + assertThat(detail.name()).isEqualTo("수정된 큐레이션"); + assertThat(detail.isActive()).isFalse(); + assertThat(detail.imageUrls()).containsExactly("https://cdn.example.com/updated.jpg"); + } + + @Test + @DisplayName("큐레이션 목록을 조회할 수 있다") + void search_목록_조회() { + CurationSpec spec = createSpec(); + adminSpecBasedCurationService.create(createRequest(spec.getId())); + + GlobalResponse result = + adminSpecBasedCurationService.search(new CurationSearchRequest("", null, 0, 20)); + + assertThat(result.getData()).asList().hasSize(1); + } + + private CurationSpec createSpec() { + return curationFixtureFactory.saveSpec( + "RECOMMENDED_WHISKY", + "추천 위스키", + null, + Map.of("type", "object", "required", List.of("source", "alcohol")), + Map.of("type", "object"), + "alcohol", + 1); + } + + private CurationCreateRequest createRequest(Long specId) { + return new CurationCreateRequest( + specId, + "비 오는 날 위스키", + "스모키 위스키 추천", + List.of("https://cdn.example.com/cover.jpg", "https://cdn.example.com/second.jpg"), + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30), + 1, + true, + Map.of("source", "BOTTLE_NOTE", "alcohol", Map.of("korName", "테스트 위스키"))); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPayloadValidatorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPayloadValidatorTest.java new file mode 100644 index 000000000..f9a543bb6 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPayloadValidatorTest.java @@ -0,0 +1,368 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.curation.service.CurationPayloadValidator.MapBackedSchema; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +@DisplayName("CurationPayloadValidator 단위 테스트") +class CurationPayloadValidatorTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private final CurationPayloadValidator validator = new CurationPayloadValidator(OBJECT_MAPPER); + + @Test + @DisplayName("추천 위스키 requestSpec은 유효한 BOTTLE_NOTE 배열 payload를 허용한다") + void validate_whenRecommendedWhiskyBottleNotePayloadIsValid_returnsEmptyErrors() + throws IOException { + Map requestSpec = schema("recommended_whisky.json", "Request"); + + List errors = + validator.validate( + new MapBackedSchema(requestSpec), List.of(recommendedItem("BOTTLE_NOTE"))); + + assertThat(errors).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("validRequestPayloadsBySpec") + @DisplayName("큐레이션 스펙별 유효한 request payload일 경우 requestSpec 검증을 통과한다") + void validate_whenPayloadMatchesEachRequestSpec_returnsEmptyErrors( + String specCode, String resourceName, Object payload) throws IOException { + Map requestSpec = schema(resourceName, "Request"); + + List errors = validator.validate(new MapBackedSchema(requestSpec), payload); + + assertThat(errors).as(specCode).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("validResponsePayloadsBySpec") + @DisplayName("큐레이션 스펙별 대표 materialized payload일 경우 responseSpec 검증을 통과한다") + void validate_whenMaterializedPayloadMatchesEachResponseSpec_returnsEmptyErrors( + String specCode, String resourceName, Object payload) throws IOException { + Map responseSpec = schema(resourceName, "Response"); + + List errors = validator.validate(new MapBackedSchema(responseSpec), payload); + + assertThat(errors).as(specCode).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidRequestPayloadsBySpec") + @DisplayName("큐레이션 스펙별 필수 payload가 누락될 경우 requestSpec 검증 오류를 반환한다") + void validate_whenRequiredPayloadBySpecIsMissing_returnsErrors( + String specCode, String resourceName, Object payload, String expectedError) + throws IOException { + Map requestSpec = schema(resourceName, "Request"); + + List errors = validator.validate(new MapBackedSchema(requestSpec), payload); + + assertThat(errors).as(specCode).contains(expectedError); + } + + @Test + @DisplayName("requestSpec의 x-container가 array이면 root payload는 비어 있거나 object일 수 없다") + void validate_whenArrayContainerIsEmptyOrObject_returnsContainerErrors() throws IOException { + Map requestSpec = schema("recommended_whisky.json", "Request"); + + List emptyErrors = validator.validate(new MapBackedSchema(requestSpec), List.of()); + List objectErrors = + validator.validate(new MapBackedSchema(requestSpec), recommendedItem("BOTTLE_NOTE")); + + assertThat(emptyErrors).containsExactly("payload 배열은 비어 있을 수 없습니다."); + assertThat(objectErrors).containsExactly("$ payload는 array여야 합니다."); + } + + @Test + @DisplayName("requestSpec의 x-container가 object이면 root payload 배열을 허용하지 않는다") + void validate_whenObjectContainerReceivesArray_returnsRootTypeError() throws IOException { + Map requestSpec = schema("whisky_tasting_event.json", "Request"); + + List errors = + validator.validate( + new MapBackedSchema(requestSpec), + List.of(tastingEventPayload(0, 1, List.of(recommendedItem("BOTTLE_NOTE"))))); + + assertThat(errors).containsExactly("$ 타입이 object이어야 합니다."); + } + + @Test + @DisplayName("requestSpec은 required, enum, type 불일치를 상세 path로 검증한다") + void validate_whenRequiredEnumOrTypeInvalid_returnsDetailedErrors() throws IOException { + Map requestSpec = schema("recommended_whisky.json", "Request"); + Map invalid = + map("source", "UNKNOWN", "alcohol", map("korName", 123, "selectedTags", List.of("셰리"))); + + List errors = validator.validate(new MapBackedSchema(requestSpec), List.of(invalid)); + + assertThat(errors) + .contains("$[0].source 값이 허용된 enum이 아닙니다.", "$[0].alcohol.korName 타입이 string이어야 합니다."); + } + + @Test + @DisplayName("requestSpec은 중첩 배열의 maxItems, minLength, maxLength 경계값을 검증한다") + void validate_whenNestedArrayAndLengthBoundsInvalid_returnsErrors() throws IOException { + Map requestSpec = schema("recommended_whisky.json", "Request"); + Map invalid = + recommendedItem( + "BOTTLE_NOTE", + map( + "alcoholId", + 1, + "korName", + "가".repeat(101), + "selectedTags", + List.of( + "셰리", + "", + "오크", + "피트", + "과일", + "바닐라", + "꿀", + "초콜릿", + "몰트", + "스모키", + "꽃", + "견과", + "긴태그".repeat(11)))); + + List errors = validator.validate(new MapBackedSchema(requestSpec), List.of(invalid)); + + assertThat(errors) + .contains( + "$[0].alcohol.korName 문자열 길이는 최대 100자여야 합니다.", + "$[0].alcohol.selectedTags 배열 크기는 최대 12개여야 합니다.", + "$[0].alcohol.selectedTags[1] 문자열 길이는 최소 1자여야 합니다.", + "$[0].alcohol.selectedTags[12] 문자열 길이는 최대 30자여야 합니다."); + } + + @Test + @DisplayName("시음회 requestSpec은 숫자와 배열의 최솟값, 최댓값 경계를 검증한다") + void validate_whenTastingEventNumericAndArrayBoundsInvalid_returnsErrors() throws IOException { + Map requestSpec = schema("whisky_tasting_event.json", "Request"); + Map invalid = + tastingEventPayload( + 0, + 1000, + List.of( + recommendedItem("BOTTLE_NOTE"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"), + recommendedItem("MANUAL"))); + + List errors = validator.validate(new MapBackedSchema(requestSpec), invalid); + + assertThat(errors) + .contains("$.capacity 값은 최대 999 이하여야 합니다.", "$.alcohols 배열 크기는 최대 10개여야 합니다."); + assertThat(errors).doesNotContain("$.entryFee 값은 최소 0 이상이어야 합니다."); + } + + @Test + @DisplayName("시음회 requestSpec은 최소 경계값을 만족하면 통과한다") + void validate_whenTastingEventMinimumBoundaryValid_returnsEmptyErrors() throws IOException { + Map requestSpec = schema("whisky_tasting_event.json", "Request"); + + List errors = + validator.validate( + new MapBackedSchema(requestSpec), + tastingEventPayload(0, 1, List.of(recommendedItem("BOTTLE_NOTE")))); + + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("responseSpec은 materialized stats가 들어간 추천 위스키 응답 payload를 허용한다") + void validate_whenRecommendedResponsePayloadMatchesResponseSpec_returnsEmptyErrors() + throws IOException { + Map responseSpec = schema("recommended_whisky.json", "Response"); + Map item = + recommendedItem( + "BOTTLE_NOTE", + map("alcoholId", 1, "korName", "테스트 위스키", "selectedTags", List.of("셰리"))); + item.put( + "stats", + map("rating", 4.2, "totalRatingsCount", 10, "reviewCount", 3, "totalPickCount", 8)); + + List errors = validator.validate(new MapBackedSchema(responseSpec), List.of(item)); + + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("responseSpec은 stats 내부 타입까지 검증한다") + void validate_whenResponseStatsTypeInvalid_returnsErrors() throws IOException { + Map responseSpec = schema("recommended_whisky.json", "Response"); + Map item = recommendedItem("BOTTLE_NOTE"); + item.put( + "stats", + map("rating", "4.2", "totalRatingsCount", "10", "reviewCount", 3, "totalPickCount", 8)); + + List errors = validator.validate(new MapBackedSchema(responseSpec), List.of(item)); + + assertThat(errors) + .contains( + "$[0].stats.rating 타입이 number이어야 합니다.", + "$[0].stats.totalRatingsCount 타입이 integer이어야 합니다."); + } + + private static Map schema(String resourceName, String suffix) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + JsonNode schemas = root.path("components").path("schemas"); + JsonNode schema = + schemas.properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + return OBJECT_MAPPER.convertValue(schema, MAP_TYPE); + } + + private static Map recommendedItem(String source) { + return recommendedItem( + source, map("alcoholId", 1, "korName", "테스트 위스키", "selectedTags", List.of("셰리"))); + } + + private static Map recommendedItem(String source, Map alcohol) { + return map("source", source, "alcohol", alcohol, "comment", null); + } + + private static Map pairingItem(String source) { + Map item = recommendedItem(source); + item.put( + "pairings", + List.of( + map( + "itemName", + "다크 초콜릿", + "itemImageUrl", + "https://cdn.example.com/pairing/chocolate.jpg", + "pairingNote", + "셰리 향과 단맛을 이어준다."))); + return item; + } + + private static Map itemWithStats(Map item) { + Map itemWithStats = new LinkedHashMap<>(item); + itemWithStats.put( + "stats", + map("rating", 4.2, "totalRatingsCount", 10, "reviewCount", 3, "totalPickCount", 8)); + return itemWithStats; + } + + private static Map withoutField(Map source, String field) { + Map copy = new LinkedHashMap<>(source); + copy.remove(field); + return copy; + } + + private static Map tastingEventPayload( + int entryFee, int capacity, List> alcohols) { + return map( + "eventDate", + "2026-06-15", + "eventTime", + "19:30", + "barAddress", + "서울 강남구 테헤란로 123", + "detailAddress", + "2층 도시남 바", + "isRecruiting", + true, + "entryFee", + entryFee, + "capacity", + capacity, + "applicationLink", + "https://forms.example.com/tasting", + "guideText", + "시작 10분 전 입장해 주세요.", + "alcohols", + alcohols); + } + + private static Stream validRequestPayloadsBySpec() { + return Stream.of( + Arguments.of( + "RECOMMENDED_WHISKY", + "recommended_whisky.json", + List.of(recommendedItem("BOTTLE_NOTE"))), + Arguments.of("WHISKY_PAIRING", "whisky_pairing.json", List.of(pairingItem("BOTTLE_NOTE"))), + Arguments.of( + "WHISKY_TASTING_EVENT", + "whisky_tasting_event.json", + tastingEventPayload(0, 1, List.of(recommendedItem("BOTTLE_NOTE"))))); + } + + private static Stream validResponsePayloadsBySpec() { + return Stream.of( + Arguments.of( + "RECOMMENDED_WHISKY", + "recommended_whisky.json", + List.of(itemWithStats(recommendedItem("BOTTLE_NOTE")))), + Arguments.of( + "WHISKY_PAIRING", + "whisky_pairing.json", + List.of(itemWithStats(pairingItem("BOTTLE_NOTE")))), + Arguments.of( + "WHISKY_TASTING_EVENT", + "whisky_tasting_event.json", + tastingEventPayload(0, 1, List.of(itemWithStats(recommendedItem("BOTTLE_NOTE")))))); + } + + private static Stream invalidRequestPayloadsBySpec() { + return Stream.of( + Arguments.of( + "RECOMMENDED_WHISKY", + "recommended_whisky.json", + List.of(withoutField(recommendedItem("BOTTLE_NOTE"), "alcohol")), + "$[0].alcohol 필드는 필수입니다."), + Arguments.of( + "WHISKY_PAIRING", + "whisky_pairing.json", + List.of(recommendedItem("BOTTLE_NOTE")), + "$[0].pairings 필드는 필수입니다."), + Arguments.of( + "WHISKY_TASTING_EVENT", + "whisky_tasting_event.json", + withoutField( + tastingEventPayload(0, 1, List.of(recommendedItem("BOTTLE_NOTE"))), + "applicationLink"), + "$.applicationLink 필드는 필수입니다.")); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationResponseMaterializerTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationResponseMaterializerTest.java new file mode 100644 index 000000000..ed0253b1b --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationResponseMaterializerTest.java @@ -0,0 +1,293 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.bottlenote.curation.exception.CurationException; +import app.bottlenote.curation.exception.CurationExceptionCode; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +@DisplayName("CurationResponseMaterializer 단위 테스트") +class CurationResponseMaterializerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + @Test + @DisplayName("source: BOTTLE_NOTE(내부 알코올 참조)는 저장된 메타 정보를 유지하고 stats만 GraphQL 결과로 보강한다") + void materialize_whenRootArrayPayload_hydratesBottleNoteStatsOnly() throws IOException { + FakeGraphQLCurationExecutor executor = + new FakeGraphQLCurationExecutor( + List.of( + map( + "alcoholId", + "1", + "rating", + 4.2, + "totalRatingsCount", + 10, + "reviewCount", + 3, + "totalPickCount", + 8, + "korName", + "GraphQL 원본명", + "selectedTags", + List.of("GraphQL 태그")))); + CurationResponseMaterializer materializer = materializer(executor); + List> payload = + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 1, "korName", "테스트", "selectedTags", List.of("셰리"))), + item("MANUAL", map("alcoholId", null, "korName", "수동", "selectedTags", List.of("오크")))); + + Object result = + materializer.materialize( + 1L, "RECOMMENDED_WHISKY", schema("recommended_whisky.json", "Response"), payload); + + JsonNode resultNode = OBJECT_MAPPER.valueToTree(result); + assertThat(executor.executedQueries()).hasSize(1); + assertThat(executor.executedQueries().get(0).query()).contains("alcohols(ids: $ids)"); + assertThat(executor.executedQueries().get(0).variables().get("ids")).isEqualTo(List.of(1L)); + assertThat(resultNode.get(0).path("alcohol").path("korName").asText()).isEqualTo("테스트"); + assertThat(resultNode.get(0).path("alcohol").path("korName").asText()) + .isNotEqualTo("GraphQL 원본명"); + assertThat(resultNode.get(0).path("alcohol").path("selectedTags").get(0).asText()) + .isEqualTo("셰리"); + assertThat(resultNode.get(0).path("alcohol").path("selectedTags").get(0).asText()) + .isNotEqualTo("GraphQL 태그"); + assertThat(resultNode.get(0).path("stats").path("rating").asDouble()).isEqualTo(4.2); + assertThat(resultNode.get(0).path("stats").path("totalRatingsCount").asInt()).isEqualTo(10); + assertThat(resultNode.get(0).path("stats").has("alcoholId")).isFalse(); + assertThat(resultNode.get(1).path("stats").isNull()).isTrue(); + assertThat(resultNode.get(1).path("alcohol").path("korName").asText()).isEqualTo("수동"); + } + + @Test + @DisplayName( + "source: BOTTLE_NOTE(내부 알코올 참조)가 중복 alcoholId를 가질 경우 GraphQL 변수는 중복 제거하고 각 항목에 stats를 보강한다") + void materialize_whenDuplicateBottleNoteAlcoholId_deduplicatesQueryVariablesAndHydratesEachItem() + throws IOException { + FakeGraphQLCurationExecutor executor = + new FakeGraphQLCurationExecutor( + List.of( + map( + "alcoholId", + 11, + "rating", + 4.7, + "totalRatingsCount", + 5, + "reviewCount", + 2, + "totalPickCount", + 9))); + CurationResponseMaterializer materializer = materializer(executor); + List> payload = + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 11, "korName", "첫 번째 스냅샷", "selectedTags", List.of("셰리"))), + item( + "BOTTLE_NOTE", + map("alcoholId", 11, "korName", "두 번째 스냅샷", "selectedTags", List.of("피트"))), + item( + "MANUAL", + map("alcoholId", null, "korName", "직접 입력", "selectedTags", List.of("오크")))); + + Object result = + materializer.materialize( + 1L, "RECOMMENDED_WHISKY", schema("recommended_whisky.json", "Response"), payload); + + JsonNode resultNode = OBJECT_MAPPER.valueToTree(result); + assertThat(executor.executedQueries()).hasSize(1); + assertThat(executor.executedQueries().get(0).variables().get("ids")).isEqualTo(List.of(11L)); + assertThat(resultNode.get(0).path("alcohol").path("korName").asText()).isEqualTo("첫 번째 스냅샷"); + assertThat(resultNode.get(1).path("alcohol").path("korName").asText()).isEqualTo("두 번째 스냅샷"); + assertThat(resultNode.get(0).path("stats").path("rating").asDouble()).isEqualTo(4.7); + assertThat(resultNode.get(1).path("stats").path("rating").asDouble()).isEqualTo(4.7); + assertThat(resultNode.get(0).path("stats").path("totalPickCount").asInt()).isEqualTo(9); + assertThat(resultNode.get(1).path("stats").path("totalPickCount").asInt()).isEqualTo(9); + assertThat(resultNode.get(2).path("stats").isNull()).isTrue(); + } + + @Test + @DisplayName("source: MANUAL(직접 입력)만 있을 경우 GraphQL을 실행하지 않고 저장된 payload 그대로 응답한다") + void materialize_whenOnlyManualItems_doesNotExecuteGraphQLAndKeepsSnapshotPayload() + throws IOException { + FakeGraphQLCurationExecutor executor = new FakeGraphQLCurationExecutor(List.of()); + CurationResponseMaterializer materializer = materializer(executor); + List> payload = + List.of( + item( + "MANUAL", + map("alcoholId", null, "korName", "직접 입력 위스키", "selectedTags", List.of("오크")))); + + Object result = + materializer.materialize( + 1L, "RECOMMENDED_WHISKY", schema("recommended_whisky.json", "Response"), payload); + + JsonNode resultNode = OBJECT_MAPPER.valueToTree(result); + assertThat(executor.executedQueries()).isEmpty(); + assertThat(resultNode.get(0).path("source").asText()).isEqualTo("MANUAL"); + assertThat(resultNode.get(0).path("alcohol").path("korName").asText()).isEqualTo("직접 입력 위스키"); + assertThat(resultNode.get(0).path("alcohol").path("selectedTags").get(0).asText()) + .isEqualTo("오크"); + assertThat(resultNode.get(0).path("stats").isNull()).isTrue(); + } + + @Test + @DisplayName("object payload의 payloadPath=$.alcohols는 하위 alcohols 배열에만 stats를 보강한다") + void materialize_whenNestedPayloadPath_hydratesOnlyAlcoholSubtree() throws IOException { + FakeGraphQLCurationExecutor executor = + new FakeGraphQLCurationExecutor( + List.of( + map( + "alcoholId", + 7, + "rating", + 3.8, + "totalRatingsCount", + 4, + "reviewCount", + 1, + "totalPickCount", + 2))); + CurationResponseMaterializer materializer = materializer(executor); + Map payload = + map( + "eventDate", + "2026-06-15", + "eventTime", + "19:30", + "barAddress", + "서울 강남구", + "detailAddress", + "2층 도시남 바", + "isRecruiting", + true, + "entryFee", + 0, + "capacity", + 20, + "applicationLink", + "https://forms.example.com/tasting", + "guideText", + "안내", + "alcohols", + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 7, "korName", "테스트", "selectedTags", List.of("셰리"))))); + + Object result = + materializer.materialize( + 2L, "WHISKY_TASTING_EVENT", schema("whisky_tasting_event.json", "Response"), payload); + + JsonNode resultNode = OBJECT_MAPPER.valueToTree(result); + assertThat(executor.executedQueries().get(0).variables().get("ids")).isEqualTo(List.of(7L)); + assertThat(resultNode.path("eventDate").asText()).isEqualTo("2026-06-15"); + assertThat(resultNode.path("stats").isMissingNode()).isTrue(); + assertThat(resultNode.path("alcohols").get(0).path("stats").path("reviewCount").asInt()) + .isEqualTo(1); + } + + @Test + @DisplayName("GraphQL 실행 errors가 있으면 부분 응답을 만들지 않고 실패한다") + void materialize_whenGraphQLResultHasErrors_throwsExecutionFailed() throws IOException { + CurationResponseMaterializer materializer = materializer(new ErrorGraphQLCurationExecutor()); + List> payload = + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 1, "korName", "테스트", "selectedTags", List.of("셰리")))); + + assertThatThrownBy( + () -> + materializer.materialize( + 1L, + "RECOMMENDED_WHISKY", + schema("recommended_whisky.json", "Response"), + payload)) + .isInstanceOf(CurationException.class) + .hasFieldOrPropertyWithValue( + "exceptionCode", CurationExceptionCode.CURATION_GRAPHQL_EXECUTION_FAILED); + } + + private static CurationResponseMaterializer materializer(GraphQLCurationExecutor executor) { + return new CurationResponseMaterializer( + OBJECT_MAPPER, + new GraphQLCurationQueryBuilder(), + executor, + new CurationPayloadValidator(OBJECT_MAPPER)); + } + + private static Map schema(String resourceName, String suffix) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + JsonNode schemas = root.path("components").path("schemas"); + JsonNode schema = + schemas.properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + return OBJECT_MAPPER.convertValue(schema, MAP_TYPE); + } + + private static Map item(String source, Map alcohol) { + return map("source", source, "alcohol", alcohol, "comment", null); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } + + private static final class FakeGraphQLCurationExecutor implements GraphQLCurationExecutor { + + private final List> alcohols; + private final List executedQueries = new ArrayList<>(); + + FakeGraphQLCurationExecutor(List> alcohols) { + this.alcohols = alcohols; + } + + @Override + public Map execute( + Long curationId, int index, GraphQLCurationQueryBuilder.Result query) { + executedQueries.add(query); + return map("data", map(query.entryField(), alcohols)); + } + + List executedQueries() { + return executedQueries; + } + } + + private static final class ErrorGraphQLCurationExecutor implements GraphQLCurationExecutor { + + @Override + public Map execute( + Long curationId, int index, GraphQLCurationQueryBuilder.Result query) { + return map("errors", List.of(map("message", "forced graphql error"))); + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java new file mode 100644 index 000000000..1cd495b9b --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java @@ -0,0 +1,56 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.dto.response.CurationSpecSyncResponse; +import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; +import app.bottlenote.curation.support.CurationSpecResourceReader; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +@Tag("unit") +@DisplayName("CurationSpecResourceSyncService 단위 테스트") +class CurationSpecResourceSyncServiceTest { + + @Test + @DisplayName("리소스 OpenAPI 스펙을 curation_spec으로 생성하고 재실행 시 갱신한다") + void sync_리소스_스펙_생성_및_갱신() { + InMemoryCurationSpecRepository curationSpecRepository = new InMemoryCurationSpecRepository(); + CurationSpecResourceReader resourceReader = + new CurationSpecResourceReader( + new PathMatchingResourcePatternResolver(), new ObjectMapper()); + CurationSpecResourceSyncService service = + new CurationSpecResourceSyncService(curationSpecRepository, resourceReader); + + CurationSpecSyncResponse firstResult = service.sync(); + + assertThat(firstResult.createdCount()).isEqualTo(3); + assertThat(firstResult.updatedCount()).isZero(); + assertThat(curationSpecRepository.findAllByIsActiveTrueOrderByIdAsc()).hasSize(3); + assertThat( + curationSpecRepository.findAllByIsActiveTrueOrderByIdAsc().stream() + .map(CurationSpec::getCode) + .toList()) + .containsExactlyInAnyOrder("RECOMMENDED_WHISKY", "WHISKY_PAIRING", "WHISKY_TASTING_EVENT"); + + CurationSpec recommended = + curationSpecRepository.findByCode("RECOMMENDED_WHISKY").orElseThrow(); + assertThat(recommended.getVersion()).isEqualTo(2); + assertThat(recommended.getRequestSpec()).containsKey("required"); + assertThat(recommended.getResponseSpec().toString()).contains("x-graphql", "stats"); + + CurationSpecSyncResponse secondResult = service.sync(); + + assertThat(secondResult.createdCount()).isZero(); + assertThat(secondResult.updatedCount()).isEqualTo(3); + assertThat(curationSpecRepository.findAllByIsActiveTrueOrderByIdAsc()) + .extracting(CurationSpec::getCode) + .containsExactlyElementsOf( + List.of("RECOMMENDED_WHISKY", "WHISKY_PAIRING", "WHISKY_TASTING_EVENT")); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java new file mode 100644 index 000000000..1ace40adf --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/ProductSpecBasedCurationServiceTest.java @@ -0,0 +1,220 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.dto.request.CurationCreateRequest; +import app.bottlenote.curation.dto.response.ProductSpecBasedCurationDetailResponse; +import app.bottlenote.curation.exception.CurationException; +import app.bottlenote.curation.exception.CurationExceptionCode; +import app.bottlenote.curation.fixture.CurationFixtureFactory; +import app.bottlenote.curation.fixture.InMemoryCurationExtensionRepository; +import app.bottlenote.curation.fixture.InMemoryCurationRepository; +import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +@DisplayName("ProductSpecBasedCurationService 단위 테스트") +class ProductSpecBasedCurationServiceTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + InMemoryCurationSpecRepository specRepository; + InMemoryCurationRepository curationRepository; + InMemoryCurationExtensionRepository extensionRepository; + CurationFixtureFactory curationFixtureFactory; + ProductSpecBasedCurationService productService; + + @BeforeEach + void setUp() { + specRepository = new InMemoryCurationSpecRepository(); + curationRepository = new InMemoryCurationRepository(); + extensionRepository = new InMemoryCurationExtensionRepository(); + curationFixtureFactory = + new CurationFixtureFactory(specRepository, curationRepository, extensionRepository); + CurationResponseMaterializer materializer = + new CurationResponseMaterializer( + OBJECT_MAPPER, + new GraphQLCurationQueryBuilder(), + new FixedGraphQLCurationExecutor(), + new CurationPayloadValidator(OBJECT_MAPPER)); + productService = + new ProductSpecBasedCurationService( + curationRepository, specRepository, extensionRepository, materializer); + } + + @Test + @DisplayName("Product v2 목록은 활성이고 노출 기간에 포함된 큐레이션만 displayOrder, id 순서로 반환한다") + void listActiveCurations_whenMixedStatus_returnsOnlyActiveItemsInDisplayOrder() + throws IOException { + CurationSpec spec = createSpec(); + Long laterId = createCuration(spec.getId(), "뒤", 20, true); + createCuration(spec.getId(), "비활성", 1, false); + createCuration( + spec.getId(), "미노출", 1, true, LocalDate.now().plusDays(1), LocalDate.now().plusDays(5)); + createCuration( + spec.getId(), "노출종료", 1, true, LocalDate.now().minusDays(5), LocalDate.now().minusDays(1)); + Long firstId = createCuration(spec.getId(), "앞", 1, true); + + var result = productService.listActiveCurations(); + + assertThat(result).hasSize(2); + assertThat(result).extracting("id").containsExactly(firstId, laterId); + assertThat(result.get(0).specCode()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(result.get(0).imageUrls()).containsExactly("https://cdn.example.com/cover.jpg"); + } + + @Test + @DisplayName("Product v2 상세는 spec meta와 responseSpec 기준으로 stats가 보강된 payload를 반환한다") + void getDetail_whenCurationExists_returnsMaterializedPayload() throws IOException { + CurationSpec spec = createSpec(); + Long curationId = createCuration(spec.getId(), "상세", 1, true); + + ProductSpecBasedCurationDetailResponse result = productService.getDetail(curationId); + + JsonNode payload = OBJECT_MAPPER.valueToTree(result.payload()); + assertThat(result.spec().code()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(result.spec().container()).isEqualTo("array"); + assertThat(result.spec().responseSpec()).containsKey("properties"); + assertThat(payload.get(0).path("stats").path("totalPickCount").asInt()).isEqualTo(8); + assertThat(payload.get(0).path("stats").has("alcoholId")).isFalse(); + assertThat(payload.get(1).path("stats").isNull()).isTrue(); + } + + @Test + @DisplayName("비활성 큐레이션 상세 조회는 Product v2에서 찾을 수 없다") + void getDetail_whenCurationInactive_throwsNotFound() throws IOException { + CurationSpec spec = createSpec(); + Long curationId = createCuration(spec.getId(), "비활성", 1, false); + + assertThatThrownBy(() -> productService.getDetail(curationId)) + .isInstanceOf(CurationException.class) + .hasFieldOrPropertyWithValue("exceptionCode", CurationExceptionCode.CURATION_NOT_FOUND); + } + + @Test + @DisplayName("노출 기간 밖 큐레이션 상세 조회는 Product v2에서 찾을 수 없다") + void getDetail_whenCurationOutsideExposureWindow_throwsNotFound() throws IOException { + CurationSpec spec = createSpec(); + Long curationId = + createCuration( + spec.getId(), "미노출", 1, true, LocalDate.now().plusDays(1), LocalDate.now().plusDays(5)); + + assertThatThrownBy(() -> productService.getDetail(curationId)) + .isInstanceOf(CurationException.class) + .hasFieldOrPropertyWithValue("exceptionCode", CurationExceptionCode.CURATION_NOT_FOUND); + } + + private CurationSpec createSpec() throws IOException { + return curationFixtureFactory.saveSpec( + "RECOMMENDED_WHISKY", + "추천 위스키", + "추천 설명", + schema("recommended_whisky.json", "Request"), + schema("recommended_whisky.json", "Response"), + "alcohol", + 2); + } + + private Long createCuration(Long specId, String name, int displayOrder, boolean active) { + return createCuration( + specId, + name, + displayOrder, + active, + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(1)); + } + + private Long createCuration( + Long specId, + String name, + int displayOrder, + boolean active, + LocalDate exposureStartDate, + LocalDate exposureEndDate) { + return curationFixtureFactory + .saveCuration( + new CurationCreateRequest( + specId, + name, + "설명", + List.of("https://cdn.example.com/cover.jpg"), + exposureStartDate, + exposureEndDate, + displayOrder, + active, + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 1, "korName", "테스트", "selectedTags", List.of("셰리"))), + item( + "MANUAL", + map("alcoholId", null, "korName", "수동", "selectedTags", List.of("오크")))))) + .getId(); + } + + private static Map schema(String resourceName, String suffix) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + JsonNode schemas = root.path("components").path("schemas"); + JsonNode schema = + schemas.properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + return OBJECT_MAPPER.convertValue(schema, MAP_TYPE); + } + + private static Map item(String source, Map alcohol) { + return map("source", source, "alcohol", alcohol, "comment", null); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } + + private static final class FixedGraphQLCurationExecutor implements GraphQLCurationExecutor { + + @Override + public Map execute( + Long curationId, int index, GraphQLCurationQueryBuilder.Result query) { + return map( + "data", + map( + query.entryField(), + List.of( + map( + "alcoholId", + 1, + "rating", + 4.2, + "totalRatingsCount", + 10, + "reviewCount", + 3, + "totalPickCount", + 8)))); + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/graphql/GraphQLCurationSchemaTest.java b/bottlenote-mono/src/test/java/app/bottlenote/graphql/GraphQLCurationSchemaTest.java new file mode 100644 index 000000000..3eb143bd1 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/graphql/GraphQLCurationSchemaTest.java @@ -0,0 +1,45 @@ +package app.bottlenote.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.TypeDefinitionRegistry; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +class GraphQLCurationSchemaTest { + + @Test + @DisplayName("큐레이션 GraphQL SDL은 Alcohol 통계 조회 필드만 노출한다") + void schema_whenParsed_exposesAlcoholStatsOnly() throws IOException { + ClassPathResource resource = new ClassPathResource("graphql/schema.graphqls"); + String schema = resource.getContentAsString(StandardCharsets.UTF_8); + + TypeDefinitionRegistry registry = new SchemaParser().parse(schema); + + assertThat(registry.getType("Query")).isPresent(); + assertThat(registry.getType("Alcohol")).isPresent(); + assertThat(schema).contains("alcohols(ids: [ID!]!): [Alcohol!]!"); + assertThat(schema) + .contains( + "alcoholId", + "korName", + "engName", + "imageUrl", + "regionName", + "korCategory", + "cask", + "abv", + "volume", + "rating", + "totalRatingsCount", + "reviewCount", + "totalPickCount"); + assertThat(schema).doesNotContain("picks(", "ratings(", "reviews("); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index 99642e7a0..3c4c79d11 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -2,9 +2,14 @@ import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewRepository; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; +import app.bottlenote.review.dto.response.AdminReviewListResponse; +import app.bottlenote.review.dto.response.AlcoholReviewCountResponse; import app.bottlenote.review.dto.response.ReviewExploreItem; import app.bottlenote.review.dto.response.ReviewListResponse; import app.bottlenote.review.facade.payload.ReviewInfo; @@ -16,6 +21,9 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryReviewRepository implements ReviewRepository { @@ -64,6 +72,11 @@ public PageResponse getReviewsByMe( return null; } + @Override + public Page searchAdminReviews(AdminReviewSearchRequest request) { + return new PageImpl<>(List.of(), PageRequest.of(request.page(), request.size()), 0); + } + @Override public Optional findByIdAndUserId(Long reviewId, Long userId) { return Optional.empty(); @@ -74,6 +87,29 @@ public List findByUserId(Long userId) { return List.of(); } + @Override + public Long countByAlcoholIdAndActiveStatusAndStatus( + Long alcoholId, ReviewActiveStatus activeStatus, ReviewDisplayStatus status) { + return database.values().stream() + .filter(review -> Objects.equals(review.getAlcoholId(), alcoholId)) + .filter(review -> review.getActiveStatus() == activeStatus) + .filter(review -> review.getStatus() == status) + .count(); + } + + @Override + public List countByAlcoholIdsAndActiveStatusAndStatus( + List alcoholIds, ReviewActiveStatus activeStatus, ReviewDisplayStatus status) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholReviewCountResponse( + alcoholId, + countByAlcoholIdAndActiveStatusAndStatus(alcoholId, activeStatus, status))) + .filter(count -> count.reviewCount() > 0) + .toList(); + } + @Override public boolean existsById(Long reviewId) { return database.containsKey(reviewId); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java index 3121442f4..262f6fb7e 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java @@ -12,6 +12,7 @@ import app.bottlenote.user.domain.User; import jakarta.persistence.EntityManager; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; @@ -104,6 +105,35 @@ public Review persistReview(@NotNull Review.ReviewBuilder builder) { return review; } + /** 어드민 리뷰 목록 테스트용 Review 생성 */ + @Transactional + @NotNull + public Review persistAdminReview( + @NotNull User user, + @NotNull Alcohol alcohol, + @NotNull String content, + @NotNull ReviewActiveStatus activeStatus, + @NotNull ReviewDisplayStatus displayStatus, + double reviewRating, + @NotNull LocalDateTime createAt, + @NotNull LocalDateTime lastModifyAt) { + Review review = + Review.builder() + .userId(user.getId()) + .alcoholId(alcohol.getId()) + .content(content) + .sizeType(SizeType.BOTTLE) + .price(BigDecimal.valueOf(50000)) + .reviewRating(reviewRating) + .activeStatus(activeStatus) + .status(displayStatus) + .build(); + em.persist(review); + em.flush(); + updateReviewTimestamps(review.getId(), createAt, lastModifyAt); + return review; + } + /** 여러 Review 일괄 생성 */ @Transactional @NotNull @@ -256,6 +286,19 @@ private String generateRandomSuffix() { return String.valueOf(counter.incrementAndGet()); } + private void updateReviewTimestamps( + @NotNull Long reviewId, + @NotNull LocalDateTime createAt, + @NotNull LocalDateTime lastModifyAt) { + em.createNativeQuery( + "UPDATE reviews SET create_at = :createAt, last_modify_at = :lastModifyAt WHERE id = :id") + .setParameter("createAt", createAt) + .setParameter("lastModifyAt", lastModifyAt) + .setParameter("id", reviewId) + .executeUpdate(); + em.flush(); + } + /** Review 빌더의 누락 필드 채우기 */ private Review.ReviewBuilder fillMissingReviewFields(Review.ReviewBuilder builder) { // 빌더를 임시로 빌드해서 필드 체크 diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java deleted file mode 100644 index f5ba5d1bb..000000000 --- a/bottlenote-mono/src/test/java/app/bottlenote/user/service/AdminAuthServiceTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package app.bottlenote.user.service; - -import static org.junit.jupiter.api.Assertions.*; - -class AdminAuthServiceTest {} diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 65087b4f5..0664a8fd2 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.1.4 +1.1.6 diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index ea655f9e0..82732a76d 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -97,7 +97,7 @@ tasks.register("prepareKotlinBuildScriptModel") {} asciidoctor { inputs.dir snippetsDir configurations 'asciidoctorExt' - dependsOn test + dependsOn restDocsTest sources { include("**/product-api.adoc") } diff --git a/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc b/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc new file mode 100644 index 000000000..706898f7d --- /dev/null +++ b/bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc @@ -0,0 +1,61 @@ +=== Spec 기반 큐레이션 v2 목록 조회 === + +- OpenAPI spec 기반으로 등록된 Product 큐레이션 목록을 조회합니다. +- 활성 상태이고 노출 기간에 포함된 큐레이션만 displayOrder, id 순서로 반환합니다. +- 상세 payload는 별도 상세 조회에서 responseSpec 기준으로 materialize 됩니다. + +[source] +---- +GET /api/v2/curations +---- + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/curation/v2/list/curl-request.adoc[] +include::{snippets}/curation/v2/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/curation/v2/list/response-fields.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/curation/v2/list/response-body.adoc[] + +''' + +=== Spec 기반 큐레이션 v2 상세 조회 === + +- 특정 spec 기반 큐레이션의 상세 정보를 조회합니다. +- `payload`는 responseSpec 기준으로 검증된 materialized 응답입니다. +- `BOTTLE_NOTE` 항목은 GraphQL hydration으로 통계가 보강되고, `MANUAL` 항목은 수동 payload를 그대로 반환합니다. +- 비활성 상태이거나 노출 기간 밖의 큐레이션은 조회되지 않습니다. + +[source] +---- +GET /api/v2/curations/{curationId} +---- + +[discrete] +==== 경로 파라미터 ==== + +include::{snippets}/curation/v2/detail/path-parameters.adoc[] + +[discrete] +==== 요청 예시 ==== + +include::{snippets}/curation/v2/detail/curl-request.adoc[] +include::{snippets}/curation/v2/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/curation/v2/detail/response-fields.adoc[] + +[discrete] +==== 응답 예시 ==== + +include::{snippets}/curation/v2/detail/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/api/review/review-modify.adoc b/bottlenote-product-api/src/docs/asciidoc/api/review/review-modify.adoc index e34d200a8..c54e5251d 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/review/review-modify.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/review/review-modify.adoc @@ -15,14 +15,15 @@ ReviewStatus와 SizeType은 아래 ENUM을 참고해주세요. [source] ---- -PATCH /api/v1/reviews +PATCH /api/v1/reviews/{reviewId} ---- [discrete] ==== 요청 파라미터 ==== [discrete] -//include::{snippets}/review/review-modify/request-fields.adoc[] +include::{snippets}/review/review-update/request-fields.adoc[] +include::{snippets}/review/review-update/request-body.adoc[] - Request Body 예시 @@ -33,8 +34,8 @@ PATCH /api/v1/reviews ==== 응답 파라미터 ==== [discrete] -//include::{snippets}/review/review-modify/response-fields.adoc[] -//include::{snippets}/review/review-modify/http-response.adoc[] +include::{snippets}/review/review-update/response-fields.adoc[] +include::{snippets}/review/review-update/http-response.adoc[] [discrete] ==== ReviewStatus ==== diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index e45fe5126..db4722f19 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -96,6 +96,9 @@ include::api/alcohols/categories.adoc[] ''' include::api/alcohols/curations.adoc[] +''' +include::api/curation/v2.adoc[] + ''' include::api/alcohols/popular.adoc[] diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java b/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java new file mode 100644 index 000000000..c9c073784 --- /dev/null +++ b/bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java @@ -0,0 +1,28 @@ +package app.bottlenote.curation.controller; + +import app.bottlenote.curation.service.ProductSpecBasedCurationService; +import app.bottlenote.global.data.response.GlobalResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v2/curations") +@RequiredArgsConstructor +public class ProductSpecBasedCurationController { + + private final ProductSpecBasedCurationService productSpecBasedCurationService; + + @GetMapping + public ResponseEntity getCurations() { + return GlobalResponse.ok(productSpecBasedCurationService.listActiveCurations()); + } + + @GetMapping("/{curationId}") + public ResponseEntity getCuration(@PathVariable Long curationId) { + return GlobalResponse.ok(productSpecBasedCurationService.getDetail(curationId)); + } +} diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index e6334ac16..1418c0ded 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -86,6 +86,10 @@ spring: config: activate: on-profile: default,local + graphql: + graphiql: + enabled: false + path: /graphiql logging: level: diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/repository/CustomJpaAlcoholQueryRepositoryImplTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/repository/CustomJpaAlcoholQueryRepositoryImplTest.java deleted file mode 100644 index 6eb6097f7..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/repository/CustomJpaAlcoholQueryRepositoryImplTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package app.bottlenote.alcohols.repository; - -import static app.bottlenote.alcohols.constant.SearchSortType.REVIEW; -import static app.bottlenote.global.service.cursor.SortOrder.DESC; -import static app.bottlenote.user.constant.SocialType.GOOGLE; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; -import app.bottlenote.alcohols.domain.Alcohol; -import app.bottlenote.alcohols.domain.AlcoholQueryRepository; -import app.bottlenote.alcohols.dto.dsl.AlcoholSearchCriteria; -import app.bottlenote.alcohols.dto.request.AlcoholSearchRequest; -import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; -import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; -import app.bottlenote.config.ModuleConfig; -import app.bottlenote.config.TestConfig; -import app.bottlenote.global.service.cursor.CursorPageable; -import app.bottlenote.global.service.cursor.PageResponse; -import app.bottlenote.rating.domain.Rating; -import app.bottlenote.rating.domain.Rating.RatingId; -import app.bottlenote.rating.domain.RatingPoint; -import app.bottlenote.review.domain.Review; -import app.bottlenote.user.constant.UserType; -import app.bottlenote.user.domain.User; -import app.bottlenote.user.domain.UserRepository; -import jakarta.persistence.EntityManager; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@Disabled("테스트 컨테이너 도입으로 인한 추후 수정 대상 ") -@Tag(value = "data-jpa-test") -@DisplayName("[database] [repository] AlcoholQuery") -@DataJpaTest -@ActiveProfiles("test") -@Import({TestConfig.class, ModuleConfig.class}) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class CustomJpaAlcoholQueryRepositoryImplTest { - - private EntityManager em; - @Autowired private TestEntityManager testEntityManager; - @Autowired private AlcoholQueryRepository alcoholQueryRepository; - @Autowired private UserRepository userRepository; - - static Stream testCase1Provider() { - return Stream.of( - Arguments.of("검색조건이 없어도 조회 된다.", AlcoholSearchRequest.builder().build(), "none"), - Arguments.of( - "키워드를 통해 검색 할 수 있다.", - AlcoholSearchRequest.builder().keyword("아벨라워").build(), - "keyword"), - Arguments.of( - "카테고리를 통해 검색 할 수 있다.", - AlcoholSearchRequest.builder().category(AlcoholCategoryGroup.BLEND).build(), - "category"), - Arguments.of( - "지역을 통해 검색 할 수 있다.", - AlcoholSearchRequest.builder().regionId(5L).pageSize(1L).build(), - "region"), - Arguments.of( - "정렬 조건을 지정할 수 있다.", - AlcoholSearchRequest.builder().sortType(REVIEW).sortOrder(DESC).pageSize(2L).build(), - "sort"), - Arguments.of( - "페이지 조건을 지정할 수 있다.", - AlcoholSearchRequest.builder().cursor(5L).pageSize(8L).build(), - "page")); - } - - @BeforeEach - void init() { - em = testEntityManager.getEntityManager(); - - Alcohol alcohol = alcoholQueryRepository.findById(1L).orElseThrow(); - User user = - userRepository.save( - User.builder() - .email("test@emai.com") - .nickName("test") - .role(UserType.ROLE_USER) - .socialType(new ArrayList<>(List.of(GOOGLE))) - .build()); - - Review review = - Review.builder().alcoholId(alcohol.getId()).userId(user.getId()).content("맛있어요").build(); - RatingId ratingId = RatingId.is(alcohol.getId(), user.getId()); - Rating rating_1 = Rating.builder().id(ratingId).ratingPoint(RatingPoint.of(4.5)).build(); - - em.persist(review); - em.persist(rating_1); - } - - @AfterEach - void tearDown() { - em.clear(); - } - - @Transactional(readOnly = true) - @ParameterizedTest(name = "[{index}]{0}") - @DisplayName("검색조건에 따라 술을 조회 할 수 있다.") - @MethodSource("testCase1Provider") - void test_case_1(String description, AlcoholSearchRequest request, String testType) { - - System.out.println(description); - - AlcoholSearchCriteria criteria = AlcoholSearchCriteria.of(request, null); - - // when - PageResponse response = alcoholQueryRepository.searchAlcohols(criteria); - - // then - AlcoholSearchResponse content = response.content(); - List alcohols = content.getAlcohols(); - Long totalCount = content.getTotalCount(); - - assertNotNull(response); - assertTrue(totalCount > 0); - - edgeTest(request, testType, alcohols, response); - } - - private void edgeTest( - AlcoholSearchRequest request, - String testType, - List alcohols, - PageResponse response) { - switch (testType) { - case "keyword": - assertTrue( - alcohols.stream() - .allMatch( - detail -> - detail.getKorName().contains(request.keyword()) - || detail.getEngName().contains(request.keyword()))); - break; - case "category": - assertTrue( - alcohols.stream() - .allMatch( - detail -> request.category().containsCategory(detail.getEngCategoryName()))); - break; - case "region": - String regionAlcohol = "아란"; // 리전ID 5는 아란이 포함된 술이 있음 - assertTrue( - alcohols.stream() - .allMatch( - detail -> - detail.getKorName().contains(regionAlcohol) - || detail.getEngName().contains(regionAlcohol))); - break; - case "sort": - System.out.println("test case sort"); - AlcoholsSearchItem detail_1 = alcohols.get(0); - AlcoholsSearchItem detail_2 = alcohols.get(1); - assertTrue(detail_1.getReviewCount() > detail_2.getReviewCount()); - break; - case "page": - System.out.println("test case page"); - CursorPageable pageable = response.cursorPageable(); - assertEquals(request.cursor(), pageable.getCurrentCursor()); - assertEquals(request.pageSize(), pageable.getPageSize()); - System.out.println(pageable); - break; - } - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java index 58d29f7ee..bf38c482a 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java @@ -7,10 +7,12 @@ import app.bottlenote.operation.utils.TestContainersConfig; import com.amazonaws.services.s3.AmazonS3; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.testcontainers.containers.MinIOContainer; +@Tag("integration") @DisplayName("[integration] MinIO 컨테이너 로딩 테스트") class MinioContainerLoadingTest extends IntegrationTestSupport { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java new file mode 100644 index 000000000..eacddd52c --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java @@ -0,0 +1,323 @@ +package app.bottlenote.curation.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import app.bottlenote.curation.dto.request.CurationCreateRequest; +import app.bottlenote.curation.service.AdminSpecBasedCurationService; +import app.bottlenote.curation.service.CurationSpecResourceSyncService; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.picks.fixture.PicksTestFactory; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.review.fixture.ReviewTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.fixture.UserTestFactory; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.assertj.MvcTestResult; + +@Tag("integration") +@DisplayName("[integration] Product Spec Based Curation API 통합 테스트") +class ProductSpecBasedCurationIntegrationTest extends IntegrationTestSupport { + + @Autowired private CurationSpecResourceSyncService curationSpecResourceSyncService; + @Autowired private CurationSpecRepository curationSpecRepository; + @Autowired private AdminSpecBasedCurationService adminSpecBasedCurationService; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private AlcoholQueryRepository alcoholRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private RatingTestFactory ratingTestFactory; + @Autowired private ReviewTestFactory reviewTestFactory; + @Autowired private PicksTestFactory picksTestFactory; + + private CurationSpec recommendedSpec; + + @BeforeEach + void setUp() { + curationSpecResourceSyncService.sync(); + recommendedSpec = curationSpecRepository.findByCode("RECOMMENDED_WHISKY").orElseThrow(); + } + + @Nested + @DisplayName("큐레이션 목록 조회 API") + class ListCurations { + + @Test + @DisplayName("활성이고 노출 기간에 포함된 큐레이션만 displayOrder와 id 순서로 조회할 수 있다") + void listActiveCurations_whenMixedStatus_returnsOnlyActiveItemsInDisplayOrder() + throws Exception { + // given + Long laterId = createCuration("뒤 큐레이션", 20, true, List.of(manualItem("뒤"))); + createCuration("비활성 큐레이션", 1, false, List.of(manualItem("비활성"))); + createCuration( + "미노출 큐레이션", + 1, + true, + LocalDate.now().plusDays(1), + LocalDate.now().plusDays(5), + List.of(manualItem("미노출"))); + createCuration( + "노출종료 큐레이션", + 1, + true, + LocalDate.now().minusDays(5), + LocalDate.now().minusDays(1), + List.of(manualItem("노출종료"))); + Long firstId = createCuration("앞 큐레이션", 1, true, List.of(manualItem("앞"))); + + // when + MvcTestResult result = + mockMvcTester.get().uri("/api/v2/curations").contentType(APPLICATION_JSON).exchange(); + + // then + JsonNode data = dataNode(result); + assertThat(data).hasSize(2); + assertThat(data.get(0).path("id").asLong()).isEqualTo(firstId); + assertThat(data.get(1).path("id").asLong()).isEqualTo(laterId); + assertThat(data.get(0).path("specCode").asText()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(data.get(0).path("imageUrls").get(0).asText()) + .isEqualTo("https://cdn.example.com/cover.jpg"); + } + + @Test + @DisplayName("활성 큐레이션이 없으면 빈 배열을 반환한다") + void listActiveCurations_whenNoActiveCuration_returnsEmptyArray() throws Exception { + // when + MvcTestResult result = + mockMvcTester.get().uri("/api/v2/curations").contentType(APPLICATION_JSON).exchange(); + + // then + assertThat(dataNode(result)).isEmpty(); + } + } + + @Nested + @DisplayName("큐레이션 상세 조회 API") + class GetCurationDetail { + + @Test + @DisplayName("BOTTLE_NOTE 항목은 GraphQL로 통계를 보강하고 MANUAL 항목은 stats를 null로 응답한다") + void getDetail_whenBottleNoteAndManualItems_returnsPayloadMatchingResponseSpec() + throws Exception { + // given + Alcohol alcohol = + alcoholTestFactory.persistAlcoholWithName("통합 테스트 위스키", "Integration Whisky"); + User userA = userTestFactory.persistUser(); + User userB = userTestFactory.persistUser(); + ratingTestFactory.persistRating(userA, alcohol, 4); + ratingTestFactory.persistRating(userB, alcohol, 5); + reviewTestFactory.persistReview(userA, alcohol); + picksTestFactory.persistPicks(alcohol.getId(), userA.getId()); + Long curationId = + createCuration( + "상세 큐레이션", 1, true, List.of(bottleNoteItem(alcohol), manualItem("수동 위스키"))); + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v2/curations/{curationId}", curationId) + .contentType(APPLICATION_JSON) + .exchange(); + + // then + JsonNode data = dataNode(result); + JsonNode payload = data.path("payload"); + assertThat(data.path("id").asLong()).isEqualTo(curationId); + assertThat(data.path("spec").path("code").asText()).isEqualTo("RECOMMENDED_WHISKY"); + assertThat(data.path("spec").path("container").asText()).isEqualTo("array"); + assertThat(payload).hasSize(2); + assertThat(payload.get(0).path("stats").path("rating").asDouble()).isEqualTo(4.5); + assertThat(payload.get(0).path("stats").path("totalRatingsCount").asLong()).isEqualTo(2L); + assertThat(payload.get(0).path("stats").path("reviewCount").asLong()).isEqualTo(1L); + assertThat(payload.get(0).path("stats").path("totalPickCount").asLong()).isEqualTo(1L); + assertThat(payload.get(0).path("stats").has("alcoholId")).isFalse(); + assertThat(payload.get(1).path("stats").isNull()).isTrue(); + } + + @Test + @DisplayName("Product v2에서 알코올 원본 정보가 변경된 후 조회할 경우 큐레이션 payload의 저장 시점 메타 정보로 응답하고 현재 통계를 보강한다") + void getDetail_whenSourceAlcoholMetadataChanged_returnsSnapshotMetadataAndCurrentStats() + throws Exception { + // given + Alcohol alcohol = alcoholTestFactory.persistAlcoholWithName("저장 시점 위스키", "Snapshot Whisky"); + Long curationId = createCuration("스냅샷 큐레이션", 1, true, List.of(bottleNoteItem(alcohol))); + changeSourceAlcoholMetadata(alcohol); + assertThat(alcoholRepository.findById(alcohol.getId()).orElseThrow().getKorName()) + .isEqualTo("변경된 원본 위스키"); + User userA = userTestFactory.persistUser(); + User userB = userTestFactory.persistUser(); + ratingTestFactory.persistRating(userA, alcohol, 3); + ratingTestFactory.persistRating(userB, alcohol, 5); + reviewTestFactory.persistReview(userA, alcohol); + picksTestFactory.persistPicks(alcohol.getId(), userA.getId()); + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v2/curations/{curationId}", curationId) + .contentType(APPLICATION_JSON) + .exchange(); + + // then + JsonNode payloadItem = dataNode(result).path("payload").get(0); + assertThat(payloadItem.path("alcohol").path("korName").asText()).isEqualTo("저장 시점 위스키"); + assertThat(payloadItem.path("alcohol").path("korName").asText()).isNotEqualTo("변경된 원본 위스키"); + assertThat(payloadItem.path("alcohol").path("selectedTags").get(0).asText()).isEqualTo("셰리"); + assertThat(payloadItem.path("stats").path("rating").asDouble()).isEqualTo(4.0); + assertThat(payloadItem.path("stats").path("totalRatingsCount").asLong()).isEqualTo(2L); + assertThat(payloadItem.path("stats").path("reviewCount").asLong()).isEqualTo(1L); + assertThat(payloadItem.path("stats").path("totalPickCount").asLong()).isEqualTo(1L); + assertThat(payloadItem.path("stats").has("alcoholId")).isFalse(); + } + + @Test + @DisplayName("비활성 큐레이션 상세 조회는 404를 반환한다") + void getDetail_whenInactiveCuration_returnsNotFound() { + // given + Long curationId = createCuration("비활성 상세", 1, false, List.of(manualItem("비활성"))); + + // when & then + assertThat(mockMvcTester.get().uri("/api/v2/curations/{curationId}", curationId)) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.code") + .isEqualTo(404); + } + + @Test + @DisplayName("노출 기간 밖 큐레이션 상세 조회는 404를 반환한다") + void getDetail_whenOutsideExposureWindow_returnsNotFound() { + // given + Long curationId = + createCuration( + "미노출 상세", + 1, + true, + LocalDate.now().plusDays(1), + LocalDate.now().plusDays(5), + List.of(manualItem("미노출"))); + + // when & then + assertThat(mockMvcTester.get().uri("/api/v2/curations/{curationId}", curationId)) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.code") + .isEqualTo(404); + } + + @Test + @DisplayName("존재하지 않는 큐레이션 상세 조회는 404를 반환한다") + void getDetail_whenMissingCuration_returnsNotFound() { + assertThat(mockMvcTester.get().uri("/api/v2/curations/{curationId}", 999999L)) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.code") + .isEqualTo(404); + } + } + + private Long createCuration( + String name, int displayOrder, boolean active, List> payload) { + return createCuration( + name, + displayOrder, + active, + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(1), + payload); + } + + private Long createCuration( + String name, + int displayOrder, + boolean active, + LocalDate exposureStartDate, + LocalDate exposureEndDate, + List> payload) { + return adminSpecBasedCurationService + .create( + new CurationCreateRequest( + recommendedSpec.getId(), + name, + "통합 테스트 큐레이션", + List.of("https://cdn.example.com/cover.jpg"), + exposureStartDate, + exposureEndDate, + displayOrder, + active, + payload)) + .targetId(); + } + + private JsonNode dataNode(MvcTestResult result) throws Exception { + result.assertThat().hasStatusOk(); + GlobalResponse response = + mapper.readValue(result.getResponse().getContentAsString(), GlobalResponse.class); + return mapper.valueToTree(response.getData()); + } + + private Map bottleNoteItem(Alcohol alcohol) { + return item( + "BOTTLE_NOTE", + map( + "alcoholId", + alcohol.getId(), + "korName", + alcohol.getKorName(), + "selectedTags", + List.of("셰리", "오크"))); + } + + private void changeSourceAlcoholMetadata(Alcohol alcohol) { + Alcohol sourceAlcohol = alcoholRepository.findById(alcohol.getId()).orElseThrow(); + sourceAlcohol.update( + "변경된 원본 위스키", + "Changed Source Whisky", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "https://cdn.example.com/changed-source.jpg", + null, + null); + alcoholRepository.save(sourceAlcohol); + } + + private Map manualItem(String name) { + return item("MANUAL", map("alcoholId", null, "korName", name, "selectedTags", List.of("오크"))); + } + + private Map item(String source, Map alcohol) { + return map("source", source, "alcohol", alcohol, "comment", "테스트 코멘트"); + } + + private Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/history/event/UserHistoryListenerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/history/event/UserHistoryListenerTest.java index 829a067c9..c111a3642 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/history/event/UserHistoryListenerTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/history/event/UserHistoryListenerTest.java @@ -8,8 +8,10 @@ import app.bottlenote.history.fixture.InMemoryUserHistoryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +@Tag("unit") class UserHistoryListenerTest { private HistoryListener historyListener; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/picks/fake/FakePicksRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/picks/fake/FakePicksRepository.java index abad274a6..40e1f76ee 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/picks/fake/FakePicksRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/picks/fake/FakePicksRepository.java @@ -3,8 +3,11 @@ import app.bottlenote.picks.constant.PicksStatus; import app.bottlenote.picks.domain.Picks; import app.bottlenote.picks.domain.PicksRepository; +import app.bottlenote.picks.dto.response.AlcoholPicksCountResponse; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.springframework.test.util.ReflectionTestUtils; @@ -25,6 +28,26 @@ public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { .findFirst(); } + @Override + public Long countByAlcoholIdAndStatus(Long alcoholId, PicksStatus status) { + return picksDatabase.values().stream() + .filter(picks -> Objects.equals(picks.getAlcoholId(), alcoholId)) + .filter(picks -> picks.getStatus() == status) + .count(); + } + + @Override + public List countByAlcoholIdsAndStatus( + List alcoholIds, PicksStatus status) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholPicksCountResponse( + alcoholId, countByAlcoholIdAndStatus(alcoholId, status))) + .filter(count -> count.totalPickCount() > 0) + .toList(); + } + @Override public Picks save(Picks picks) { long id = picksDatabase.size() + 1L; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java index d5c56166f..56fc34618 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java @@ -5,6 +5,7 @@ import app.bottlenote.rating.domain.Rating.RatingId; import app.bottlenote.rating.domain.RatingRepository; import app.bottlenote.rating.dto.dsl.RatingListFetchCriteria; +import app.bottlenote.rating.dto.response.AlcoholRatingStatsResponse; import app.bottlenote.rating.dto.response.RatingListFetchResponse; import app.bottlenote.rating.dto.response.UserRatingResponse; import java.util.HashMap; @@ -57,6 +58,37 @@ public Optional fetchUserRating(Long alcoholId, Long userId) return Optional.empty(); } + @Override + public Double findAverageRatingByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .filter(rating -> rating.getId().getAlcoholId().equals(alcoholId)) + .mapToDouble(rating -> rating.getRatingPoint().getRating()) + .filter(rating -> rating > 0.0) + .average() + .orElse(0.0); + } + + @Override + public Long countByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .filter(rating -> rating.getId().getAlcoholId().equals(alcoholId)) + .filter(rating -> rating.getRatingPoint().getRating() > 0.0) + .count(); + } + + @Override + public List findStatsByAlcoholIds(List alcoholIds) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholRatingStatsResponse( + alcoholId, + findAverageRatingByAlcoholId(alcoholId), + countByAlcoholId(alcoholId))) + .filter(stats -> stats.totalRatingsCount() > 0) + .toList(); + } + @Override public boolean existsByAlcoholId(Long alcoholId) { return ratings.values().stream() diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/repository/CustomRatingQueryRepositoryImplTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/repository/CustomRatingQueryRepositoryImplTest.java deleted file mode 100644 index fa8970a43..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/repository/CustomRatingQueryRepositoryImplTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package app.bottlenote.rating.repository; - -import app.bottlenote.global.service.cursor.SortOrder; -import app.bottlenote.rating.constant.SearchSortType; -import app.bottlenote.rating.domain.RatingRepository; -import app.bottlenote.rating.dto.dsl.RatingListFetchCriteria; -import app.bottlenote.rating.dto.response.RatingListFetchResponse; - -/** 해당 테스트는 실제로 어플리케이션을 띄워 실행 결과를 볼수 있는 예시라고 생각하고 참조하세요. 테스트를 실행하려면 해당 테스트를 실행할 수 있는 환경이 필요합니다. */ -// @SpringBootTest -// @ActiveProfiles("dev") -class CustomRatingQueryRepositoryImplTest { - - // @Autowired - private RatingRepository ratingRepository; - - // @Test - void test() { - var criteria = - new RatingListFetchCriteria( - "Yam", null, null, SearchSortType.REVIEW, SortOrder.DESC, 0L, 10L, 33L); - - var response = ratingRepository.fetchRatingList(criteria); - - RatingListFetchResponse content = response.content(); - - content - .ratings() - .forEach( - info -> - System.out.printf( - "ID: %d\nImage URL: %s\nKorean Name: %s\nEnglish Name: %s\nKorean Category Name: %s\nEnglish Category Name: %s\nIs Picked: %b\n\n", - info.alcoholId(), - info.imageUrl(), - info.korName(), - info.engName(), - info.korCategoryName(), - info.engCategoryName(), - info.isPicked())); - - // when - // then - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index 980e2538f..eb02d8afb 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -2,9 +2,14 @@ import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.review.constant.ReviewActiveStatus; +import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewRepository; +import app.bottlenote.review.dto.request.AdminReviewSearchRequest; import app.bottlenote.review.dto.request.ReviewPageableRequest; +import app.bottlenote.review.dto.response.AdminReviewListResponse; +import app.bottlenote.review.dto.response.AlcoholReviewCountResponse; import app.bottlenote.review.dto.response.ReviewExploreItem; import app.bottlenote.review.dto.response.ReviewListResponse; import app.bottlenote.review.facade.payload.ReviewInfo; @@ -16,6 +21,9 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryReviewRepository implements ReviewRepository { @@ -64,6 +72,11 @@ public PageResponse getReviewsByMe( return null; } + @Override + public Page searchAdminReviews(AdminReviewSearchRequest request) { + return new PageImpl<>(List.of(), PageRequest.of(request.page(), request.size()), 0); + } + @Override public Optional findByIdAndUserId(Long reviewId, Long userId) { return database.values().stream() @@ -76,6 +89,29 @@ public List findByUserId(Long userId) { return List.of(); } + @Override + public Long countByAlcoholIdAndActiveStatusAndStatus( + Long alcoholId, ReviewActiveStatus activeStatus, ReviewDisplayStatus status) { + return database.values().stream() + .filter(review -> Objects.equals(review.getAlcoholId(), alcoholId)) + .filter(review -> review.getActiveStatus() == activeStatus) + .filter(review -> review.getStatus() == status) + .count(); + } + + @Override + public List countByAlcoholIdsAndActiveStatusAndStatus( + List alcoholIds, ReviewActiveStatus activeStatus, ReviewDisplayStatus status) { + return alcoholIds.stream() + .map( + alcoholId -> + new AlcoholReviewCountResponse( + alcoholId, + countByAlcoholIdAndActiveStatusAndStatus(alcoholId, activeStatus, status))) + .filter(count -> count.reviewCount() > 0) + .toList(); + } + @Override public boolean existsById(Long reviewId) { return database.containsKey(reviewId); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/service/OauthServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/service/OauthServiceTest.java index 311ba6b0d..08a77e498 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/service/OauthServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/service/OauthServiceTest.java @@ -183,161 +183,4 @@ void reissue_token_fail() { // when & then assertThrows(UserException.class, () -> oauthService.refresh(invalidRefreshToken)); } - - // @Test - // @DisplayName("Apple 로그인 시 Nonce 검증에 실패하면 예외를 발생시킨다.") - // void loginWithApple_invalidNonce_throwsException() { - // // Given - // String idToken = "mockIdToken"; - // String invalidNonce = "invalidNonce"; - - // // When & Then - // assertThrows(UserException.class, () -> oauthService.loginWithApple(idToken, invalidNonce)); - // } - - // @Test - // @DisplayName("Apple 로그인이 성공할 수 있다.") - // void loginWithApple_success() { - // // Given - // String idToken = "validIdToken"; - // String validNonce = nonceService.generateNonce(); - - // // When - // TokenItem result = oauthService.loginWithApple(idToken, validNonce); - - // // Then - // assertNotNull(result); - // assertThat(result.accessToken()).isNotNull().isNotEmpty(); - // assertThat(result.refreshToken()).isNotNull().isNotEmpty(); - - // // 사용자가 저장되었는지 확인 - // assertThat(oauthRepository.findByEmail("apple.user@example.com")).isPresent(); - // } - - // @Test - // @DisplayName("카카오 로그인 - 신규 회원가입이 성공할 수 있다") - // void loginWithKakao_신규회원가입_성공() { - // // Given - // String accessToken = "valid_token_with_email"; - - // // When - // TokenItem result = oauthService.loginWithKakao(accessToken); - - // // Then - // assertNotNull(result); - // assertThat(result.accessToken()).isNotNull().isNotEmpty(); - // assertThat(result.refreshToken()).isNotNull().isNotEmpty(); - - // // 카카오 ID로 사용자가 저장되었는지 확인 - // assertThat(oauthRepository.findBySocialUniqueId("123456789")).isPresent(); - - // // 이메일로도 조회 가능한지 확인 - // assertThat(oauthRepository.findByEmail("test@kakao.com")).isPresent(); - - // User savedUser = oauthRepository.findBySocialUniqueId("123456789").get(); - // assertThat(savedUser.getSocialType()).contains(SocialType.KAKAO); - // assertThat(savedUser.getGender()).isEqualTo(GenderType.FEMALE); - // assertThat(savedUser.getAge()).isEqualTo(24); // 20~29 -> 24 - // } - - // @Test - // @DisplayName("카카오 로그인 - 이메일 없는 사용자도 회원가입할 수 있다") - // void loginWithKakao_이메일없는사용자_회원가입_성공() { - // // Given - // String accessToken = "valid_token_without_email"; - - // // When - // TokenItem result = oauthService.loginWithKakao(accessToken); - - // // Then - // assertNotNull(result); - // assertThat(result.accessToken()).isNotNull().isNotEmpty(); - // assertThat(result.refreshToken()).isNotNull().isNotEmpty(); - - // // 카카오 ID로 사용자가 저장되었는지 확인 - // User savedUser = oauthRepository.findBySocialUniqueId("987654321").orElseThrow(); - // assertThat(savedUser.getEmail()).startsWith("kakao").endsWith("@bottlenote.com"); - // assertThat(savedUser.getSocialType()).contains(SocialType.KAKAO); - // assertThat(savedUser.getGender()).isEqualTo(GenderType.MALE); - // assertThat(savedUser.getAge()).isEqualTo(34); // 30~39 -> 34 - // } - - // @Test - // @DisplayName("카카오 로그인 - 기존 회원은 로그인할 수 있다") - // void loginWithKakao_기존회원_로그인_성공() { - // // Given - // // 기존 사용자를 DB에 미리 저장 - // User existingUser = - // User.builder() - // .email("existing@test.com") - // .socialUniqueId("555555555") - // .socialType(List.of(SocialType.KAKAO)) - // .role(UserType.ROLE_USER) - // .nickName("기존회원") - // .build(); - // oauthRepository.save(existingUser); - - // String accessToken = "existing_user_token"; - - // // When - // TokenItem result = oauthService.loginWithKakao(accessToken); - - // // Then - // assertNotNull(result); - // assertThat(result.accessToken()).isNotNull().isNotEmpty(); - // assertThat(result.refreshToken()).isNotNull().isNotEmpty(); - - // // 기존 사용자가 조회되는지 확인 - // User loginUser = oauthRepository.findBySocialUniqueId("555555555").orElseThrow(); - // assertThat(loginUser.getNickName()).isEqualTo("기존회원"); - // } - - // @Test - // @DisplayName("카카오 로그인 - 잘못된 토큰으로 요청하면 예외가 발생한다") - // void loginWithKakao_잘못된토큰_예외발생() { - // // Given - // String invalidToken = "invalid_token"; - - // // When & Then - // assertThrows(UserException.class, () -> oauthService.loginWithKakao(invalidToken)); - // } - - // @Test - // @DisplayName("카카오 로그인 - 카카오 서버 에러시 예외가 발생한다") - // void loginWithKakao_서버에러_예외발생() { - // // Given - // kakaoFeignClient.simulateServerError(); - // String accessToken = "valid_token_with_email"; - - // // When & Then - // assertThrows(UserException.class, () -> oauthService.loginWithKakao(accessToken)); - // } - - // @Test - // @DisplayName("카카오 로그인 - 이메일로 기존 회원 연동이 가능하다") - // void loginWithKakao_이메일기반_기존회원연동_성공() { - // // Given - // // 기존 사용자를 이메일로만 저장 (카카오 연동 안된 상태) - // User existingUser = - // User.builder() - // .email("test@kakao.com") - // .socialType(new ArrayList<>(List.of(SocialType.BASIC))) - // .role(UserType.ROLE_USER) - // .nickName("기존회원") - // .build(); - // oauthRepository.save(existingUser); - - // String accessToken = "valid_token_with_email"; - - // // When - // TokenItem result = oauthService.loginWithKakao(accessToken); - - // // Then - // assertNotNull(result); - - // // 기존 회원에 카카오 ID가 업데이트되었는지 확인 - // User updatedUser = oauthRepository.findByEmail("test@kakao.com").orElseThrow(); - // assertThat(updatedUser.getSocialUniqueId()).isEqualTo("123456789"); - // assertThat(updatedUser.getNickName()).isEqualTo("기존회원"); // 기존 정보 유지 - // } } diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholExploreControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholExploreControllerTest.java index b398064f4..910e98934 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholExploreControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholExploreControllerTest.java @@ -21,8 +21,10 @@ import app.docs.AbstractRestDocs; import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +@Tag("restdocs") @DisplayName("alcohol 컨트롤러 RestDocs용 테스트") public class RestAlcoholExploreControllerTest extends AbstractRestDocs { private final AlcoholQueryService alcoholQueryService = mock(AlcoholQueryService.class); diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java index 99cde0583..553c42723 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholQueryControllerTest.java @@ -23,8 +23,10 @@ import app.bottlenote.global.service.cursor.PageResponse; import app.docs.AbstractRestDocs; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +@Tag("restdocs") @DisplayName("alcohol 컨트롤러 RestDocs용 테스트") class RestAlcoholQueryControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java index a311b4459..6f7eaf33b 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java @@ -31,7 +31,7 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.ResultActions; -@Tag("rest-docs") +@Tag("restdocs") @DisplayName("큐레이션 키워드 API 문서화 테스트") class RestAlcoholReferenceControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java index 4152492f4..805c38fc0 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java @@ -21,9 +21,11 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +@Tag("restdocs") @DisplayName("Popular API RestDocs") class RestPopularControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java index 48a0c44f5..257187ed7 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java @@ -20,10 +20,12 @@ import app.docs.AbstractRestDocs; import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +@Tag("restdocs") @DisplayName("알코올 참조 컨트롤러 RestDocs용 테스트") class RestReferenceControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java index 02fcf52a9..a5c2b6886 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java @@ -25,7 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.ResultActions; -@Tag("rest-docs") +@Tag("restdocs") @DisplayName("TastingTag API 문서화 테스트") class RestTastingTagControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java index b823f5194..2461348b5 100644 --- a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java @@ -20,9 +20,11 @@ import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("배너 조회 컨트롤러 Rest API 문서화 테스트") class RestBannerQueryControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java new file mode 100644 index 000000000..5bfc6afa4 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java @@ -0,0 +1,196 @@ +package app.docs.curation; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.curation.controller.ProductSpecBasedCurationController; +import app.bottlenote.curation.dto.response.ProductSpecBasedCurationDetailResponse; +import app.bottlenote.curation.dto.response.ProductSpecBasedCurationListResponse; +import app.bottlenote.curation.service.ProductSpecBasedCurationService; +import app.docs.AbstractRestDocs; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("restdocs") +@DisplayName("Product spec 기반 큐레이션 v2 API 문서화 테스트") +class RestProductSpecBasedCurationControllerTest extends AbstractRestDocs { + + private final ProductSpecBasedCurationService productSpecBasedCurationService = + mock(ProductSpecBasedCurationService.class); + + @Override + protected Object initController() { + return new ProductSpecBasedCurationController(productSpecBasedCurationService); + } + + @Test + @DisplayName("spec 기반 큐레이션 v2 목록을 조회할 수 있다") + void getCurations() throws Exception { + when(productSpecBasedCurationService.listActiveCurations()) + .thenReturn( + List.of( + new ProductSpecBasedCurationListResponse( + 1L, + 1L, + "RECOMMENDED_WHISKY", + "추천 위스키", + "비 오는 날 위스키", + "스모키 위스키 추천", + "https://cdn.example.com/cover.jpg", + List.of( + "https://cdn.example.com/cover.jpg", "https://cdn.example.com/second.jpg"), + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30), + 1, + LocalDateTime.of(2026, 5, 15, 12, 0)))); + + mockMvc + .perform(get("/api/v2/curations")) + .andExpect(status().isOk()) + .andDo( + document( + "curation/v2/list", + responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(NUMBER).description("응답 코드"), + fieldWithPath("data").type(ARRAY).description("spec 기반 큐레이션 목록"), + fieldWithPath("data[].id").type(NUMBER).description("큐레이션 ID"), + fieldWithPath("data[].specId").type(NUMBER).description("큐레이션 스펙 ID"), + fieldWithPath("data[].specCode").description("큐레이션 스펙 코드"), + fieldWithPath("data[].specName").description("큐레이션 스펙명"), + fieldWithPath("data[].name").description("큐레이션 이름"), + fieldWithPath("data[].description").description("큐레이션 설명").optional(), + fieldWithPath("data[].coverImageUrl").description("대표 이미지 URL"), + fieldWithPath("data[].imageUrls").type(ARRAY).description("큐레이션 이미지 URL 목록"), + fieldWithPath("data[].exposureStartDate") + .type(ARRAY) + .description("노출 시작일") + .optional(), + fieldWithPath("data[].exposureEndDate") + .type(ARRAY) + .description("노출 종료일") + .optional(), + fieldWithPath("data[].displayOrder").type(NUMBER).description("노출 순서"), + fieldWithPath("data[].createAt").type(ARRAY).description("생성 일시"), + fieldWithPath("errors").type(ARRAY).description("에러 목록"), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + } + + @Test + @DisplayName("spec 기반 큐레이션 v2 상세를 materialized payload로 조회할 수 있다") + void getCuration() throws Exception { + when(productSpecBasedCurationService.getDetail(1L)) + .thenReturn( + new ProductSpecBasedCurationDetailResponse( + 1L, + "비 오는 날 위스키", + "스모키 위스키 추천", + "https://cdn.example.com/cover.jpg", + List.of("https://cdn.example.com/cover.jpg"), + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30), + 1, + LocalDateTime.of(2026, 5, 15, 12, 0), + new ProductSpecBasedCurationDetailResponse.SpecMeta( + 1L, + "RECOMMENDED_WHISKY", + "추천 위스키", + "array", + map("type", "object", "x-container", "array")), + List.of( + item( + "BOTTLE_NOTE", + map("alcoholId", 1, "korName", "테스트 위스키", "selectedTags", List.of("셰리")), + map( + "rating", + 4.2, + "totalRatingsCount", + 10, + "reviewCount", + 3, + "totalPickCount", + 8)), + item( + "MANUAL", + map("alcoholId", null, "korName", "수동 위스키", "selectedTags", List.of("오크")), + null)))); + + mockMvc + .perform(get("/api/v2/curations/{curationId}", 1L)) + .andExpect(status().isOk()) + .andDo( + document( + "curation/v2/detail", + pathParameters(parameterWithName("curationId").description("큐레이션 ID")), + responseFields( + fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(NUMBER).description("응답 코드"), + fieldWithPath("data").type(OBJECT).description("큐레이션 상세"), + fieldWithPath("data.id").type(NUMBER).description("큐레이션 ID"), + fieldWithPath("data.name").description("큐레이션 이름"), + fieldWithPath("data.description").description("큐레이션 설명").optional(), + fieldWithPath("data.coverImageUrl").description("대표 이미지 URL"), + fieldWithPath("data.imageUrls").type(ARRAY).description("큐레이션 이미지 URL 목록"), + fieldWithPath("data.exposureStartDate") + .type(ARRAY) + .description("노출 시작일") + .optional(), + fieldWithPath("data.exposureEndDate") + .type(ARRAY) + .description("노출 종료일") + .optional(), + fieldWithPath("data.displayOrder").type(NUMBER).description("노출 순서"), + fieldWithPath("data.createAt").type(ARRAY).description("생성 일시"), + fieldWithPath("data.spec").type(OBJECT).description("스펙 메타"), + fieldWithPath("data.spec.id").type(NUMBER).description("스펙 ID"), + fieldWithPath("data.spec.code").description("스펙 코드"), + fieldWithPath("data.spec.name").description("스펙명"), + fieldWithPath("data.spec.container") + .description("payload 컨테이너 타입(array 또는 object)"), + subsectionWithPath("data.spec.responseSpec") + .type(OBJECT) + .description("Product 응답 검증 기준 OpenAPI response schema"), + subsectionWithPath("data.payload") + .type(ARRAY) + .description("responseSpec 기준으로 materialized 된 payload"), + fieldWithPath("errors").type(ARRAY).description("에러 목록"), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + } + + private static Map item( + String source, Map alcohol, Map stats) { + return map("source", source, "alcohol", alcohol, "comment", "추천 코멘트", "stats", stats); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} diff --git a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java index d573a0165..07aaf116c 100644 --- a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java @@ -31,11 +31,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("FollowController RestDocs 테스트") class RestDocsFollowControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/history/RestDocsUserHistoryTest.java b/bottlenote-product-api/src/test/java/app/docs/history/RestDocsUserHistoryTest.java index d1be7e5fd..f59c23226 100644 --- a/bottlenote-product-api/src/test/java/app/docs/history/RestDocsUserHistoryTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/history/RestDocsUserHistoryTest.java @@ -30,11 +30,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("UserHistory RestDocs 테스트") class RestDocsUserHistoryTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/likes/RestLikesControllerDocsTest.java b/bottlenote-product-api/src/test/java/app/docs/likes/RestLikesControllerDocsTest.java index 62316cb48..2e8193b45 100644 --- a/bottlenote-product-api/src/test/java/app/docs/likes/RestLikesControllerDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/likes/RestLikesControllerDocsTest.java @@ -24,10 +24,12 @@ import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; +@Tag("restdocs") @DisplayName("RestLikesController API 문서화 테스트") public class RestLikesControllerDocsTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/picks/RestPicksCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/picks/RestPicksCommandControllerTest.java index 45c5ce51f..644b7376a 100644 --- a/bottlenote-product-api/src/test/java/app/docs/picks/RestPicksCommandControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/picks/RestPicksCommandControllerTest.java @@ -21,11 +21,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("Pick 컨트롤러 Rest API 문서화 테스트") class RestPicksCommandControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/rating/RestRatingControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/rating/RestRatingControllerTest.java index 4102c3772..3cdf0f822 100644 --- a/bottlenote-product-api/src/test/java/app/docs/rating/RestRatingControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/rating/RestRatingControllerTest.java @@ -41,11 +41,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +@Tag("restdocs") @DisplayName("별점 RestDocs용 테스트") public class RestRatingControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewControllerDocsTest.java b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewControllerDocsTest.java index 01444738e..120d14d4d 100644 --- a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewControllerDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewControllerDocsTest.java @@ -37,12 +37,14 @@ import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +@Tag("restdocs") @DisplayName("[restdocs] 리뷰 컨트롤러 RestDocs용 테스트") class RestReviewControllerDocsTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewExploreControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewExploreControllerTest.java index 16b0d4314..e0a7de7f2 100644 --- a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewExploreControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewExploreControllerTest.java @@ -23,8 +23,10 @@ import java.util.List; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +@Tag("restdocs") @DisplayName("[restdocs] 리뷰 둘러보기 계열 컨트롤러 RestDocs용 테스트") public class RestReviewExploreControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewReplyControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewReplyControllerTest.java index 67179f9a1..896de1ea2 100644 --- a/bottlenote-product-api/src/test/java/app/docs/review/RestReviewReplyControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/review/RestReviewReplyControllerTest.java @@ -41,7 +41,7 @@ import org.mockito.MockedStatic; import org.springframework.http.MediaType; -@Tag("rest-docs") +@Tag("restdocs") @DisplayName("리뷰 댓글 컨트롤러 RestDocs용 테스트") class RestReviewReplyControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java index bb2ad4144..32775bd74 100644 --- a/bottlenote-product-api/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/support/block/RestDocsBlockControllerTest.java @@ -29,11 +29,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("BlockController RestDocs 테스트") class RestDocsBlockControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/support/help/RestDocsHelpCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/support/help/RestDocsHelpCommandControllerTest.java index 69d02a76b..45b881ef5 100644 --- a/bottlenote-product-api/src/test/java/app/docs/support/help/RestDocsHelpCommandControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/support/help/RestDocsHelpCommandControllerTest.java @@ -36,11 +36,13 @@ import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("문의글 커맨드 컨트롤러 RestDocs용 테스트") class RestDocsHelpCommandControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/support/report/RestDocsReportCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/support/report/RestDocsReportCommandControllerTest.java index 8bc0da993..0644b5b1b 100644 --- a/bottlenote-product-api/src/test/java/app/docs/support/report/RestDocsReportCommandControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/support/report/RestDocsReportCommandControllerTest.java @@ -22,11 +22,13 @@ import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("유저 신고 커맨드 컨트롤러 RestDocs용 테스트") class RestDocsReportCommandControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java index f570e15b3..0b3802c03 100644 --- a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java @@ -18,9 +18,11 @@ import app.docs.AbstractRestDocs; import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.security.test.context.support.WithMockUser; +@Tag("restdocs") @DisplayName("이미지 업로드 RestDocs용 테스트") class RestImageUploadControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/user/OpenApiAuthV2ControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/OpenApiAuthV2ControllerTest.java index 8ac68f8eb..968f5de10 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/OpenApiAuthV2ControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/OpenApiAuthV2ControllerTest.java @@ -23,7 +23,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; -@Tag("document") +@Tag("restdocs") @DisplayName("유저 Auth 컨트롤러 V2x OpenAPI 테스트") class OpenApiAuthV2ControllerTest extends AbstractRestDocs { private final AuthService authService = Mockito.mock(AuthService.class); diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestAuthV2ControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestAuthV2ControllerTest.java index 8618c82b6..686c95f34 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestAuthV2ControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestAuthV2ControllerTest.java @@ -33,7 +33,7 @@ import org.mockito.MockedStatic; import org.springframework.http.MediaType; -@Tag("document") +@Tag("restdocs") @DisplayName("유저 Auth 컨트롤러 V2x RestDocs 테스트") class RestAuthV2ControllerTest extends AbstractRestDocs { private final AuthService authService = mock(AuthService.class); diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserChangeContollerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserChangeContollerTest.java index 97ab897b6..7aaef3fe6 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserChangeContollerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserChangeContollerTest.java @@ -28,11 +28,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; +@Tag("restdocs") @DisplayName("닉네임 변경 RestDocs용 테스트") class RestDocsUserChangeContollerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserQueryControllerTest.java index 492b8b077..0f80daa4a 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestDocsUserQueryControllerTest.java @@ -31,7 +31,7 @@ import org.mockito.Mockito; import org.springframework.http.MediaType; -@Tag("rest-docs") +@Tag("restdocs") @DisplayName("[restdocs] 마이페이지 컨트롤러 RestDocs용 테스트") class RestDocsUserQueryControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java index 8a0cf9037..d80bf93b3 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java @@ -35,7 +35,7 @@ import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; -@Tag("document") +@Tag("restdocs") @DisplayName("유저 Auth 컨트롤러 RestDocs 테스트") class RestOauthControllerTest extends AbstractRestDocs { private final OauthService oauthService = mock(OauthService.class); diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestUserBasicControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestUserBasicControllerTest.java index 0a29b09dd..ed37708e1 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestUserBasicControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestUserBasicControllerTest.java @@ -21,11 +21,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +@Tag("restdocs") @DisplayName("유저 Command 컨트롤러 RestDocs용 테스트") class RestUserBasicControllerTest extends AbstractRestDocs { diff --git a/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java b/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java index 3a0ffeec3..808209004 100644 --- a/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java @@ -63,6 +63,13 @@ void tearDownLogCapture() { .noneMatch(msg -> msg != null && msg.contains("NoResourceFoundException")); } + @Test + @DisplayName("내부 GraphQL 엔드포인트는 외부 HTTP 접근을 차단한다") + void 내부_GraphQL_엔드포인트_외부_접근_차단() { + mockMvcTester.get().uri("/graphiql").exchange().assertThat().hasStatus(FORBIDDEN); + mockMvcTester.post().uri("/graphql").exchange().assertThat().hasStatus(FORBIDDEN); + } + @Test @DisplayName("정상 API 경로는 차단되지 않는다") void 정상_API_경로_허용() { diff --git a/bottlenote-product-api/src/test/java/app/rule/api/ControllerLayerRules.java b/bottlenote-product-api/src/test/java/app/rule/api/ControllerLayerRules.java index 3d3a65f00..361ea279a 100644 --- a/bottlenote-product-api/src/test/java/app/rule/api/ControllerLayerRules.java +++ b/bottlenote-product-api/src/test/java/app/rule/api/ControllerLayerRules.java @@ -39,6 +39,8 @@ @SuppressWarnings({"NonAsciiCharacters", "JUnitTestClassNamingConvention"}) public class ControllerLayerRules extends AbstractRules { + private static final String GRAPHQL_PACKAGE = "..graphql.."; + /** 컨트롤러 클래스 명명 규칙을 검증합니다. 컨트롤러 애노테이션이 있는 모든 클래스는 이름이 'Controller'로 끝나야 합니다. */ @Test public void 컨트롤러_클래스_명명_규칙_검증() { @@ -48,6 +50,8 @@ public class ControllerLayerRules extends AbstractRules { .areAnnotatedWith(RestController.class) .or() .areAnnotatedWith(Controller.class) + .and() + .resideOutsideOfPackage(GRAPHQL_PACKAGE) .should() .haveSimpleNameEndingWith("Controller") .because("컨트롤러 클래스는 명확한 식별을 위해 'Controller'로 끝나야 합니다"); @@ -64,6 +68,8 @@ public class ControllerLayerRules extends AbstractRules { .areAnnotatedWith(RestController.class) .or() .areAnnotatedWith(Controller.class) + .and() + .resideOutsideOfPackage(GRAPHQL_PACKAGE) .should() .resideInAnyPackage("..controller..", "..api..") .because("컨트롤러 클래스는 구조적 일관성을 위해 '.controller' 또는 '.api' 패키지에 위치해야 합니다"); @@ -98,6 +104,8 @@ public class ControllerLayerRules extends AbstractRules { .areAnnotatedWith(RestController.class) .or() .areAnnotatedWith(Controller.class) + .and() + .resideOutsideOfPackage(GRAPHQL_PACKAGE) .should() .beAnnotatedWith(RequestMapping.class) .because("컨트롤러 클래스는 기본 경로 정의를 위해 클래스 수준의 @RequestMapping을 가져야 합니다"); @@ -123,6 +131,9 @@ public class ControllerLayerRules extends AbstractRules { .arePublic() .and() .areNotStatic() + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackage(GRAPHQL_PACKAGE) .should() .beAnnotatedWith(GetMapping.class) .orShould() @@ -251,6 +262,9 @@ public boolean test(JavaClass javaClass) { .areAnnotatedWith(Controller.class) .and() .arePublic() + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackage(GRAPHQL_PACKAGE) .should(followControllerMethodNamingConvention()) .because("컨트롤러 메서드는 명확한 동사로 시작하는 명명 규칙을 따라야 합니다(예: getUser, createOrder)"); diff --git a/bottlenote-product-api/src/test/java/app/rule/test/TestTagRules.java b/bottlenote-product-api/src/test/java/app/rule/test/TestTagRules.java new file mode 100644 index 000000000..9fd1b8488 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/rule/test/TestTagRules.java @@ -0,0 +1,86 @@ +package app.rule.test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("rule") +@DisplayName("테스트 태그 정책") +@SuppressWarnings({"NonAsciiCharacters", "JUnitTestClassNamingConvention"}) +class TestTagRules { + + private static final Set ALLOWED_TAGS = + Set.of("unit", "integration", "admin_integration", "restdocs", "rule", "batch"); + private static final Pattern TAG_PATTERN = + Pattern.compile("@Tag\\(\\s*(?:value\\s*=\\s*)?\"([^\"]+)\"\\s*\\)"); + + @Test + void 모든_테스트_파일은_허용된_태그를_가져야_한다() throws IOException { + Path root = findRepositoryRoot(Path.of("").toAbsolutePath()); + List violations = new ArrayList<>(); + + try (Stream files = Files.walk(root)) { + files + .filter(Files::isRegularFile) + .filter(TestTagRules::isTestSource) + .forEach(path -> collectViolations(root, path, violations)); + } + + assertTrue(violations.isEmpty(), () -> "테스트 태그 정책 위반:\n- " + String.join("\n- ", violations)); + } + + private static Path findRepositoryRoot(Path start) { + Path current = start; + while (current != null) { + if (Files.exists(current.resolve("settings.gradle"))) { + return current; + } + current = current.getParent(); + } + return start; + } + + private static boolean isTestSource(Path path) { + String normalized = path.toString().replace('\\', '/'); + String fileName = path.getFileName().toString(); + return normalized.contains("/src/test/") + && (fileName.endsWith("Test.java") || fileName.endsWith("Test.kt")); + } + + private static void collectViolations(Path root, Path path, List violations) { + String content; + try { + content = Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("테스트 파일을 읽을 수 없습니다: " + path, e); + } + + List tags = new ArrayList<>(); + Matcher matcher = TAG_PATTERN.matcher(content); + while (matcher.find()) { + tags.add(matcher.group(1)); + } + + Path relative = root.relativize(path); + if (tags.isEmpty()) { + violations.add(relative + " - @Tag 누락"); + return; + } + + tags.stream() + .filter(tag -> !ALLOWED_TAGS.contains(tag)) + .forEach(tag -> violations.add(relative + " - 허용되지 않은 @Tag(\"" + tag + "\")")); + } +} diff --git a/build.gradle b/build.gradle index f884faabb..c9c2d64fa 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,7 @@ subprojects { test { outputs.upToDateWhen { false } useJUnitPlatform { - excludeTags 'data-jpa-test', 'integration', 'admin_integration' + excludeTags 'integration', 'admin_integration' } testLogging { events "passed", "skipped", "failed" @@ -154,10 +154,8 @@ subprojects { tasks.register('restDocsTest', Test) { description = 'REST Docs snippets 생성을 위한 테스트' group = 'documentation' - useJUnitPlatform() - filter { - includeTestsMatching 'app.docs.*' - includeTestsMatching 'app.external.docs.*' + useJUnitPlatform { + includeTags 'restdocs' } } } @@ -207,6 +205,50 @@ tasks.register('restDocsTest') { dependsOn subprojects.findAll { it.tasks.findByName('restDocsTest') }.collect { it.tasks.restDocsTest } } +tasks.register('verifyRestDocsIncludes') { + description = 'AsciiDoc include 대상과 REST Docs snippet 존재 여부 검증' + group = 'documentation' + dependsOn tasks.named('restDocsTest') + + doLast { + def missing = [] + ['bottlenote-product-api', 'bottlenote-admin-api'].each { moduleName -> + def docsDir = file("${moduleName}/src/docs/asciidoc") + def snippetsDir = file("${moduleName}/build/generated-snippets") + fileTree(docsDir) { + include '**/*.adoc' + }.each { adoc -> + def matcher = adoc.text =~ /include::(.+?)\[\]/ + matcher.each { match -> + def includePath = match[1] + def includeFile = includePath.startsWith('{snippets}/') + ? new File(snippetsDir, includePath - '{snippets}/') + : new File(adoc.parentFile, includePath) + if (!includeFile.isFile()) { + missing << "${moduleName}:${docsDir.toPath().relativize(adoc.toPath())} -> ${includePath}" + } + } + } + } + if (!missing.isEmpty()) { + throw new GradleException("Missing AsciiDoc includes:\\n- ${missing.join('\\n- ')}") + } + } +} + +tasks.register('docs_test') { + description = 'REST Docs snippets 생성과 AsciiDoc 조립 검증' + group = 'documentation' + dependsOn tasks.named('restDocsTest') + dependsOn tasks.named('verifyRestDocsIncludes') +} + +gradle.projectsEvaluated { + tasks.named('docs_test') { + dependsOn subprojects.findAll { it.tasks.findByName('asciidoctor') }.collect { it.tasks.asciidoctor } + } +} + tasks.register('batch_test') { description = '배치 모듈 테스트 실행 (@Tag("batch"))' group = 'verification' diff --git a/git.environment-variables b/git.environment-variables index 5f4f19f1d..95160c970 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 5f4f19f1dd52a3d12cd83b03330aa8b6904fb548 +Subproject commit 95160c9709dacb1275d55a310d87f7d799fa1191 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de6c71075..a626b0687 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ kotlin = "1.9.23" # Spring Boot Core spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } +spring-boot-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "spring-boot" } spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "spring-boot" } spring-boot-starter-data-redis = { module = "org.springframework.boot:spring-boot-starter-data-redis", version.ref = "spring-boot" } spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "spring-boot" } diff --git "a/http/admin/05_\355\201\220\353\240\210\354\235\264\354\205\230\352\264\200\353\246\254/\354\212\244\355\216\231\352\270\260\353\260\230\355\201\220\353\240\210\354\235\264\354\205\230v2.http" "b/http/admin/05_\355\201\220\353\240\210\354\235\264\354\205\230\352\264\200\353\246\254/\354\212\244\355\216\231\352\270\260\353\260\230\355\201\220\353\240\210\354\235\264\354\205\230v2.http" new file mode 100644 index 000000000..ac52e8ba2 --- /dev/null +++ "b/http/admin/05_\355\201\220\353\240\210\354\235\264\354\205\230\352\264\200\353\246\254/\354\212\244\355\216\231\352\270\260\353\260\230\355\201\220\353\240\210\354\235\264\354\205\230v2.http" @@ -0,0 +1,268 @@ +### 로그인 +# v1 인증 API로 토큰을 받은 뒤 v2 큐레이션 관리 API를 호출한다. +POST {{host}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); + client.global.set("refreshToken", response.body.data.refreshToken); +%} + +### 1. 큐레이션 스펙 목록 조회 +# @no-cookie-jar +GET {{adminApiHost}}/v2/curation-specs +Authorization: Bearer {{accessToken}} + +> {% + const specs = response.body.data || []; + const recommended = specs.find((spec) => spec.code === "RECOMMENDED_WHISKY"); + const pairing = specs.find((spec) => spec.code === "WHISKY_PAIRING"); + const tasting = specs.find((spec) => spec.code === "WHISKY_TASTING_EVENT"); + + if (recommended) client.global.set("recommendedWhiskySpecId", recommended.id); + if (pairing) client.global.set("whiskyPairingSpecId", pairing.id); + if (tasting) client.global.set("whiskyTastingEventSpecId", tasting.id); +%} + +### 2. 큐레이션 스펙 상세 조회 +# @no-cookie-jar +@specId = {{recommendedWhiskySpecId}} +GET {{adminApiHost}}/v2/curation-specs/{{specId}} +Authorization: Bearer {{accessToken}} + +### 3. spec 기반 큐레이션 목록 조회 +# @no-cookie-jar +GET {{adminApiHost}}/v2/curations?keyword=&isActive=true&page=0&size=20 +Authorization: Bearer {{accessToken}} + +> {% + const page = response.body.data || {}; + const content = page.content || page.items || []; + if (content.length > 0) { + client.global.set("curationV2Id", content[0].id); + } +%} + +### 4. spec 기반 큐레이션 상세 조회 +# @no-cookie-jar +@curationId = {{curationV2Id}} +GET {{adminApiHost}}/v2/curations/{{curationId}} +Authorization: Bearer {{accessToken}} + +### 5. 추천 위스키 큐레이션 생성 +# @no-cookie-jar +POST {{adminApiHost}}/v2/curations +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{recommendedWhiskySpecId}}, + "name": "비 오는 날 위스키", + "description": "스모키 위스키 추천", + "imageUrls": [ + "https://cdn.example.com/curation/rainy-whisky-cover.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 1, + "isActive": true, + "payload": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "검증 위스키", + "selectedTags": ["셰리", "오크"] + }, + "comment": "비 오는 날 천천히 마시기 좋은 위스키" + }, + { + "source": "MANUAL", + "alcohol": { + "alcoholId": null, + "korName": "수동 입력 위스키", + "selectedTags": ["스모키"] + }, + "comment": "외부 데이터 없이 직접 노출하는 항목" + } + ] +} + +> {% + if (response.body.data && response.body.data.targetId) { + client.global.set("curationV2Id", response.body.data.targetId); + } +%} + +### 6. 추천 위스키 큐레이션 수정 +# @no-cookie-jar +PUT {{adminApiHost}}/v2/curations/{{curationV2Id}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{recommendedWhiskySpecId}}, + "name": "수정된 비 오는 날 위스키", + "description": "request spec 검증을 통과하는 수정 요청", + "imageUrls": [ + "https://cdn.example.com/curation/rainy-whisky-cover.jpg", + "https://cdn.example.com/curation/rainy-whisky-second.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 2, + "isActive": true, + "payload": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "검증 위스키", + "selectedTags": ["셰리", "오크", "스모키"] + }, + "comment": "수정된 추천 코멘트" + } + ] +} + +### 7. 위스키 페어링 큐레이션 생성 +# @no-cookie-jar +POST {{adminApiHost}}/v2/curations +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{whiskyPairingSpecId}}, + "name": "디저트 페어링 위스키", + "description": "위스키와 어울리는 디저트 페어링", + "imageUrls": [ + "https://cdn.example.com/curation/pairing-cover.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 3, + "isActive": true, + "payload": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "글렌드로낙 오리지널 12년", + "selectedTags": ["셰리", "건포도", "오크"] + }, + "comment": "셰리 캐스크 입문용으로 추천", + "pairings": [ + { + "itemName": "부드러운 티라미수 초콜릿", + "itemImageUrl": "https://images.example.com/pairing/tiramisu.jpg", + "pairingNote": "진한 초콜릿 향이 위스키의 단맛과 이어진다." + } + ] + } + ] +} + +### 8. 위스키 시음회 큐레이션 생성 +# @no-cookie-jar +POST {{adminApiHost}}/v2/curations +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{whiskyTastingEventSpecId}}, + "name": "6월 싱글몰트 시음회", + "description": "소규모 위스키 테이스팅 이벤트", + "imageUrls": [ + "https://cdn.example.com/curation/tasting-event-cover.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 4, + "isActive": true, + "payload": { + "eventDate": "2026-06-15", + "eventTime": "19:30", + "barAddress": "서울 강남구 테헤란로 123", + "detailAddress": "2층 도시남 바", + "isRecruiting": true, + "entryFee": 0, + "capacity": 12, + "applicationLink": "https://forms.example.com/tasting", + "guideText": "시작 10분 전 입장해 주세요.", + "alcohols": [ + { + "source": "BOTTLE_NOTE", + "alcohol": { + "alcoholId": 1, + "korName": "검증 위스키", + "selectedTags": ["셰리", "오크"] + }, + "comment": "시음회 대표 위스키" + } + ] + } +} + +### 9. 검증 실패 예시 - request spec required 누락 +# @no-cookie-jar +POST {{adminApiHost}}/v2/curations +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{recommendedWhiskySpecId}}, + "name": "필수 필드 누락 테스트", + "description": "payload.source 누락으로 400 기대", + "imageUrls": [ + "https://cdn.example.com/curation/invalid-cover.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 99, + "isActive": true, + "payload": [ + { + "alcohol": { + "alcoholId": 1, + "korName": "검증 위스키", + "selectedTags": ["오크"] + }, + "comment": "source가 없어서 실패해야 한다." + } + ] +} + +### 10. 검증 실패 예시 - enum 값 불일치 +# @no-cookie-jar +POST {{adminApiHost}}/v2/curations +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "specId": {{recommendedWhiskySpecId}}, + "name": "enum 실패 테스트", + "description": "source enum 불일치로 400 기대", + "imageUrls": [ + "https://cdn.example.com/curation/invalid-cover.jpg" + ], + "exposureStartDate": "2026-06-01", + "exposureEndDate": "2026-06-30", + "displayOrder": 100, + "isActive": true, + "payload": [ + { + "source": "UNKNOWN_SOURCE", + "alcohol": { + "alcoholId": 1, + "korName": "검증 위스키", + "selectedTags": ["오크"] + }, + "comment": "source enum이 아니어서 실패해야 한다." + } + ] +} diff --git a/http/admin/http-client.env.json b/http/admin/http-client.env.json index d55a9180e..f2936f938 100644 --- a/http/admin/http-client.env.json +++ b/http/admin/http-client.env.json @@ -1,6 +1,7 @@ { "local": { "host": "http://localhost:8080/admin/api/v1", + "adminApiHost": "http://localhost:8080/admin/api", "email": "admin@bottlenote.com", "password": "password123", "accessToken": "", @@ -8,6 +9,7 @@ }, "dev": { "host": "https://admin-api.development.bottle-note.com/admin/api/v1", + "adminApiHost": "https://admin-api.development.bottle-note.com/admin/api", "email": "bottlenote.official@email.com", "password": "password1234", "accessToken": "", diff --git "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230v2.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230v2.http" new file mode 100644 index 000000000..86f82a0bf --- /dev/null +++ "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230v2.http" @@ -0,0 +1,39 @@ +### 토큰 발급 (일반) +# @no-cookie-jar +POST {{host}}/api/v1/oauth/login +Content-Type: application/json + +{ + "email": "dev.bottle-note@gmail.com", + "socialType": "GOOGLE", + "gender": "MALE", + "age": 25 +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); +%} + +### 1. spec 기반 큐레이션 v2 목록 조회 +# @no-cookie-jar +GET {{host}}/api/v2/curations +Authorization: Bearer {{accessToken}} + +> {% + const curations = response.body.data || []; + if (curations.length > 0) { + client.global.set("curationV2Id", curations[0].id); + } +%} + +### 2. spec 기반 큐레이션 v2 상세 조회 +# @no-cookie-jar +@curationId = {{curationV2Id}} +GET {{host}}/api/v2/curations/{{curationId}} +Authorization: Bearer {{accessToken}} + +### 3. spec 기반 큐레이션 v2 상세 조회 - 직접 ID 지정 +# @no-cookie-jar +@manualCurationId = 1 +GET {{host}}/api/v2/curations/{{manualCurationId}} +Authorization: Bearer {{accessToken}} diff --git a/plan/assets/spec-based-curation-v2-flow.png b/plan/assets/spec-based-curation-v2-flow.png new file mode 100644 index 000000000..3281d7edb Binary files /dev/null and b/plan/assets/spec-based-curation-v2-flow.png differ diff --git a/plan/complete/admin-api-v2-curation-endpoints.md b/plan/complete/admin-api-v2-curation-endpoints.md new file mode 100644 index 000000000..a8814b072 --- /dev/null +++ b/plan/complete/admin-api-v2-curation-endpoints.md @@ -0,0 +1,226 @@ +# Plan: Admin API V2 Curation Endpoints + +## Overview + +Admin API의 versioning 경계를 정리한다. 현재 admin-api는 `server.servlet.context-path=/admin/api/v1`에 version이 박혀 있어서 신규 spec 기반 큐레이션 endpoint가 `/admin/api/v1/spec-based-curations` 형태로 노출된다. 앞으로는 context-path를 `/admin/api`로 낮추고, 기존 Admin API는 controller mapping에서 `/v1`을 명시하며, 신규 spec 기반 큐레이션 API는 `/v2` surface로 노출한다. + +최종 목표는 기존 legacy Admin API를 `/admin/api/v1/**`로 유지하면서, 신규 spec 기반 큐레이션 관리는 `/admin/api/v2/curations`, `/admin/api/v2/curation-specs`로 고정하는 것이다. + +### Assumptions + +- admin-api 전체 context-path는 `/admin/api`로 변경한다. +- 기존 Admin v1 API는 깨지지 않도록 모든 기존 controller mapping에 `/v1` prefix를 명시한다. +- 기존 spec 기반 endpoint `/admin/api/v1/spec-based-curations`, `/admin/api/v1/curation-specs`는 호환 alias로 남기지 않는다. +- spec 기반 큐레이션 본문 관리는 `/admin/api/v2/curations`로 노출한다. +- 큐레이션 스펙 관리는 `/admin/api/v2/curation-specs`로 노출한다. +- Product API `/api/v2/curations`는 이번 변경 범위가 아니다. +- DB schema, GraphQL SDL, payload validation, Product materializer 로직은 이번 변경 범위가 아니다. + +### Success Criteria + +- `bottlenote-admin-api`의 `server.servlet.context-path`가 `/admin/api`로 변경된다. +- 기존 Admin v1 controller들은 최종 URL이 기존과 동일하게 `/admin/api/v1/**`로 유지된다. +- spec 기반 큐레이션 관리 API 최종 URL은 다음과 같다. + - `GET /admin/api/v2/curation-specs` + - `GET /admin/api/v2/curation-specs/{specId}` + - `GET /admin/api/v2/curations` + - `GET /admin/api/v2/curations/{curationId}` + - `POST /admin/api/v2/curations` + - `PUT /admin/api/v2/curations/{curationId}` +- `/admin/api/v1/spec-based-curations`와 `/admin/api/v1/curation-specs`는 더 이상 canonical endpoint가 아니다. +- Admin RestDocs snippets와 문서 경로/설명이 신규 `/v2` endpoint 기준으로 갱신된다. +- Admin integration test가 신규 `/v2` endpoint로 생성/조회/검증을 수행한다. +- `.example/display` 데모의 Admin API 호출 경로가 신규 `/admin/api/v2` endpoint 기준으로 갱신된다. +- `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin`가 성공한다. +- `./gradlew :bottlenote-admin-api:test --tests 'app.docs.curation.AdminSpecBasedCurationControllerDocsTest'`가 성공한다. +- `./gradlew admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'`가 성공한다. +- `./gradlew check_rule_test`가 성공한다. + +### Impact Scope + +- `bottlenote-admin-api` + - `src/main/resources/application.yml`: context-path 변경. + - `src/main/kotlin/app/bottlenote/**/presentation/*Controller.kt`: 기존 v1 surface 보존을 위한 `/v1` prefix 반영. + - `src/main/kotlin/app/bottlenote/curation/presentation/AdminCurationSpecController.kt`: `/v2/curation-specs`로 변경. + - `src/main/kotlin/app/bottlenote/curation/presentation/AdminSpecBasedCurationController.kt`: `/v2/curations`로 변경. + - `src/test/kotlin/app/docs/**`: RestDocs URI와 snippets 갱신 가능. + - `src/test/kotlin/app/integration/**`: context-path 변화에 따른 URI 갱신 가능. +- `.example/display` + - Admin API base path 또는 endpoint 조합이 `/admin/api/v2/curations`, `/admin/api/v2/curation-specs`를 사용하도록 갱신된다. +- `plan/spec-based-curation-v2-graphql-sdl.md` + - 기존 `/admin/api/v1/spec-based-curations`, `/admin/api/v1/curation-specs` 결정 내용을 취소선 + 정정으로 갱신한다. +- `bottlenote-mono` + - 서비스, 도메인, repository, GraphQL hydration 로직은 변경하지 않는다. +- Persistence + - schema migration 없음. +- Security + - Admin security 정책은 기존 `anyRequest().authenticated()` 구조를 유지한다. 단, `auth/login`, `auth/refresh` 최종 URL이 `/admin/api/v1/auth/*`로 유지되도록 mapping을 점검한다. +- API compatibility + - 기존 Admin v1 API는 유지한다. + - 기존 spec 기반 임시 endpoint는 호환 alias 없이 신규 `/v2`로 이동한다. + +### Open Questions + +- 없음. 사용자 확인 사항: + - context-path는 `/admin/api`로 낮춘다. + - 기존 spec 기반 v1 endpoint alias는 남기지 않는다. + - spec 관리는 `/v2/curation-specs`로 간다. + +## Dependency Analysis + +1. `context-path`를 `/admin/api`로 낮추면 기존 Admin controller mapping이 그대로일 경우 최종 URL이 `/admin/api/{path}`로 바뀌어 v1 API가 깨진다. +2. 기존 v1 API를 보존하려면 모든 legacy Admin controller에 `/v1` prefix가 적용되어야 한다. +3. 모든 controller 파일을 직접 수정하면 변경량이 커지므로, 중앙 Web MVC path prefix 설정으로 legacy Admin controller에 `/v1`을 적용하고 spec 기반 curation v2 controller만 제외하는 방향을 우선한다. +4. v2 curation controller는 명시적으로 `/v2/curation-specs`, `/v2/curations`를 갖는다. +5. Security permit matcher는 context-path를 제외한 servlet path 기준으로 동작하므로 `/v1/auth/login`, `/v1/auth/refresh` 기준으로 갱신한다. +6. MockMvc 기반 docs/integration test는 context-path가 아니라 controller mapping을 직접 호출하므로 기존 Admin v1 테스트 URI도 `/v1/**`로 갱신해야 한다. + +## Tasks + +### Task 1: Admin API versioning foundation + +- Acceptance: + - admin-api `server.servlet.context-path`가 `/admin/api`로 변경된다. + - legacy Admin presentation controller에는 중앙 설정으로 `/v1` prefix가 적용된다. + - spec 기반 curation v2 controller는 `/v1` prefix 대상에서 제외된다. + - Admin security permit matcher가 `/v1/auth/login`, `/v1/auth/refresh`를 허용한다. +- Verification: + - `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` +- Files: + - `bottlenote-admin-api/src/main/resources/application.yml` + - `bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt` + - `bottlenote-admin-api/src/main/kotlin/app/global/config/*` +- Size: S +- Status: [x] done + +### Task 2: Curation v2 admin endpoint remapping + +- Acceptance: + - `AdminCurationSpecController`가 `/v2/curation-specs`를 제공한다. + - `AdminSpecBasedCurationController`가 `/v2/curations`를 제공한다. + - `/v1/curation-specs`, `/v1/spec-based-curations` alias는 만들지 않는다. +- Verification: + - `./gradlew :bottlenote-admin-api:test --tests 'app.docs.curation.AdminSpecBasedCurationControllerDocsTest'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'` +- Files: + - `bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminCurationSpecController.kt` + - `bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/presentation/AdminSpecBasedCurationController.kt` + - `bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt` + - `bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt` +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-2 + +- [ ] Admin Kotlin compile/test compile passes +- [ ] Curation v2 docs test passes +- [ ] Curation v2 admin integration test passes + +### Task 3: Preserve core Admin v1 auth/common/user/help routes in tests + +- Acceptance: + - Auth docs/integration tests call `/v1/auth/**`. + - File upload tests call `/v1/s3/**`. + - User and help tests call `/v1/users`, `/v1/helps/**`. + - Existing final URLs remain `/admin/api/v1/**`. +- Verification: + - `./gradlew :bottlenote-admin-api:test --tests 'app.docs.auth.AuthControllerDocsTest' --tests 'app.docs.file.AdminImageUploadControllerDocsTest' --tests 'app.docs.user.AdminUsersControllerDocsTest' --tests 'app.docs.help.AdminHelpControllerDocsTest'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.auth.AdminAuthIntegrationTest' --tests 'app.integration.file.AdminImageUploadIntegrationTest' --tests 'app.integration.user.AdminUsersIntegrationTest' --tests 'app.integration.help.AdminHelpIntegrationTest'` +- Files: + - `bottlenote-admin-api/src/test/kotlin/app/docs/auth/*` + - `bottlenote-admin-api/src/test/kotlin/app/docs/file/*` + - `bottlenote-admin-api/src/test/kotlin/app/docs/user/*` + - `bottlenote-admin-api/src/test/kotlin/app/docs/help/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/auth/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/file/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/user/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/help/*` +- Size: M +- Status: [x] done + +### Task 4: Preserve Admin v1 alcohol/reference routes in tests + +- Acceptance: + - Alcohol, distillery, region, tasting-tag docs/integration tests call `/v1/**`. + - Reference data endpoints remain under `/admin/api/v1`. +- Verification: + - `./gradlew :bottlenote-admin-api:test --tests 'app.docs.alcohols.*'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.alcohols.*' --tests 'app.integration.region.*'` +- Files: + - `bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/region/*` +- Size: M +- Status: [x] done + +### Task 5: Preserve Admin v1 banner and legacy curation routes in tests + +- Acceptance: + - Banner docs/integration tests call `/v1/banners/**`. + - Legacy Admin curation docs/integration tests call `/v1/curations/**`. + - Spec 기반 curation v2와 legacy curation v1이 `/admin/api/v2/curations` vs `/admin/api/v1/curations`로 명확히 분리된다. +- Verification: + - `./gradlew :bottlenote-admin-api:test --tests 'app.docs.banner.AdminBannerControllerDocsTest' --tests 'app.docs.curation.AdminCurationControllerDocsTest'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.banner.AdminBannerIntegrationTest' --tests 'app.integration.curation.AdminCurationIntegrationTest'` +- Files: + - `bottlenote-admin-api/src/test/kotlin/app/docs/banner/*` + - `bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt` + - `bottlenote-admin-api/src/test/kotlin/app/integration/banner/*` + - `bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt` +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 3-5 + +- [x] Existing Admin v1 docs tests pass +- [x] Existing Admin v1 integration tests pass +- [x] Legacy `/admin/api/v1/curations` and new `/admin/api/v2/curations` are both represented in tests + +### Task 6: Demo and planning documents update + +- Acceptance: + - `.example/display` Admin base URL and endpoint calls use `/admin/api` + `/v1` or `/v2` consistently. + - Spec 기반 curation demo calls `/v2/curation-specs` and `/v2/curations`. + - Architecture/demo text no longer points to `/admin/api/v1/spec-based-curations`. + - `plan/spec-based-curation-v2-graphql-sdl.md` records the endpoint decision change with 취소선 + 정정. +- Verification: + - `curl -s http://localhost:8098/curation-architecture.html` + - `rg -n "spec-based-curations|/admin/api/v1/curation-specs" .example/display plan/spec-based-curation-v2-graphql-sdl.md` returns no stale canonical references except historical struck-through notes. +- Files: + - `.example/display/js/config.js` + - `.example/display/js/api.js` + - `.example/display/js/*.js` + - `plan/spec-based-curation-v2-graphql-sdl.md` + - `plan/admin-api-v2-curation-endpoints.md` +- Size: M +- Status: [x] done + +### Task 7: Final verification and commit + +- Acceptance: + - Admin API versioning change is committed as one coherent commit after verification. + - No unintended `bottlenote-mono`, Product API, DB changelog, or GraphQL SDL changes are included. + - Git diff shows only Admin API endpoint/versioning docs and demo path updates. +- Verification: + - `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` + - `./gradlew :bottlenote-admin-api:test` + - `./gradlew admin_integration_test` + - `./gradlew check_rule_test` + - `git diff --check` +- Files: + - `bottlenote-admin-api/**` + - `.example/display/**` + - `plan/admin-api-v2-curation-endpoints.md` + - `plan/spec-based-curation-v2-graphql-sdl.md` +- Size: S +- Status: [x] done + +## Progress Log + +- 2026-05-18: Task 1 완료. admin-api context-path를 `/admin/api`로 낮추고, `AdminApiVersionConfig`에서 legacy Admin presentation controller에 중앙 `/v1` prefix를 적용했다. spec 기반 curation controller 2개는 prefix 대상에서 제외했고, Security permit matcher를 `/v1/auth/login`, `/v1/auth/refresh`로 갱신했다. 검증: `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` 성공. +- 2026-05-18: Task 2 완료. spec 기반 admin curation controller를 `/v2/curations`, spec 조회 controller를 `/v2/curation-specs`로 변경하고 RestDocs/integration 테스트 URI와 snippet 경로를 v2 기준으로 갱신했다. 검증: `./gradlew :bottlenote-admin-api:test --tests 'app.docs.curation.AdminSpecBasedCurationControllerDocsTest'` 성공, `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'` 성공. root aggregate `admin_integration_test --tests ...`는 `--tests` 옵션을 받지 않아 모듈 Test 태스크 명령으로 정정했다. +- 2026-05-18: Task 3 완료. auth, file, user, help docs/integration 테스트 요청 URI를 `/v1/**` 기준으로 갱신해 기존 Admin v1 최종 URL 보존을 검증했다. 검증: `./gradlew :bottlenote-admin-api:test --tests 'app.docs.auth.AuthControllerDocsTest' --tests 'app.docs.file.AdminImageUploadControllerDocsTest' --tests 'app.docs.user.AdminUsersControllerDocsTest' --tests 'app.docs.help.AdminHelpControllerDocsTest'` 성공, `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.auth.AdminAuthIntegrationTest' --tests 'app.integration.file.AdminImageUploadIntegrationTest' --tests 'app.integration.user.AdminUsersIntegrationTest' --tests 'app.integration.help.AdminHelpIntegrationTest'` 성공. +- 2026-05-18: Task 4 완료. alcohol, distillery, region, tasting-tag docs/integration 테스트 요청 URI를 `/v1/**` 기준으로 갱신해 reference/admin alcohol API의 기존 v1 surface를 보존했다. 검증: `./gradlew :bottlenote-admin-api:test --tests 'app.docs.alcohols.*'` 성공, `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.alcohols.*' --tests 'app.integration.region.*'` 성공. +- 2026-05-18: Task 5 완료. banner docs/integration 테스트를 `/v1/banners/**`, legacy curation docs/integration 테스트를 `/v1/curations/**` 기준으로 갱신했다. spec 기반 신규 curation 테스트는 `/v2/curations`로 유지되어 legacy와 v2 surface가 분리된다. 검증: `./gradlew :bottlenote-admin-api:test --tests 'app.docs.banner.AdminBannerControllerDocsTest' --tests 'app.docs.curation.AdminCurationControllerDocsTest'` 성공, `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.banner.AdminBannerIntegrationTest' --tests 'app.integration.curation.AdminCurationIntegrationTest'` 성공. +- 2026-05-18: Task 6 완료. `.example/display`의 admin base URL을 `/admin/api`로 낮추고, legacy 호출은 `/v1`, spec 기반 신규 호출은 `/v2/curation-specs`, `/v2/curations`로 분리했다. `plan/spec-based-curation-v2-graphql-sdl.md`에는 기존 `/admin/api/v1/curation-specs`, `/admin/api/v1/spec-based-curations` 결정을 취소선 + 정정으로 남겼다. 검증: `curl -s http://localhost:8098/curation-architecture.html` 성공, `rg -n "spec-based-curations|/admin/api/v1/curation-specs|/admin/api/v1/spec-based-curations" .example/display plan/spec-based-curation-v2-graphql-sdl.md` 결과는 plan 문서의 취소선/정정 이력 3건만 남고 `.example/display`에는 stale canonical reference가 없음을 확인했다. +- 2026-05-18: Task 7 완료. 최종 검증으로 admin compile, admin 전체 RestDocs/test, 전체 admin integration, rule test, diff whitespace check를 수행했고 모두 성공했다. 검증: `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` 성공, `./gradlew :bottlenote-admin-api:test` 성공, `./gradlew admin_integration_test` 성공, `./gradlew check_rule_test` 성공, `git diff --check` 성공. diff --git a/plan/complete/admin-review-list.md b/plan/complete/admin-review-list.md new file mode 100644 index 000000000..8e7b98564 --- /dev/null +++ b/plan/complete/admin-review-list.md @@ -0,0 +1,171 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-05-14 + +** Core Achievements ** +- 어드민 리뷰 목록 조회 GET API 추가 (bottlenote-admin-api): `GET /admin/api/v1/reviews` — 필터 7종(alcoholId / userId / activeStatus / displayStatus / keyword / createdFrom / createdTo) + 정렬 3종(CREATED_AT / REPLY_COUNT / UPDATED_AT) + 전체 상태(ACTIVE/DELETED/DISABLED, PUBLIC/PRIVATE) 노출 +- Task 4개 전부 커밋: 50caf5ae(기본 조회) / 669a038d(필터·정렬) / d4137a56(통합 테스트) / a0428009(RestDocs) +- /verify full L3 8단계 전부 PASS (compile / lint / arch / unit / build / integration / admin-integration / asciidoctor, 11m 28s) +- DB 스키마 변경 0건, 기존 product 리뷰 조회 정책 무변 + +** Key Components ** +- AdminReviewController.kt (bottlenote-admin-api, presentation) — GET /reviews 엔드포인트 +- AdminReviewQueryService (bottlenote-mono) — 어드민 전용 read-only 조회 서비스 +- CustomReviewRepository / CustomReviewRepositoryImpl + ReviewQuerySupporter — QueryDSL 동적 필터·정렬 +- AdminReviewSearchRequest / AdminReviewListResponse / AdminReviewSortType +- AdminReviewIntegrationTest (@Tag("admin_integration")) + AdminReviewControllerDocsTest (RestDocs) +- InMemoryReviewRepository 2곳(mono / product) 갱신 — domain interface 변경 동기화 + +** Deferred Items ** +- [Important] AdminReviewIntegrationTest 의 keyword 검색 커버리지에 한글 주류명(alcohol.korName) 케이스 누락 — 구현은 포함돼 있으나 회귀 방지 테스트 부재. /test 로 보강 권장 +- [Nit] AdminReviewControllerDocsTest 의 PageImpl 예시를 PageRequest 명시 형태로 정리하면 문서 일관성 향상 + +** GSL 검증 메모 ** +- 본 작업은 GSL 강화본(B1'~B5)이 codex 런타임에서 게이트를 지키는지 실전 검증을 겸함 +- /define → /plan → /implement Task 1~4 → /verify full → /self-review → /next-flow 전 단계에서 codex 가 "Runtime Boundary — HARD STOP" 을 준수, 모호한 "진행하자" 에도 Task 단위로만 진행 (news-admin 구버전 폭주 대비 단계별 정지 확인) +================================================================================ +``` + +# Plan: 어드민 리뷰 목록 조회 API + +## Overview + +어드민이 리뷰 운영 상태를 한 화면에서 확인할 수 있도록 `bottlenote-admin-api`에 리뷰 목록 조회 `GET` API를 추가한다. API 표면은 Kotlin `presentation` 패키지에 두고, 조회 조건 조합과 목록 응답 생성은 `bottlenote-mono`의 어드민 전용 리뷰 조회 서비스와 QueryDSL 기반 repository 확장으로 처리한다. + +범위는 목록 조회만 포함한다. 단건 상세 조회, 리뷰 상태 변경, DB 스키마 변경, 기존 product 리뷰 조회 정책 변경은 포함하지 않는다. + +### Assumptions + +- endpoint는 admin context path `/admin/api/v1` 아래 `GET /reviews`로 둔다. +- 컨트롤러는 `bottlenote-admin-api/src/main/kotlin/app/bottlenote/review/presentation/AdminReviewController.kt`에 신설한다. +- 요청 DTO, 응답 DTO, sort enum, 어드민 전용 조회 서비스는 `bottlenote-mono`의 `app.bottlenote.review` 하위에 둔다. +- 어드민 목록 조회는 인증된 admin API surface로 동작하며, 현재 `bottlenote-admin-api`의 SecurityConfig 정책을 그대로 따른다. +- 페이징은 기존 어드민 검색 API와 맞춰 `page`, `size` 기반 offset pagination과 `GlobalResponse.fromPage(Page)` 응답을 사용한다. +- 필터는 `alcoholId`, `userId`, `activeStatus`, `displayStatus`, `keyword`, `createdFrom`, `createdTo`를 지원한다. +- `keyword`는 리뷰 본문과 작성자 식별 정보 중 기존 join으로 안정적으로 제공 가능한 값에 적용한다. 정확한 대상 컬럼은 `/plan`에서 기존 user/alcohol join 비용을 보고 확정한다. +- 정렬은 `CREATED_AT`, `REPLY_COUNT`, `UPDATED_AT` 3종과 `sortOrder`를 지원하며 기본값은 `CREATED_AT DESC`다. +- 모든 상태 노출 요구사항에 따라 기본 조회는 `ReviewActiveStatus.ACTIVE/DELETED/DISABLED`와 `ReviewDisplayStatus.PUBLIC/PRIVATE`를 모두 포함하고, 상태 파라미터가 있을 때만 좁힌다. +- 응답 항목에는 운영 목록에서 식별과 판단에 필요한 최소 필드인 `reviewId`, `alcoholId`, `alcoholName`, `userId`, `userNickname`, `content`, `reviewRating`, `activeStatus`, `displayStatus`, `replyCount`, `createAt`, `lastModifyAt`를 포함한다. +- 댓글 수 정렬은 기존 `ReviewReply` 데이터를 집계해서 산출하며, 별도 counter column이나 schema migration은 추가하지 않는다. + +### Success Criteria + +- `GET /admin/api/v1/reviews`가 `GlobalResponse` 형식으로 리뷰 목록과 page meta를 반환한다. +- 필터 7종 중 각각을 단독 적용했을 때 결과가 해당 조건으로 좁혀진다: `alcoholId`, `userId`, `activeStatus`, `displayStatus`, `keyword`, `createdFrom`, `createdTo`. +- `activeStatus` 미지정 시 `ACTIVE`, `DELETED`, `DISABLED` 리뷰가 모두 조회 대상에 포함된다. +- `displayStatus` 미지정 시 `PUBLIC`, `PRIVATE` 리뷰가 모두 조회 대상에 포함된다. +- 정렬 `CREATED_AT`, `REPLY_COUNT`, `UPDATED_AT`가 각각 `ASC`/`DESC` 방향으로 동작한다. +- 단건 상세 API나 상태 변경 API가 추가되지 않는다. +- DB migration 파일이 추가되지 않는다. +- 기존 product 리뷰 목록/상세 조회는 계속 `ACTIVE`와 공개 정책을 유지한다. +- admin controller docs 또는 admin integration 테스트에서 필터, 전체 상태 노출, 정렬, page meta를 검증한다. +- `./gradlew :bottlenote-admin-api:test` 또는 관련 admin integration/docs 테스트 태스크로 변경 범위를 검증할 수 있다. + +### Impact Scope + +- `bottlenote-admin-api` + - 새 admin review presentation controller 추가 + - admin docs/integration 테스트 추가 가능 +- `bottlenote-mono` + - admin review search request/response DTO 추가 + - admin review sort enum 추가 + - admin 전용 read-only service 추가 + - `ReviewRepository` 또는 `CustomReviewRepository` 계열에 admin 목록 조회 query 추가 + - `Review`, `ReviewReply`, `User`, `Alcohol` join 및 댓글 수 집계 사용 +- Persistence + - DB 스키마 변경 없음 + - 기존 `reviews.active_status`, `reviews.status`, `reviews.create_at`, `reviews.last_modify_at` 필드 사용 +- Async / Events + - 새 이벤트 발행 또는 소비 없음 +- Cache + - 신규 캐시와 invalidation 정책 없음 +- API Contract / Docs + - admin API 계약 추가 + - product API 계약 변경 없음 +- Tests + - QueryDSL 조건 조합 검증 + - admin controller 문서 또는 통합 테스트 + - 상태 미지정 시 전체 상태 노출 회귀 방지 + +## Tasks + +의존성 순서는 `bottlenote-mono` 조회 경로를 먼저 만들고, `bottlenote-admin-api` HTTP surface를 같은 기본 목록 slice에 연결한 뒤, 필터/정렬과 검증 범위를 확장한다. 모든 Task는 L 사이즈(8개 이상 파일)를 피하고, 한 번에 커밋 가능한 수직 slice로 유지한다. + +### Task 1: 기본 어드민 리뷰 목록 조회 slice +- Acceptance: `GET /admin/api/v1/reviews`가 인증된 admin 요청에서 기본 `page=0`, `size=20`, `CREATED_AT DESC` 목록을 `GlobalResponse.fromPage(Page)` 형태로 반환한다. +- Acceptance: 응답 항목에 `reviewId`, `alcoholId`, `alcoholName`, `userId`, `userNickname`, `content`, `reviewRating`, `activeStatus`, `displayStatus`, `replyCount`, `createAt`, `lastModifyAt`가 포함된다. +- Acceptance: 상태 파라미터가 없을 때 `ACTIVE`, `DELETED`, `DISABLED` 및 `PUBLIC`, `PRIVATE` 리뷰가 조회 대상에서 제외되지 않는다. +- Verification: `./gradlew :bottlenote-mono:compileJava :bottlenote-admin-api:compileKotlin` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/review/dto/request/AdminReviewSearchRequest.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/dto/response/AdminReviewListResponse.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/service/AdminReviewQueryService.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepository.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java`, `bottlenote-admin-api/src/main/kotlin/app/bottlenote/review/presentation/AdminReviewController.kt` +- Size: M +- Status: [x] done + +### Task 2: 필터와 정렬 query slice +- Acceptance: `alcoholId`, `userId`, `activeStatus`, `displayStatus`, `keyword`, `createdFrom`, `createdTo`가 단독 적용될 때 QueryDSL where 조건으로 결과를 좁힌다. +- Acceptance: `keyword`는 리뷰 본문, 작성자 닉네임, 작성자 이메일, 주류 한글명, 주류 영문명에 적용한다. +- Acceptance: `CREATED_AT`, `REPLY_COUNT`, `UPDATED_AT` 정렬이 `ASC`/`DESC`로 동작하고 동일 정렬값에서는 최신 리뷰가 보조 정렬된다. +- Verification: `./gradlew :bottlenote-mono:compileJava :bottlenote-admin-api:compileKotlin` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/review/constant/AdminReviewSortType.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/dto/request/AdminReviewSearchRequest.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java`, `bottlenote-mono/src/main/java/app/bottlenote/review/repository/ReviewQuerySupporter.java` +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-2 +- [x] `bottlenote-mono` Java compile passes +- [x] `bottlenote-admin-api` Kotlin compile passes +- [x] No DB migration file is added +- [x] Product review query policy remains unchanged + +### Task 3: 어드민 리뷰 목록 통합 테스트 slice +- Acceptance: admin integration 테스트가 기본 목록, 전체 active/display 상태 노출, 7종 필터 단독 적용을 검증한다. +- Acceptance: admin integration 테스트가 `CREATED_AT`, `REPLY_COUNT`, `UPDATED_AT` 정렬과 `ASC`/`DESC` 방향, page meta를 검증한다. +- Acceptance: 테스트 데이터는 기존 Testcontainers 기반 factory를 사용하고 Mock 기반 repository 대체를 추가하지 않는다. +- Verification: `./gradlew admin_integration_test --tests '*AdminReviewIntegrationTest'` +- Files: `bottlenote-admin-api/src/test/kotlin/app/integration/review/AdminReviewIntegrationTest.kt`, `bottlenote-mono/src/test/java/app/bottlenote/review/fixture/ReviewTestFactory.java` +- Size: S +- Status: [x] done + +### Task 4: 어드민 리뷰 목록 RestDocs 계약 slice +- Acceptance: RestDocs 테스트가 `GET /reviews` query parameters와 응답 필드, page meta를 문서화한다. +- Acceptance: 문서에는 목록 조회만 포함하고 단건 상세 조회, 상태 변경 API, DB migration 내용은 추가하지 않는다. +- Acceptance: admin default test에서 새 DocsTest가 함께 통과한다. +- Verification: `./gradlew :bottlenote-admin-api:test --tests '*AdminReviewControllerDocsTest'` +- Files: `bottlenote-admin-api/src/test/kotlin/app/docs/review/AdminReviewControllerDocsTest.kt`, `bottlenote-admin-api/src/docs/asciidoc/admin-review.adoc` +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 3-4 +- [ ] `./gradlew admin_integration_test --tests '*AdminReviewIntegrationTest'` passes +- [x] `./gradlew :bottlenote-admin-api:test --tests '*AdminReviewControllerDocsTest'` passes +- [x] `./gradlew :bottlenote-admin-api:test` passes or any unrelated failure is recorded with evidence +- [x] API contract still exposes only `GET /admin/api/v1/reviews` + +## Progress Log + +### 2026-05-14 — Task 1 +- Implemented admin review basic list slice for `GET /admin/api/v1/reviews`. +- Added mono request/response DTOs, read-only admin query service, `ReviewRepository.searchAdminReviews`, QueryDSL projection, and admin Kotlin controller. +- Updated `InMemoryReviewRepository` implementations in mono/product test fixtures because the domain repository interface changed. +- Verification: `./gradlew :bottlenote-mono:compileJava :bottlenote-admin-api:compileKotlin` passed. + +### 2026-05-14 — Task 2 +- Added admin review sort type and request defaults for `CREATED_AT DESC`. +- Added QueryDSL filters for `alcoholId`, `userId`, `activeStatus`, `displayStatus`, `keyword`, `createdFrom`, and `createdTo`. +- Added admin review ordering for `CREATED_AT`, `REPLY_COUNT`, and `UPDATED_AT` with latest-review tie breakers. +- Verification: `./gradlew :bottlenote-mono:compileJava :bottlenote-admin-api:compileKotlin` passed. + +### 2026-05-14 — Task 3 +- Added `AdminReviewIntegrationTest` with `@Tag("admin_integration")` for default list response fields, all active/display state exposure, 7 standalone filters, keyword targets, 3 sort types with both directions, and page meta. +- Extended `ReviewTestFactory` with a Testcontainers-backed admin review fixture method that persists real `Review` rows and fixes `create_at` / `last_modify_at` via the test database. +- Verification note: root aggregate command `./gradlew admin_integration_test --tests '*AdminReviewIntegrationTest'` failed because the aggregate task does not accept `--tests`. +- Verification: `git submodule update --init --recursive` completed for missing `git.environment-variables`; `./gradlew :bottlenote-admin-api:admin_integration_test --tests '*AdminReviewIntegrationTest'` passed. + +### 2026-05-14 — Task 4 +- Added `AdminReviewControllerDocsTest` for `GET /admin/api/v1/reviews` query parameters, response fields, and page meta. +- Added `admin-review.adoc` and linked it from `admin-api.adoc`; the document includes only the admin review list API. +- Verification: `./gradlew :bottlenote-admin-api:test --tests '*AdminReviewControllerDocsTest'` passed. +- Verification: `./gradlew :bottlenote-admin-api:test` passed, confirming the new admin DocsTest runs in the default admin test task. +- Verification: `./gradlew :bottlenote-admin-api:asciidoctor` passed. +- Contract check: `rg -n "@(?:Get|Post|Put|Patch|Delete)Mapping|/reviews|admin/reviews|Review API|admin-review" ...` found only `@GetMapping` on `/reviews`, the list DocsTest, and the list document include. diff --git a/plan/complete/curation-v2-display-demo-runbook.md b/plan/complete/curation-v2-display-demo-runbook.md new file mode 100644 index 000000000..9b235c89e --- /dev/null +++ b/plan/complete/curation-v2-display-demo-runbook.md @@ -0,0 +1,121 @@ +# Curation V2 Display Demo Runbook + +## Purpose + +이 문서는 ignored workspace인 `.example/display`에 구성한 Bottlenote curation v2 정적 데모 실행 방법과 검증 결과를 기록한다. 데모 파일은 local/development 전용 credential을 포함할 수 있으므로 git에 추적하지 않는다. + +## Workspace + +- Demo root: `.example/display` +- Entry page: `.example/display/index.html` +- Environment config: `.example/display/js/config.js` +- API adapter: `.example/display/js/api.js` +- Git tracking policy: `.example/` is ignored by `.gitignore` + +## Environment Switching + +`js/config.js`의 `displayConfig.active` 값으로 대상 환경을 바꾼다. + +```js +export const displayConfig = { + active: 'development', + environments: { + local: { /* local admin/product base URL */ }, + development: { /* development admin/product base URL */ }, + }, +}; +``` + +화면에서 환경 선택 UI는 제공하지 않는다. 데모를 새 환경으로 확인하려면 config 파일을 수정한 뒤 브라우저를 새로고침한다. + +## Authentication + +- Admin API 요청은 `api.login()`이 먼저 `/admin/api/v1/auth/login`을 호출한다. +- access token은 `sessionStorage`에 저장한다. +- token은 파일에 쓰지 않는다. +- 401 응답을 받으면 저장된 token을 지우고 한 번 재로그인한다. + +## Current API Contract + +현재 코드베이스 기준 데모가 호출해야 하는 canonical endpoint는 다음과 같다. + +- Admin login: `/admin/api/v1/auth/login` +- Admin specs: `/admin/api/v2/curation-specs`, `/admin/api/v2/curation-specs/{specId}` +- Admin spec-based curation: `/admin/api/v2/curations`, `/admin/api/v2/curations/{curationId}` +- Product curation v2: `/api/v2/curations`, `/api/v2/curations/{curationId}` + +기존 `/admin/api/v1/curation-specs`, `/admin/api/v1/spec-based-curations`는 2026-05-18 Admin API versioning 변경 이후 canonical endpoint가 아니며 alias도 제공하지 않는다. + +## Local Static Server + +```bash +python3 -m http.server 8097 --directory .example/display +``` + +확인 URL: + +- `http://127.0.0.1:8097/index.html` +- `http://127.0.0.1:8097/specs.html` +- `http://127.0.0.1:8097/curations.html` +- `http://127.0.0.1:8097/curation-new.html` +- `http://127.0.0.1:8097/curation-detail.html?id=2` + +## Development Smoke Result + +Executed at: 2026-05-17 + +이 결과는 2026-05-18 Admin API v2 path remap 이전 실행 기록이다. 현재 코드베이스로 재검증할 때는 위 Current API Contract의 `/admin/api/v2/**` 경로를 사용한다. + +- Static pages: + - `index.html`: HTTP 200 + - `specs.html`: HTTP 200 + - `curations.html`: HTTP 200 + - `curation-detail.html?id=2`: HTTP 200 +- Admin login: + - `/admin/api/v1/auth/login`: HTTP 200 + - access token present: yes +- Admin specs: + - historical path: `/admin/api/v1/curation-specs` + - current path: `/admin/api/v2/curation-specs` + - historical result: HTTP 200 + - count: 3 + - first spec keys: `id`, `code`, `name`, `description`, `hydratorKey`, `version`, `isActive`, `requestSpec`, `responseSpec` +- Admin curation list before demo create: + - historical path: `/admin/api/v1/spec-based-curations?keyword=&isActive=&page=0&size=50` + - current path: `/admin/api/v2/curations?keyword=&isActive=&page=0&size=50` + - historical result: HTTP 200 + - count: 0 +- Product curation list before demo create: + - `/api/v2/curations`: HTTP 200 + - count: 0 +- Invalid create smoke: + - historical path: `/admin/api/v1/spec-based-curations` + - current path: `/admin/api/v2/curations` + - historical result: HTTP 400 + - error code: `CURATION_PAYLOAD_REQUIRED` +- Valid create smoke: + - created name: `CODEX_DISPLAY_SMOKE_20260517` + - targetId: 2 +- Admin detail: + - historical path: `/admin/api/v1/spec-based-curations/2` + - current path: `/admin/api/v2/curations/2` + - historical result: HTTP 200 + - spec: `RECOMMENDED_WHISKY` + - payload type: array + - payload count: 1 +- Product detail: + - `/api/v2/curations/2`: HTTP 200 + - spec: `RECOMMENDED_WHISKY` + - payload type: array + - payload count: 1 + - first payload `stats`: `null` because the smoke item uses `source=MANUAL` + +## Git Tracking Check + +Expected `git status --short --ignored .example`: + +```text +!! .example/ +``` + +Only `.gitignore`, this plan document, and this runbook should be tracked for the demo workflow. `.example/display/**` must remain ignored. diff --git a/plan/complete/curation-v2-display-demo.md b/plan/complete/curation-v2-display-demo.md new file mode 100644 index 000000000..85902b7ce --- /dev/null +++ b/plan/complete/curation-v2-display-demo.md @@ -0,0 +1,154 @@ +# Plan: Curation V2 Display Demo + +## Overview + +Bottlenote repository 안에 curation demo 프로젝트의 `display` 정적 데모 화면을 이식한다. 목적은 spec 기반 curation v2의 Admin/Product API 계약이 실제 화면 흐름에서 올바르게 동작하는지 확인하는 것이다. + +데모는 애플리케이션 빌드/배포 산출물에 포함하지 않고, `.example/display` 아래에 둔다. 원본 curation demo의 화면 구조와 UX는 가능한 유지하되, 더 나은 표현이 필요한 부분은 현재 Bottlenote curation v2 API 계약에 맞춰 조정한다. API adapter는 데모 서버 계약이 아니라 현재 Bottlenote 프로젝트의 Admin/Product endpoint와 응답 형식을 기준으로 작성한다. + +## Assumptions + +- `.exmale/display`는 오타이며 최종 대상 경로는 `.example/display`이다. +- 구현 시작 시 Bottlenote repository는 `main` 기준 최신으로 전환하고 `git pull`, submodule update를 먼저 수행한다. +- `git.environment-variables` submodule도 최신 main revision으로 맞춘 뒤 작업한다. +- 원본 curation demo의 디자인과 화면 흐름은 가능한 유지한다. 다만 Bottlenote 응답 구조를 더 명확하게 보여주기 위한 UI 조정은 허용한다. +- 환경 전환은 화면 선택 UI를 만들지 않고 config 파일 하나의 active 값으로 제어한다. +- 지원 환경은 `local`, `development` 두 개다. +- 각 환경 config에는 product/admin base URL, admin login credential, active flag를 둔다. +- admin token은 데모 페이지가 자동 로그인으로 로딩해 보관하고 이후 admin API 요청에 자동으로 붙인다. +- local/development 서버 기동 여부는 데모가 책임지지 않는다. 데모는 API 호출 실패를 화면에 명확히 표시한다. +- 데모는 정적 파일이며 Spring Boot runtime, Gradle build artifact, API 문서 산출물에는 포함하지 않는다. +- 현재 `.gitignore`에는 `.example/`가 없으므로, 구현 단계에서 `.example/`를 ignore 대상으로 추가해야 한다. + +## Success Criteria + +- `.example/display` 아래에서 정적 데모 페이지가 열리고 원본 display의 주요 화면을 제공한다. +- config 파일에서 `active` 환경을 `local` 또는 `development`로 바꾸면 같은 화면이 해당 API base URL을 사용한다. +- development 환경에서 admin login이 자동 수행되고 access token이 이후 Admin API 요청의 `Authorization: Bearer` 헤더에 자동 반영된다. +- 스펙 목록 화면에서 `GET /admin/api/v2/curation-specs` 응답의 3개 spec이 표시된다. +- 스펙 상세 또는 카드 확장 영역에서 `requestSpec`, `responseSpec`, `hydratorKey`, `version`, `isActive`가 확인 가능하다. +- 큐레이션 목록 화면에서 Admin `GET /admin/api/v2/curations`와 Product `GET /api/v2/curations`의 결과 차이를 구분해 확인할 수 있다. +- 큐레이션 생성 화면은 현재 Bottlenote Admin create contract에 맞는 payload를 생성한다. +- 생성 요청 실패 시 HTTP status, 서버 error code/message, validation 실패 위치를 화면에 표시한다. +- 생성 성공 후 상세 화면에서 Admin detail payload와 Product detail materialized payload를 비교해 볼 수 있다. +- Product 응답 payload가 `responseSpec`의 의도와 맞는지 사람이 확인할 수 있도록 raw JSON과 모바일 미리보기를 함께 제공한다. +- local 서버가 꺼져 있거나 CORS/auth/API 오류가 발생하면 빈 화면이 아니라 원인 진단 메시지를 표시한다. +- `.example/` 경로는 git에 추적되지 않도록 ignore 처리된다. 단, 필요하면 별도 문서에 실행 방법만 추적한다. + +## Impact Scope + +- Repository state: + - 작업 시작 전 `main` 최신화와 submodule 최신화가 필요하다. + - 기존 `feat/curation` 브랜치 상태에서 바로 구현하지 않는다. + +- Static demo files: + - 새 경로: `.example/display/**` + - 원본 참조: `/Users/hgkim/workspace/etc/curation_demo/curation_demo/display/**` + - 주요 구성: HTML, CSS, JS modules, widgets, environment config, API adapter + +- API surfaces: + - Admin login: `/admin/api/v1/auth/login` + - Admin specs: `/admin/api/v2/curation-specs`, `/admin/api/v2/curation-specs/{specId}` + - Admin spec-based curation: `/admin/api/v2/curations`, `/admin/api/v2/curations/{curationId}` + - Product curation v2: `/api/v2/curations`, `/api/v2/curations/{curationId}` + +- Security: + - credential은 static config에 들어가므로 이 데모는 local/development 전용이다. + - production credential이나 production base URL은 포함하지 않는다. + - token은 브라우저 메모리 또는 sessionStorage 수준으로만 보관하고 파일에 쓰지 않는다. + +- Persistence/schema: + - DB schema 변경 없음. + - seed/migration 변경 없음. + - 데모를 통한 생성 요청은 development/local DB에 실제 데이터를 만들 수 있으므로 화면에서 대상 환경을 명확히 표시해야 한다. + +- Build/deploy: + - Gradle module 변경 없음. + - Spring Boot app packaging 변경 없음. + - 배포 workflow 변경 없음. + +- Tests/verification: + - 정적 파일 문법 검증 또는 간단한 smoke 실행이 필요하다. + - 가능하면 local static server로 페이지를 열고 development API 대상 스펙 목록 조회, 로그인, curation 목록 조회를 확인한다. + - 브라우저 기반 전체 검증은 필요 시 별도 단계로 수행한다. + +- Documentation: + - `.example/`가 ignore되면 repo에 데모 실행 문서를 남길 별도 위치가 필요할 수 있다. + - 실행 문서에는 config 전환 방법, local/development base URL, 자동 로그인 동작, 주의사항을 적는다. + +## Tasks + +### Task 1: Repository baseline sync +- Acceptance: 작업 브랜치가 최신 `main` 기준에서 시작되고, `git.environment-variables` submodule도 최신 main revision을 가리킨다. +- Acceptance: 동기화 후 기존 curation v2 API 파일과 계획 문서가 사라지지 않는다. +- Verification: `git status --short --branch`, `git rev-parse --short HEAD`, `git submodule status --recursive` +- Files: repository state only; submodule gitlink only if upstream revision changed +- Size: S +- Status: [x] done + +### Task 2: Ignored display workspace +- Acceptance: `.example/`가 git ignore 대상이 되고, `.example/display` 정적 페이지 기본 구조가 준비된다. +- Acceptance: `display.config.js` 하나로 `active: "local" | "development"`를 제어하며 production 환경은 포함하지 않는다. +- Acceptance: config에는 product/admin base URL과 admin login credential이 들어가고, token은 브라우저 메모리 또는 `sessionStorage`에만 저장된다. +- Verification: `git status --ignored --short .example .gitignore`, static server root에서 `index.html` 로드 확인 +- Files: `.gitignore` tracked; `.example/display/index.html`, `.example/display/js/config.js`, `.example/display/js/api.js` ignored +- Size: M +- Status: [x] done + +### Task 3: Spec browsing screens +- Acceptance: 원본 display의 navigation, common style, specs/curations 목록 화면이 Bottlenote API 응답 구조에 맞게 동작한다. +- Acceptance: development 환경에서 자동 로그인 후 `GET /admin/api/v2/curation-specs`가 3개 spec을 표시한다. +- Acceptance: spec 카드에서 `requestSpec`, `responseSpec`, `hydratorKey`, `version`, `isActive`를 확인할 수 있다. +- Verification: static server + development config로 specs 화면 조회, browser console error 없음 +- Files: `.example/display/specs.html`, `.example/display/curations.html`, `.example/display/css/**`, `.example/display/js/nav.js`, `.example/display/js/specs.js`, `.example/display/js/curations.js` ignored +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-3 +- [x] `main` and submodule baseline confirmed +- [x] `.example/` is ignored +- [x] development specs list returns HTTP 200 and renders 3 specs +- [x] no credential or token is tracked by git + +### Task 4: Curation creation flow +- Acceptance: 원본 `curation-new.html`의 동적 form UX를 가능한 유지하면서 Bottlenote Admin `POST /admin/api/v2/curations` request body를 생성한다. +- Acceptance: request payload는 선택된 spec의 `requestSpec` 기준으로 구성되고, 기본 정보와 payload가 현재 `CurationCreateRequest` 계약에 맞게 전송된다. +- Acceptance: 실패 시 HTTP status, server error code/message, validation 실패 위치를 화면에 표시한다. +- Verification: development config에서 intentionally invalid payload 요청 후 validation error 표시 확인, valid payload 요청은 사용자 승인 데이터로 smoke 가능 +- Files: `.example/display/curation-new.html`, `.example/display/js/curation-new.js`, `.example/display/js/widgets/**`, `.example/display/js/styles.js` ignored +- Size: M +- Status: [x] done + +### Task 5: Detail comparison screen +- Acceptance: 상세 화면에서 Admin detail 응답과 Product detail 응답을 같은 curation id 기준으로 조회해 비교한다. +- Acceptance: Admin raw payload, Product materialized payload, `responseSpec`를 동시에 확인할 수 있다. +- Acceptance: 모바일 미리보기는 Product 응답을 기준으로 표시하고, hydration 결과가 없거나 null인 항목을 구분해 보여준다. +- Verification: existing or newly created curation id로 admin/product detail HTTP 200 확인, raw JSON과 preview 렌더 확인 +- Files: `.example/display/curation-detail.html`, `.example/display/js/curation-detail.js`, `.example/display/js/mobile-preview.js`, `.example/display/js/widgets/**` ignored +- Size: M +- Status: [x] done + +### Task 6: Demo verification notes +- Acceptance: ignored demo 실행 방법, config 전환 방법, 자동 로그인 동작, local/development 주의사항이 tracked 문서에 남는다. +- Acceptance: development API 기준 specs list, admin curations list, product curations list, login token injection smoke 결과가 기록된다. +- Acceptance: `.example/` 아래 credential/token이 git에 추적되지 않는다는 증거가 남는다. +- Verification: `git status --short --ignored .example`, `curl` 또는 browser smoke 결과 기록, `git diff --check` +- Files: tracked documentation under `plan/` or `docs/`; ignored `.example/display/**` remains untracked +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 4-6 +- [x] create flow maps to Admin request contract +- [x] detail screen compares Admin and Product responses +- [x] development smoke completed with token auto-loading +- [x] tracked diff excludes `.example/display/**` + +## Progress Log + +- 2026-05-17 Task 1 완료: `main`으로 전환 후 `git pull`로 `dc9cc851`까지 fast-forward했고, `git.environment-variables` submodule은 `8dd662e15c098c2e57c36a2604a1ba42fceed9f1` 상태임을 확인했다. 최신 main 기준 작업 브랜치 `codex/curation-v2-display-demo`를 생성했다. 검증: `git status --short --branch`, `git rev-parse --short HEAD`, `git submodule status --recursive`. +- 2026-05-17 Task 2 완료: `.gitignore`에 `.example/`를 추가했고 원본 curation demo display를 `.example/display`로 복사했다. `js/config.js`는 `active` 기반 local/development 전환을 제공하고, `js/api.js`는 Bottlenote Admin/Product base URL, admin 자동 로그인, sessionStorage token 저장, GlobalResponse unwrap을 담당한다. 검증: `git status --short --ignored .example .gitignore plan/curation-v2-display-demo.md`, `node --check .example/display/js/api.js`, `node --check .example/display/js/config.js`, `node --check .example/display/js/nav.js`. +- 2026-05-17 Task 3 완료: specs 화면은 Admin `/curation-specs` 응답의 `requestSpec`, `responseSpec`, `hydratorKey`, `version`, `isActive`를 표시하고, curations 화면은 Admin `/spec-based-curations`와 Product `/api/v2/curations`를 나란히 조회하도록 조정했다. 정적 서버 `localhost:8097`에서 `index.html`, `specs.html`, `curations.html` HTTP 200을 확인했다. development API smoke는 specs=200 count=3, admin_curations=200 count=0, product_curations=200 count=0. +- 2026-05-18 정정: admin-api context-path를 `/admin/api`로 낮추고 spec 기반 curation surface를 `/v2`로 이동했다. 현재 코드베이스 기준 Admin specs는 `/admin/api/v2/curation-specs`, Admin spec-based curation은 `/admin/api/v2/curations`가 canonical endpoint다. 기존 `/admin/api/v1/curation-specs`, `/admin/api/v1/spec-based-curations` alias는 제공하지 않는다. +- 2026-05-17 Task 4 완료: `curation-new.html` 기본 노출 기간을 현재 dev smoke에 맞게 2026-05-17~2026-12-31로 바꾸고, submit body에서 DTO에 없는 `coverImageUrl`을 제거했다. 오류 표시는 `HTTP status`, server `code/status/message`를 노출하도록 조정했다. development Admin create invalid smoke는 HTTP 400, `CURATION_PAYLOAD_REQUIRED`를 확인했다. +- 2026-05-17 Task 5 완료: 상세 화면을 Admin raw detail과 Product materialized detail 비교 화면으로 단순화했다. development에 `CODEX_DISPLAY_SMOKE_20260517` 큐레이션을 생성했고 `targetId=2`를 받았다. Admin detail HTTP 200, Product detail HTTP 200, Admin payload array count=1, Product payload array count=1, MANUAL 항목 `stats=null`을 확인했다. 정적 상세 페이지 `curation-detail.html?id=2`도 HTTP 200으로 서빙됐다. +- 2026-05-17 Task 6 완료: `plan/curation-v2-display-demo-runbook.md`에 ignored demo 실행 방법, config 전환, 자동 로그인, development smoke 결과, git tracking policy를 기록했다. `.example/`는 `!! .example/`로 ignored 상태이고, tracked diff에는 `.example/display/**`가 포함되지 않는다. diff --git a/plan/complete/curation-v2-test-hardening.md b/plan/complete/curation-v2-test-hardening.md new file mode 100644 index 000000000..1454f382f --- /dev/null +++ b/plan/complete/curation-v2-test-hardening.md @@ -0,0 +1,187 @@ +# Plan: Curation V2 Test Hardening + +## Overview + +큐레이션 v2의 현재 코드베이스 로직을 테스트로 더 명확하게 고정한다. + +이번 작업은 신규 기능 구현이 아니라 테스트 보강이다. 특히 사용자가 설계한 핵심 정책인 "큐레이션 아이템은 저장 시점의 메타 정보를 payload로 스냅샷 저장하고, 현재성이 필요한 통계만 GraphQL로 보강한다"는 동작을 테스트명과 assertion으로 직접 표현한다. + +테스트 보강은 에이전트 역할을 분리해서 진행한다. 구현 에이전트가 테스트를 추가하고, 목적 검증 에이전트가 테스트가 실제 의도를 검증하는지 반례까지 확인하며, 컨벤션 검토 에이전트가 기존 테스트 방식과 프로젝트 규칙을 점검한다. 마지막으로 회귀 검증 에이전트가 focused Gradle suite를 실행해 실제 통과 여부를 확인한다. + +### Assumptions + +- 대상은 spec 기반 큐레이션 v2만이다. legacy `curation_keyword` 테스트는 이번 범위가 아니다. +- 현재 canonical endpoint는 Admin `/admin/api/v2/curation-specs`, `/admin/api/v2/curations`, Product `/api/v2/curations`다. +- `source: BOTTLE_NOTE(내부 알코올 참조)`는 `alcoholId`를 통해 현재 통계를 GraphQL로 보강할 수 있는 아이템이다. +- `source: MANUAL(직접 입력)`은 내부 알코올 조회 대상이 아니며 저장된 payload 그대로 응답하고 `stats`는 `null`이다. +- 이름, 이미지, 태그, 코멘트 같은 노출용 메타 정보는 저장 시점 payload의 스냅샷을 사용한다. +- 별점, 평가 수, 리뷰 수, 찜 수 같은 현재성 있는 통계만 Product 상세 조회 시 GraphQL hydration으로 보강한다. +- Unit test는 fake, in-memory repository, fake GraphQL executor를 우선 사용한다. +- E2E/Integration test는 실제 Spring context, JPA, TestContainers가 필요한 검증에만 사용한다. +- 기존 미커밋 변경인 Admin Asciidoc, display demo plan/runbook, `git.environment-variables` gitlink 변경은 보존한다. + +### Success Criteria + +- `source: BOTTLE_NOTE(내부 알코올 참조)` 항목은 저장된 메타 정보를 유지하고 현재 통계만 GraphQL로 보강한다는 테스트가 추가된다. +- `source: MANUAL(직접 입력)` 항목은 GraphQL 조회 대상에서 제외되고 `stats=null`로 응답한다는 테스트가 명확히 유지되거나 보강된다. +- 알코올 원본 정보가 변경된 뒤 Product v2 상세를 조회해도 큐레이션 payload의 저장 시점 메타 정보가 응답된다는 E2E/Integration 테스트가 추가된다. +- 3개 스펙 `RECOMMENDED_WHISKY`, `WHISKY_PAIRING`, `WHISKY_TASTING_EVENT`에 대해 유효 payload 저장 또는 materialized response 검증이 보강된다. +- Admin v2 무인증 접근 또는 존재하지 않는 spec/curation 같은 API 경계 오류가 필요한 범위에서 보강된다. +- 테스트 이름은 가능한 한 `~할 경우 ~한다` 형태를 사용하고, source 타입은 `source: BOTTLE_NOTE(내부 알코올 참조)`, `source: MANUAL(직접 입력)`처럼 괄호 설명을 포함한다. +- Parameterized test가 중복을 줄일 수 있는 스펙별 검증에는 `@ParameterizedTest`를 우선 고려한다. +- 추가 테스트는 기존 tag 규칙을 따른다: unit은 `@Tag("unit")`, product integration은 `@Tag("integration")`, admin integration은 `@Tag("admin_integration")`. +- focused verification이 성공한다: + - `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.*' --tests 'app.bottlenote.graphql.GraphQLCurationSchemaTest'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'` + - `./gradlew :bottlenote-product-api:integration_test --tests 'app.bottlenote.curation.integration.ProductSpecBasedCurationIntegrationTest'` + - 필요 시 관련 RestDocs 테스트와 `git diff --check` + +### Impact Scope + +- `bottlenote-mono` + - `CurationResponseMaterializerTest` + - `CurationPayloadValidatorTest` + - `ProductSpecBasedCurationServiceTest` + - `AdminSpecBasedCurationServiceTest` + - `GraphQLCurationAlcoholResolverTest` + - curation fixture/fake repository +- `bottlenote-product-api` + - `ProductSpecBasedCurationIntegrationTest` + - 필요 시 Product curation RestDocs test +- `bottlenote-admin-api` + - `AdminSpecBasedCurationIntegrationTest` + - 필요 시 Admin spec-based curation RestDocs test +- Persistence + - schema 변경 없음. + - 테스트 데이터만 추가한다. +- API Contract + - endpoint 변경 없음. + - 응답 형태 변경 없음. +- Docs + - 테스트 보강 자체에는 API 문서 변경이 필수는 아니다. + - 기존 문서 diff는 보존한다. + +### Agent Roles + +- Implementation Agent + - 테스트 코드를 추가한다. + - 기존 fixture, fake, TestFactory를 우선 재사용한다. + - production code 변경은 테스트 불가능성이 확인된 경우에만 별도 보고한다. + +- Test Intent Review Agent + - 각 테스트가 이름 그대로의 목적을 검증하는지 확인한다. + - `A일 때 X다`뿐 아니라 `B일 때 X가 아니다`에 해당하는 반례 assertion이 필요한지 점검한다. + - 스냅샷 메타 정보와 GraphQL 통계 보강이 같은 assertion에 섞여 흐려지지 않았는지 확인한다. + +- Test Convention Review Agent + - 기존 테스트 패턴, tag, DisplayName, fake/in-memory 우선 원칙, TestContainers 사용 기준을 검토한다. + - 불필요한 mock, 과도한 통합 테스트, 기존 fixture 중복 생성을 반려한다. + +- Regression Verification Agent + - focused Gradle suite를 실행한다. + - 실패 시 compile/test/docs/integration/TestContainers 문제를 분리해 보고한다. + - 성공 주장 전 실제 `BUILD SUCCESSFUL` 또는 실패 원인을 확인한다. + +### Out of Scope + +- 큐레이션 v2 production 로직 변경 +- DB schema 변경 +- legacy `curation_keyword` 동작 변경 +- 신규 큐레이션 스펙 추가 +- Admin/Product endpoint 변경 +- display demo UI 구현 변경 + +## Runtime Boundary + +이 문서는 `/define` 산출물이다. 다음 단계는 `/plan`에서 테스트 보강 task를 쪼개는 것이다. + +## Dependency Analysis + +1. Unit 테스트는 production context 없이 빠르게 정책을 고정한다. 특히 `CurationResponseMaterializer`와 `CurationPayloadValidator`가 Product 상세 응답의 payload 형태를 결정하므로 먼저 보강한다. +2. Product integration 테스트는 Admin 저장 경로, JPA persistence, Product 조회, 내부 GraphQL hydration까지 연결되는 핵심 E2E 경로다. 저장 시점 스냅샷 정책은 이 레이어에서 가장 명확하게 검증한다. +3. Admin integration 테스트는 v2 endpoint의 인증/오류 경계를 고정한다. Product integration과 독립적으로 구현 가능하다. +4. 리뷰 에이전트는 구현 후 병렬로 목적/컨벤션을 검토한다. 회귀 검증은 리뷰 반영 뒤 수행한다. + +## Tasks + +### Task 1: Unit materializer source policy hardening + +- Acceptance: `source: BOTTLE_NOTE(내부 알코올 참조)`는 저장된 메타 정보를 유지하고 stats만 GraphQL 결과로 보강한다. +- Acceptance: `source: MANUAL(직접 입력)`은 GraphQL 변수에서 제외되고 stats를 null로 유지한다. +- Acceptance: 중복 `alcoholId`는 GraphQL 변수에서 중복 제거된다. +- Verification: `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationResponseMaterializerTest'` +- Files: `bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationResponseMaterializerTest.java` +- Size: S +- Status: [x] done + +### Task 2: Spec parameterized validation coverage + +- Acceptance: `RECOMMENDED_WHISKY`, `WHISKY_PAIRING`, `WHISKY_TASTING_EVENT` 유효 request payload가 모두 requestSpec 검증을 통과한다. +- Acceptance: 세 스펙의 대표 materialized payload가 responseSpec 검증을 통과한다. +- Verification: `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationPayloadValidatorTest'` +- Files: `bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPayloadValidatorTest.java` +- Size: S +- Status: [x] done + +### Task 3: Product integration snapshot semantics + +- Acceptance: Product v2에서 알코올 원본 정보가 변경된 뒤 조회할 경우 큐레이션 payload의 저장 시점 메타 정보로 응답한다. +- Acceptance: 같은 응답에서 현재 통계는 GraphQL hydration 결과로 보강된다. +- Verification: `./gradlew :bottlenote-product-api:integration_test --tests 'app.bottlenote.curation.integration.ProductSpecBasedCurationIntegrationTest'` +- Files: `bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java` +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 1-3 + +- [x] Mono curation unit tests pass +- [x] Product curation integration tests pass +- [x] Snapshot semantics are asserted by both unit or integration coverage + +### Task 4: Admin integration boundary coverage + +- Acceptance: Admin v2에서 존재하지 않는 specId로 생성할 경우 404를 반환한다. +- Acceptance: Admin v2에서 존재하지 않는 curationId로 수정할 경우 404를 반환한다. +- Acceptance: Admin v2에서 인증 없이 `/v2/curation-specs` 또는 `/v2/curations`를 요청할 경우 현재 보안 설정에 맞는 4xx를 반환한다. +- Verification: `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'` +- Files: `bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt` +- Size: S +- Status: [x] done + +### Task 5: Agent review and focused verification + +- Acceptance: Test Intent Review Agent가 테스트 목적과 반례 assertion을 검토한다. +- Acceptance: Test Convention Review Agent가 tag, fixture, fake/TestContainers 사용 기준을 검토한다. +- Acceptance: Focused Gradle suite와 `git diff --check`가 성공한다. +- Verification: + - `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.*' --tests 'app.bottlenote.graphql.GraphQLCurationSchemaTest'` + - `./gradlew :bottlenote-admin-api:test --tests 'app.docs.curation.AdminSpecBasedCurationControllerDocsTest'` + - `./gradlew :bottlenote-admin-api:admin_integration_test --tests 'app.integration.curation.AdminSpecBasedCurationIntegrationTest'` + - `./gradlew :bottlenote-product-api:test --tests 'app.docs.curation.RestProductSpecBasedCurationControllerTest'` + - `./gradlew :bottlenote-product-api:integration_test --tests 'app.bottlenote.curation.integration.ProductSpecBasedCurationIntegrationTest'` + - `git diff --check` +- Files: no expected source files beyond test updates and plan progress log +- Size: S +- Status: [x] done + +### Task 6: Full verification + +- Acceptance: `/verify full` 상당 범위의 compile, rule, unit, build, integration, admin integration 검증이 성공한다. +- Acceptance: 실패 시 실패 레이어와 원인을 Progress Log에 기록한다. +- Verification: + - `./gradlew compileJava compileKotlin compileTestJava compileTestKotlin` + - `./gradlew check_rule_test` + - `./gradlew unit_test` + - `./gradlew build` + - `./gradlew integration_test` + - `./gradlew admin_integration_test` +- Files: `plan/curation-v2-test-hardening.md` +- Size: S +- Status: [x] done + +## Progress Log + +- 2026-05-18: `/plan` 완료. 테스트 보강을 unit materializer, spec parameterized validation, Product integration snapshot semantics, Admin integration boundary, agent review/focused verification, full verification 6개 task로 분리했다. +- 2026-05-18: Task 1-4 구현 및 리뷰 피드백 반영. `source: MANUAL(직접 입력)` 단독 payload에서 GraphQL 실행을 생략하고 `stats=null`을 유지하도록 materializer를 보강했다. Admin 무인증 경계는 현재 보안 설정의 실제 응답인 403을 포함하는 4xx 기준으로 문서화했다. +- 2026-05-18: Test Intent Review Agent와 Test Convention Review Agent 검토 완료. 반영 사항: 원본 알코올 변경 전제 assertion 추가, 스펙별 required 누락 parameterized 반례 추가, Admin 무인증 테스트 복구, helper mutation 제거, DisplayName 문장형 보정. +- 2026-05-18: Task 5 focused suite와 `git diff --check` 성공. `/verify full` 수행 결과 `compileJava compileTestJava`, `:bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin`, `check_rule_test`, `unit_test`, `build -x test -x asciidoctor --build-cache --parallel`, `integration_test`, `admin_integration_test`, optional `asciidoctor` 모두 `BUILD SUCCESSFUL`. diff --git a/plan/complete/spec-based-curation-v2-graphql-sdl.md b/plan/complete/spec-based-curation-v2-graphql-sdl.md new file mode 100644 index 000000000..f0af2a0c0 --- /dev/null +++ b/plan/complete/spec-based-curation-v2-graphql-sdl.md @@ -0,0 +1,357 @@ +# Plan: Spec-Based Curation V2 - GraphQL SDL Foundation + +## Overview + +`curation_demo`에서 검증한 spec 기반 큐레이션을 `bottle-note-api-server`에 이식한다. + +전체 기능은 신규 도메인, DB schema, Admin/Product endpoint, payload validation, GraphQL hydration까지 포함하므로 한 번에 끝내지 않는다. 이번 기능은 검증 범위를 3개 phase로 나눈 뒤, Phase 1에서 GraphQL 도메인 SDL과 서버 내 실행 기반만 먼저 만든다. + +기존 `curation_keyword`와 `curation_keyword_alcohol_ids`는 유연한 이관을 위해 그대로 둔다. 기존 Admin/Product endpoint도 변경하지 않는다. 신규 외부 API는 이후 phase에서 v2 endpoint로 추가한다. + +### Flow Diagram + +![Spec-Based Curation V2 Flow](../assets/spec-based-curation-v2-flow.png) + +### Phase Roadmap + +1. **GraphQL SDL Foundation** + - Spring GraphQL 의존성, SDL, resolver, 내부 실행 검증 기반을 추가한다. + - Alcohol 도메인 조회와 stats hydration에 필요한 최소 field만 다룬다. + - 외부 curation v2 endpoint와 신규 curation DB table은 만들지 않는다. +2. **Curation V2 Persistence + Admin Command** + - `git.environment-variables/storage/mysql/changelog/`에 `curation_spec`, `curation`, `curation_extension` schema changeset을 추가하고, mono domain/repository/service를 추가한다. + - Admin v2 등록/수정/상세/목록 API에서 spec 기반 payload 저장과 검증을 처리한다. +3. **Product V2 Query + Hydration** + - Product v2 목록/상세 API를 추가한다. + - 저장 payload를 response spec과 GraphQL hydration 결과로 앱 응답 형태로 조립한다. + - `BOTTLE_NOTE` 항목만 stats를 보강하고 `MANUAL` 항목은 stats를 생략한다. + +## Assumptions + +- 신규 기능은 기존 `app.bottlenote.alcohols.domain.CurationKeyword`를 개조하지 않고 별도 `app.bottlenote.curation` 도메인으로 진행한다. +- 기존 테이블 `curation_keyword`, `curation_keyword_alcohol_ids`는 이번 전체 이식 기간 동안 drop 또는 data migration하지 않는다. +- DB schema 변경이 필요한 phase에서는 반드시 서브모듈 `git.environment-variables/storage/mysql/changelog/` 아래 changelog를 사용한다. +- 기존 endpoint는 유지하고, 신규 endpoint는 후속 phase에서 v2로 추가한다. +- Phase 1은 GraphQL 기반만 다루며, curation v2 DB table과 Admin/Product curation endpoint는 만들지 않는다. +- GraphQL은 외부 공개 API로 먼저 설계하지 않고, 서버 내부 hydration 실행 기반으로 도입한다. `/graphql` HTTP 노출 여부는 구현 단계에서 Spring GraphQL 기본 동작과 보안 설정 영향을 확인한 뒤 필요한 경우 제한한다. +- OpenAPI curation spec seed는 Liquibase insert changeset이 아니라 `bottlenote-mono/src/main/resources/openapi/curation/*.json` 리소스를 admin-api 기동 시 `curation_spec`으로 동기화하는 방식으로 관리한다. +- 도메인 SDL은 `Alcohol`을 시작점으로 하며, Phase 1의 선택 field는 stats hydration에 필요한 평균 별점, 평점 수, 리뷰 수, 찜 수와 기본 alcohol 식별/표시 field로 제한한다. +- 사용자별 picks/reviews/ratings 원본 목록은 curation v2 응답에 노출하지 않는다. +- `reviewCount`는 데모처럼 rating count를 재사용하지 않고 실제 review 도메인 집계 기준으로 정의한다. + +## Success Criteria + +- `bottlenote-mono` 또는 GraphQL 설정을 소유할 적절한 모듈에 Spring GraphQL 의존성과 SDL 리소스가 추가된다. +- SDL에 `Query.alcohols(ids: [ID!]!)` 또는 동등한 batch 조회 진입점이 정의된다. +- SDL의 `Alcohol` 타입에 curation stats hydration에 필요한 필드가 정의된다: + - `alcoholId` + - `korName` + - `engName` + - `imageUrl` + - `regionName` + - `korCategory` + - `cask` + - `abv` + - `volume` + - `rating` + - `totalRatingsCount` + - `reviewCount` + - `totalPickCount` +- GraphQL resolver는 기존 alcohol/rating/review/picks 도메인의 repository 또는 service를 사용해 위 필드를 해석한다. +- `reviewCount`는 실제 review count를 반환한다. +- 없는 alcohol id는 전체 요청을 실패시키지 않고 누락 또는 null 정책을 명확히 따른다. +- `source = MANUAL` 같은 `alcoholId = null` 입력은 후속 payload hydration 단계에서 GraphQL 조회 대상이 되지 않는 정책으로 문서화된다. +- Phase 1 완료 시 기존 `curation_keyword` API 동작 표면은 변경되지 않는다. +- Phase 1 완료 시 다음 검증이 통과해야 한다: + - `./gradlew :bottlenote-mono:compileJava` + - GraphQL resolver 또는 builder 대상 단위 테스트 + - `./gradlew check_rule_test` + +## Impact Scope + +- **Modules** + - `bottlenote-mono`: GraphQL SDL, resolver, stats 조회 조립, 테스트가 들어갈 가능성이 높다. + - `bottlenote-product-api`: Phase 1에서는 외부 curation endpoint를 추가하지 않는다. Spring GraphQL runtime wiring이 executable app 쪽 설정을 요구하는지는 `/plan`에서 확인한다. + - `bottlenote-admin-api`: Phase 1 영향 없음. + - `git.environment-variables`: Phase 1 schema 변경 없음. +- **Persistence** + - Phase 1에서는 신규 테이블을 만들지 않는다. + - 기존 `curation_keyword`, `curation_keyword_alcohol_ids`는 읽기 확인 대상일 뿐 수정 대상이 아니다. + - Phase 2 이후 DB schema 변경은 `/Users/hgkim/workspace/etc/bottlenote/bottle-note-api-server/git.environment-variables/storage/mysql/changelog/`의 Liquibase changelog에 append한다. + - 이미 적용된 기존 changeset은 수정하지 않는다. +- **API Contract** + - Phase 1에서는 신규 public REST endpoint를 만들지 않는다. + - `/graphql` HTTP endpoint가 자동 노출되는 경우 보안/운영 노출 정책을 별도 결정해야 한다. +- **Cross-Domain Coupling** + - GraphQL resolver가 alcohol, rating, review, picks 집계를 한 지점에서 묶는다. + - curation 도메인에서 직접 각 도메인을 호출하는 대신, 후속 phase에서 GraphQL hydration boundary를 재사용한다. +- **Validation** + - Phase 1은 payload JSON Schema validation을 구현하지 않는다. + - payload validation은 Phase 2 범위다. +- **Tests** + - SDL schema loading 또는 GraphQL query execution 테스트가 필요하다. + - resolver 단위 테스트는 Fake/InMemory 우선 원칙을 따른다. + - 기존 curation keyword endpoint regression은 최소 smoke 수준으로 범위를 정한다. +- **Docs** + - 이번 plan 문서는 전체 phase boundary와 Phase 1 정의의 source of truth다. + - `/plan` 단계에서 Phase 1 task breakdown과 검증 명령을 추가한다. + +## Phase 1 Out of Scope + +- `curation_spec`, `curation`, `curation_extension` table 추가 +- Admin curation v2 등록/수정/목록/상세 endpoint +- Product curation v2 목록/상세 endpoint +- 기존 `curation_keyword` 제거, drop, data migration +- 기존 curation keyword endpoint path 변경 +- FE/Admin 화면 구현 + +## Tasks + +### Task 1: Spring GraphQL module wiring + +- Acceptance: + - Spring GraphQL 의존성이 version catalog 또는 기존 Gradle 패턴에 맞게 추가된다. + - SDL 리소스 경로는 `bottlenote-mono/src/main/resources/graphql/schema.graphqls`로 확정한다. + - Phase 1에서 `git.environment-variables/storage/mysql/changelog/`는 수정하지 않는다. +- Verification: + - `./gradlew :bottlenote-mono:compileJava` + - `./gradlew :bottlenote-product-api:compileJava` + - `git diff -- git.environment-variables/storage/mysql/changelog/` 결과가 비어 있어야 한다. +- Files: + - `gradle/libs.versions.toml` + - `bottlenote-mono/build.gradle` + - `bottlenote-mono/src/main/resources/graphql/schema.graphqls` path decision +- Size: S +- Status: [x] done + +### Task 2: Alcohol GraphQL SDL contract + +- Acceptance: + - `Alcohol` 타입과 `Query.alcohols(ids: [ID!]!)` 또는 동등한 batch 조회 진입점이 SDL에 정의된다. + - SDL field는 Phase 1 성공 기준의 stats hydration 필드로 제한된다. + - 사용자별 picks/reviews/ratings 목록 field는 SDL에 포함하지 않는다. +- Verification: + - SDL 파일에서 `type Alcohol`, `type Query`, `alcohols` 진입점이 확인된다. + - GraphQL schema loading 테스트 또는 application context 테스트가 SDL을 로딩한다. +- Files: + - `bottlenote-mono/src/main/resources/graphql/schema.graphqls` + - GraphQL schema loading test +- Size: S +- Status: [x] done + +### Task 3: Alcohol stats resolver foundation + +- Acceptance: + - resolver는 기존 alcohol/rating/review/picks 도메인의 repository 또는 service를 사용한다. + - `reviewCount`는 rating count가 아니라 실제 review count 기준으로 계산한다. + - 존재하지 않는 alcohol id는 전체 GraphQL 실행을 실패시키지 않는 정책으로 처리된다. +- Verification: + - resolver unit test 또는 in-process GraphQL execution test가 `rating`, `totalRatingsCount`, `reviewCount`, `totalPickCount`를 검증한다. + - `MANUAL` 항목처럼 `alcoholId = null`인 케이스는 GraphQL 조회 대상에서 제외해야 한다는 정책이 테스트명 또는 문서에 남는다. +- Files: + - GraphQL resolver package under the selected module + - Existing domain repository/service additions only if needed + - Resolver or execution test +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-3 + +- [x] `./gradlew :bottlenote-mono:compileJava` +- [x] `./gradlew :bottlenote-product-api:compileJava` +- [x] GraphQL schema/resolver focused tests pass +- [x] `git diff -- git.environment-variables/storage/mysql/changelog/` is empty + +### Task 4: Product bootRun smoke with .env + +- Acceptance: + - `.env`를 로드한 상태로 product API를 `bootRun` 기동할 수 있다. + - 기동 검증은 secret 값을 로그나 문서에 노출하지 않는다. + - Phase 1이 기존 REST curation keyword endpoint path를 변경하지 않았음을 확인한다. +- Verification: + - `set -a; source .env; set +a; ./gradlew :bottlenote-product-api:bootRun` + - 서버 기동 후 GraphQL wiring 또는 application startup log 확인 + - 필요 시 별도 터미널에서 기존 reference endpoint smoke 확인 +- Files: + - No required source file unless startup wiring needs a config adjustment +- Size: S +- Status: [x] done + +### Task 5: Phase 1 final verification + +- Acceptance: + - Phase 1 성공 기준이 모두 충족된다. + - 다음 phase에서 사용할 GraphQL hydration boundary가 문서상 명확하다. + - 신규 DB schema, Admin spec 기반 endpoint, Product curation v2 endpoint가 아직 추가되지 않았음이 확인된다. +- Verification: + - `./gradlew :bottlenote-mono:compileJava` + - `./gradlew :bottlenote-product-api:compileJava` + - `./gradlew check_rule_test` + - `rg -n "curation_spec|curation_extension" git.environment-variables/storage/mysql/changelog/schema.mysql.sql` 결과가 기존 파일 기준으로 신규 추가되지 않았는지 diff로 확인 +- Files: + - `plan/spec-based-curation-v2-graphql-sdl.md` + - Optional focused docs update only if implementation decisions diverge from this plan +- Size: S +- Status: [x] done + +### Phase 2 Task 1: Curation V2 schema changelog apply + +- Acceptance: + - `git.environment-variables/storage/mysql/changelog/schema.mysql.sql`에 `curation_spec`, `curation`, `curation_extension` changeset을 append한다. + - 기존 `curation_keyword`, `curation_keyword_alcohol_ids`는 변경하지 않는다. + - SOPS 기반 Liquibase 설정을 사용하되 secret 값을 출력하거나 문서화하지 않는다. + - 개발 DB와 운영 DB 모두 pending changeset이 이번 3개뿐인지 확인한 뒤 적용한다. +- Verification: + - `liquibase validate` + - `liquibase status --verbose` + - `liquibase update` + - `information_schema.tables`에서 `curation_spec`, `curation`, `curation_extension` 생성 확인 + - `liquibase status --verbose` 결과가 up to date +- Files: + - `git.environment-variables/storage/mysql/changelog/schema.mysql.sql` +- Size: S +- Status: [x] done + +### Phase 2 Task 2: Mono curation v2 persistence model + +- Acceptance: + - `bottlenote-mono`에 `curation_spec`, `curation`, `curation_extension`에 대응되는 Entity를 추가한다. + - Repository는 domain interface + JPA implementation 패턴을 따른다. + - JSON 컬럼은 기존 Hypersistence `JsonType` 기반 매핑을 사용한다. + - ~~Admin command가 재사용할 persistence service를 추가하되, REST endpoint는 아직 만들지 않는다.~~ + - 정정: Admin command 저장 경로는 `AdminSpecBasedCurationService`로 단일화하고, mono에는 Entity + Repository + 테스트 fixture만 유지한다. +- Verification: + - `./gradlew :bottlenote-mono:compileJava` + - ~~`./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationV2ServiceTest'`~~ + - `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.AdminSpecBasedCurationServiceTest' --tests 'app.bottlenote.curation.service.ProductSpecBasedCurationServiceTest' --tests 'app.bottlenote.curation.service.CurationResponseMaterializerTest'` + - `./gradlew check_rule_test` +- Files: + - `bottlenote-mono/src/main/java/app/bottlenote/curation/domain/*` + - `bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/*` + - `bottlenote-mono/src/main/java/app/bottlenote/curation/repository/*` + - ~~`bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationV2Service.java`~~ + - `bottlenote-mono/src/test/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java` + - `bottlenote-mono/src/test/java/app/bottlenote/curation/**` +- Size: M +- Status: [x] done + +### Phase 2 Task 3: Admin spec-based curation command/query API + +- Acceptance: + - Admin spec 기반 등록/수정/상세/목록 API를 추가한다. + - ~~Admin context-path가 이미 `/admin/api/v1`이므로 컨트롤러 path에 `/v2` prefix를 중복으로 붙이지 않는다.~~ + - 정정: 2026-05-18에 admin-api context-path를 `/admin/api`로 낮추고 legacy Admin controller는 중앙 `/v1` prefix로 보존했다. spec 기반 신규 Admin API는 `/v2` controller mapping을 명시한다. + - ~~스펙 API는 `/curation-specs`, spec 기반 큐레이션 API는 기존 `/curations`와 충돌하지 않도록 `/spec-based-curations`를 사용한다.~~ + - 정정: 최종 Admin spec API는 `/admin/api/v2/curation-specs`, spec 기반 큐레이션 API는 `/admin/api/v2/curations`를 사용한다. 기존 legacy Admin curation API는 `/admin/api/v1/curations`로 유지한다. + - 스펙 목록과 스펙 상세 API를 제공한다. + - request payload는 저장 전 OpenAPI spec resource 기준 검증 경계로 연결한다. + - admin-api 기동 시 `openapi/curation/*.json` 리소스를 읽어 `curation_spec`을 생성 또는 갱신한다. + - 리소스 동기화는 `curation.spec-sync.enabled` 설정으로 비활성화할 수 있다. + - 기존 Admin curation keyword API와 path는 변경하지 않는다. + - Product v2 hydration API는 아직 만들지 않는다. +- Verification: + - Admin API compile + - Admin controller/docs focused test + - Resource sync focused test + - `./gradlew check_rule_test` +- Files: + - `bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/**` + - 필요한 mono DTO/service 확장 +- Size: L +- Status: [x] done + +### Phase 3 Task 1: Product v2 query + response materialization + +- Acceptance: + - Product v2 목록/상세 API를 `/api/v2/curations`, `/api/v2/curations/{curationId}`로 추가한다. + - 기존 Product `/api/v1/curations` keyword endpoint는 변경하지 않는다. + - 상세 응답은 header + spec meta + materialized payload 구조로 반환한다. + - `responseSpec.x-graphql` 메타를 읽어 GraphQL query를 생성하고 서버 내부 GraphQL executor로 실행한다. + - `BOTTLE_NOTE` 항목은 `alcohol.alcoholId`로 stats를 보강하고, `MANUAL` 또는 `alcoholId = null` 항목은 GraphQL 조회 대상으로 넘기지 않는다. + - root array 스펙과 `payloadPath = $.alcohols` object 스펙을 모두 지원한다. + - Admin 저장 시점 request payload는 requestSpec 기준으로 required, enum, type, string length, array min/max items, numeric min/max를 검증한다. + - Product 조회 시점 materialized payload는 responseSpec 기준으로 다시 검증한다. +- Verification: + - `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationPayloadValidatorTest' --tests 'app.bottlenote.curation.service.CurationResponseMaterializerTest' --tests 'app.bottlenote.curation.service.ProductSpecBasedCurationServiceTest'` + - `./gradlew :bottlenote-product-api:compileJava` + - `./gradlew check_rule_test` +- Files: + - `bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java` + - `bottlenote-mono/src/main/java/app/bottlenote/curation/service/GraphQLCurationQueryBuilder.java` + - `bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java` + - `bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java` + - `bottlenote-product-api/src/main/java/app/bottlenote/curation/controller/ProductSpecBasedCurationController.java` +- Size: L +- Status: [x] done + +### Phase 3 Task 2: Product v2 API docs + runtime smoke + +- Acceptance: + - Product v2 목록/상세 API RestDocs 테스트를 추가한다. + - `/api/v2/curations` 문서에는 spec 기반 큐레이션 목록 응답 구조를 남긴다. + - `/api/v2/curations/{curationId}` 문서에는 header, spec meta, materialized payload 구조를 남긴다. + - `.env` 기반 product-api 기동 후 `/api/v2/curations` runtime smoke를 수행한다. + - 실제 DB에 smoke row가 없으면 임시 row를 생성해 상세 조회까지 검증하고, 검증 후 삭제한다. + - smoke는 `BOTTLE_NOTE` stats 병합과 `MANUAL` stats null 처리를 확인한다. +- Verification: + - `./gradlew :bottlenote-product-api:test --tests 'app.docs.curation.RestProductSpecBasedCurationControllerTest'` + - `./gradlew :bottlenote-product-api:asciidoctor` + - `./gradlew check_rule_test` + - `.env` 기반 `./gradlew :bottlenote-product-api:bootRun` + - `GET /api/v2/curations` HTTP 200 + - `GET /api/v2/curations/{curationId}` HTTP 200 +- Files: + - `bottlenote-product-api/src/test/java/app/docs/curation/RestProductSpecBasedCurationControllerTest.java` + - `plan/spec-based-curation-v2-graphql-sdl.md` +- Size: M +- Status: [x] done + +## Progress Log + +- 2026-05-15 Task 1 완료: Spring GraphQL starter alias를 version catalog에 추가하고, `bottlenote-mono`에 GraphQL runtime dependency를 연결했다. SDL 경로는 `bottlenote-mono/src/main/resources/graphql/schema.graphqls`로 확정했다. 검증: `./gradlew :bottlenote-mono:compileJava` 성공, `./gradlew :bottlenote-product-api:compileJava` 성공, `git diff -- git.environment-variables/storage/mysql/changelog/` 변경 없음. +- 2026-05-15 Task 2 완료: `bottlenote-mono/src/main/resources/graphql/schema.graphqls`에 `Query.alcohols(ids: [ID!]!)`와 stats hydration용 `Alcohol` SDL을 추가했다. 사용자별 `picks`, `ratings`, `reviews` 목록 field는 포함하지 않았다. 검증: `rg -n "type Alcohol|type Query|alcohols|picks\\(|ratings\\(|reviews\\(" ...` 확인, `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.graphql.GraphQLCurationSchemaTest'` 성공, changelog diff 변경 없음. +- 2026-05-15 Task 3 완료: `bottlenote-mono`에 `GraphQLCurationAlcoholResolver`와 `GraphQLCurationAlcoholService`를 추가해 `Alcohol` field resolver를 연결했다. `rating`/`totalRatingsCount`는 rating repository 집계, `reviewCount`는 `ACTIVE` + `PUBLIC` review count, `totalPickCount`는 `PICK` 상태 count로 계산한다. `alcoholId = null`인 `MANUAL` 항목과 존재하지 않는 alcohol id는 GraphQL 조회 결과에서 제외하는 정책을 resolver 테스트명과 검증에 남겼다. 검증: `./gradlew :bottlenote-mono:compileJava` 성공, `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.graphql.GraphQLCurationSchemaTest' --tests 'app.bottlenote.curation.graphql.GraphQLCurationAlcoholResolverTest'` 성공, `./gradlew :bottlenote-product-api:compileJava` 성공, `git diff -- git.environment-variables/storage/mysql/changelog/` 변경 없음. +- 2026-05-15 추가 반영: `curation_demo/spec`의 OpenAPI 3.0 JSON 스펙 3종을 `bottlenote-mono/src/main/resources/openapi/curation/`에 그대로 추가했다. 대상은 `RECOMMENDED_WHISKY`, `WHISKY_PAIRING`, `WHISKY_TASTING_EVENT`이며 `x-curation`, `x-form-style`, `x-field-style`, `x-graphql` 메타를 포함한다. 검증: 원본 파일과 `cmp -s` 3건 일치, `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.CurationOpenApiSpecResourceTest'` 성공. +- 2026-05-15 Task 4 완료: `.env`에 `SPRING_PROFILES_ACTIVE=local`을 추가하고 Redis를 로컬 `bottle-note-redis` 컨테이너(`localhost:26379`, no auth)로 연결하도록 수정했다. `set -a; source .env; set +a; ./gradlew :bottlenote-product-api:bootRun`으로 product API가 8080에서 기동했고, 로그에서 GraphQL schema 1개 로드, unmapped field 없음, `POST /graphql` 매핑, Redis connection success를 확인했다. smoke 결과: `GET /actuator/health` HTTP 200 및 Redis UP, `POST /graphql { __typename }` HTTP 200, 기존 `GET /api/v1/curations?cursor=0&pageSize=1` HTTP 200, changelog diff 변경 없음. +- 2026-05-15 GraphiQL 운영 확인: `spring.graphql.graphiql.enabled=true`, `spring.graphql.graphiql.path=/graphiql`를 local/default profile에 추가해 브라우저 기반 GraphQL query console을 열 수 있게 했다. `GET /graphql`은 HTTP 405가 맞고, query 실행은 `POST /graphql` 또는 `/graphiql` UI를 사용한다. 2026-05-17 운영 하드닝에서 이 결정은 철회했다. 현재는 GraphQL을 내부 hydration boundary로만 유지하기 위해 GraphiQL을 비활성화하고 `/graphql`, `/graphiql`, `/graphiql/**` 외부 HTTP 접근을 403으로 차단한다. +- 2026-05-15 GraphQL browser smoke: Chrome에서 `/graphiql?path=/graphql`로 직접 쿼리를 실행했다. `[1, 999999]`는 존재하는 `1`만 반환했고, `[1, 1, 2]`는 중복 제거 후 `1, 2`만 반환했다. `[]`는 빈 배열을 반환했다. `[3, 1, 999999, 1, 2]`는 요청 순서 기준으로 `3, 1, 2`를 반환했고 없는 id와 중복 id를 제외했다. +- 2026-05-15 GraphQL boundary smoke: Chrome에서 `picks` field 요청 시 `Field 'picks' in type 'Alcohol' is undefined` validation error가 발생했다. `[1, null]` 입력은 `[ID!]!` 제약에 의해 `ids[1] ... must not be null` validation error가 발생했다. `engName`, `imageUrl`, `regionName`, `korCategory`, `cask`, `abv`, `volume`, `rating`, `totalRatingsCount`, `reviewCount`, `totalPickCount` 전체 field hydration도 정상 확인했다. +- 2026-05-15 Task 5 완료: final verification 중 `check_rule_test`가 먼저 product-api 테스트 fake repository compile error로 실패했다. 원인은 `PicksRepository`, `RatingRepository`, `ReviewRepository`에 Phase 1 stats count 메서드를 추가하면서 product-api 테스트 fake 3곳이 새 인터페이스 시그니처를 구현하지 않은 것이었다. `FakePicksRepository`, `InMemoryRatingRepository`, `InMemoryReviewRepository`에 count/average 메서드를 추가했다. +- 2026-05-15 Task 5 완료: 두 번째 `check_rule_test` 실패는 GraphQL resolver의 `@Controller`가 REST controller ArchUnit 규칙에 포함된 것이 원인이었다. GraphQL handler는 HTTP mapping 기반 REST controller가 아니므로 `ControllerLayerRules`에서 `..graphql..` 패키지를 REST controller naming/mapping/method naming rule 대상에서 제외했다. 또한 `GraphQLCurationAlcoholService` public 메서드에는 프로젝트 rule에 맞춰 `@Transactional(readOnly = true)`를 메서드 레벨로 명시했다. +- 2026-05-15 Task 5 검증 결과: `./gradlew :bottlenote-mono:compileJava` 성공, `./gradlew :bottlenote-product-api:compileJava` 성공, focused tests 3종 성공, `./gradlew check_rule_test` 성공, `git diff -- git.environment-variables/storage/mysql/changelog/` 변경 없음. Phase 1은 종료 가능한 상태로 확인했다. +- 2026-05-15 Phase 2 Task 1 완료: `git.environment-variables/storage/mysql/changelog/schema.mysql.sql`에 `curation_spec`, `curation`, `curation_extension` changeset 3개를 추가했다. SOPS 기반 Liquibase 설정은 임시 파일로만 사용했고 secret 값은 출력하지 않았다. 개발 DB와 운영 DB 모두 `validate` 성공, pending 3건 확인 후 `update` 3건 실행, 세 테이블 생성 확인, 최종 `status --verbose` up to date를 확인했다. +- 2026-05-15 Phase 2 Task 3 추가 완료: Liquibase insert seed 대신 admin-api startup resource sync를 추가했다. `CurationSpecResourceReader`가 `bottlenote-mono/src/main/resources/openapi/curation/*.json` 3종을 읽고, `CurationSpecResourceSyncService`가 `code` 기준으로 `curation_spec`을 생성 또는 갱신한다. admin-api runner는 `curation.spec-sync.enabled=${CURATION_SPEC_SYNC_ENABLED:true}`로 제어된다. 검증: `./gradlew :bottlenote-mono:compileJava :bottlenote-admin-api:compileKotlin :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationSpecResourceSyncServiceTest'` 성공, `./gradlew check_rule_test` 성공. +- 2026-05-15 Phase 2 Task 2 완료: `bottlenote-mono`에 curation v2 Entity, domain repository interface, JPA repository, `CurationV2Service`, focused fake repository와 unit test를 추가했다. `request_spec`, `response_spec`, `payload` JSON 컬럼은 기존 의존성인 Hypersistence `JsonType`으로 매핑했다. `check_rule_test`에서 서비스 메서드 파라미터 5개 제한과 DTO 패키지/네이밍 규칙이 먼저 실패해 service 입력을 `dto.request`의 `*Request` record로 묶었다. 검증: `./gradlew :bottlenote-mono:compileJava :bottlenote-mono:test --tests 'app.bottlenote.curation.service.CurationV2ServiceTest' check_rule_test` 성공, focused 테스트 4개 통과. +- 2026-05-15 Phase 2 Task 3 완료: 기존 Admin `/curations` endpoint는 유지하고, 신규 spec 기반 Admin surface를 ~~`/spec-based-curations`와 `/curation-specs`~~ `/v2/curations`와 `/v2/curation-specs`로 추가했다. ~~admin-api의 context-path가 이미 `/admin/api/v1`이므로 컨트롤러 path에 `/v2` prefix를 붙이지 않는다.~~ 2026-05-18 정정: admin-api context-path는 `/admin/api`로 낮추고 legacy Admin API만 `/v1` prefix를 중앙 적용한다. 스펙 API는 목록/상세를 제공한다. 등록/수정은 `specId`로 `curation_spec`을 조회하고, `imageUrls` 1~3장을 정규화해 첫 번째 이미지를 `cover_image_url`에 저장하며, payload는 `curation_extension.payload`에 저장한다. 새 의존성 없이 requestSpec의 required field와 payload 크기를 검증하는 `CurationPayloadValidator`를 추가했다. 검증: `./gradlew :bottlenote-mono:test --tests 'app.bottlenote.curation.service.AdminSpecBasedCurationServiceTest' :bottlenote-admin-api:test --tests 'app.docs.curation.AdminSpecBasedCurationControllerDocsTest'` 성공, service 테스트 5개와 RestDocs 테스트 6개 통과. `./gradlew check_rule_test` 성공. +- 2026-05-15 Phase 3 Task 1 완료: Product v2 조회 API `/api/v2/curations`, `/api/v2/curations/{curationId}`를 추가하고, `responseSpec.x-graphql` 기반 materializer를 도입했다. materializer는 GraphQL query/variables를 responseSpec에서 만들고, Spring GraphQL executor를 통해 내부 실행한 뒤 payload의 `stats` 위치에 병합한다. root array와 `payloadPath = $.alcohols` object payload를 모두 지원하며, `MANUAL` 또는 `alcoholId = null` 항목은 stats를 null로 둔다. `CurationPayloadValidator`는 requestSpec/responseSpec 공통 검증기로 확장해 required, enum, type, minLength/maxLength, minItems/maxItems, minimum/maximum을 검증한다. 검증: focused 테스트 13개 통과, `./gradlew :bottlenote-product-api:compileJava` 성공, `./gradlew check_rule_test` 성공. +- 2026-05-15 Phase 3 Task 2 완료: Product v2 목록/상세 RestDocs 테스트를 추가했고, `curation/v2/list`, `curation/v2/detail` 스니펫이 생성되는 것을 확인했다. `./gradlew :bottlenote-product-api:asciidoctor check_rule_test` 성공으로 `product-api.html` 생성과 rule test 통과를 확인했다. runtime smoke는 `.env` 기반 product-api를 8080에서 기동해 `GET /api/v2/curations` HTTP 200을 확인했다. 개발 DB에 active spec 기반 큐레이션이 0건이어서 `CODEX_RUNTIME_SMOKE_20260515` 임시 row를 생성한 뒤 `GET /api/v2/curations` HTTP 200, `GET /api/v2/curations/1` HTTP 200, list count=1, detail spec=`RECOMMENDED_WHISKY`, payload_count=2, BOTTLE_NOTE stats keys=`rating,reviewCount,totalPickCount,totalRatingsCount`, MANUAL stats null을 확인했다. 검증 후 임시 row는 삭제했고 삭제 확인 count=0, bootRun 프로세스도 종료했다. +- 2026-05-17 Copilot review 대응 완료: GraphiQL default/local 비활성화, `/graphql`/`/graphiql`/`/graphiql/**` denyAll 차단, GraphQL stats field N+1 제거를 위한 `@BatchMapping` + `IN ... GROUP BY` 집계, Curation create/update Bean Validation enum message 수정을 반영했다. Copilot review thread 4건은 코드 확인 후 GitHub에서 resolved 처리했다. 검증: `/verify full` 범위로 compile, rule, unit, build, integration, admin integration 모두 성공. +- 2026-05-17 Product V2 Runtime Hardening 완료: Product v2 목록/상세에 `isActive = true`와 `exposureStartDate <= today <= exposureEndDate` 정책을 적용했다. `exposureStartDate` 또는 `exposureEndDate`가 null이면 열린 구간으로 처리한다. GraphQL executor가 null response 또는 execution errors를 반환하면 부분 응답을 만들지 않고 `CURATION_GRAPHQL_EXECUTION_FAILED`로 fail-closed 처리한다. responseSpec 검증 실패 시 내부 로그에 `curationId`, `specCode`, validator error path를 남긴다. 검증: `./gradlew :bottlenote-mono:compileJava :bottlenote-product-api:compileJava :bottlenote-mono:compileTestJava :bottlenote-product-api:compileTestJava` 성공, focused unit/integration + `check_rule_test` 성공. +- 2026-05-18 리팩토링 완료: `CurationV2Service`와 `CurationSpecCreateRequest`를 제거해 검증 없는 운영 저장 경로를 닫았다. 단위 테스트의 spec/curation 생성은 `CurationFixtureFactory`로 이동했고, 실제 Admin 저장 경로는 `AdminSpecBasedCurationService`로 단일화했다. 검증: `./gradlew :bottlenote-mono:compileJava :bottlenote-mono:compileTestJava` 성공, focused curation unit 13개 성공, `./gradlew check_rule_test` 성공, `./gradlew unit_test` 성공. +- 2026-05-18 GraphQL prefix 리팩토링 완료: GraphQL 관련 클래스명을 `GraphQL*` prefix로 통일하고, annotation 없는 단건 Alcohol field resolver/service 메서드 4개를 제거해 batch mapping 경로만 남겼다. 검증: `./gradlew :bottlenote-mono:compileJava :bottlenote-mono:compileTestJava` 성공, focused GraphQL/curation unit 9개 성공, `./gradlew check_rule_test` 성공. + +## Current Decision Summary + +- Phase 1의 GraphQL boundary는 외부 공개 curation endpoint가 아니라 서버 내부 hydration 기반으로 둔다. +- 현재 SDL은 `Query.alcohols(ids: [ID!]!)`와 `Alcohol` 표시/stats field만 노출한다. +- GraphQL 관련 구현 클래스명은 화면 정렬과 검색 일관성을 위해 `GraphQL*` prefix를 사용한다. +- 사용자별 원본 목록 field(`picks`, `reviews`, `ratings`)는 SDL에 노출하지 않는다. +- 없는 alcohol id는 전체 요청 실패가 아니라 결과에서 제외한다. +- 중복 alcohol id는 resolver service에서 제거하며, 반환 순서는 최초 요청 순서를 따른다. +- `MANUAL` 항목처럼 `alcoholId = null`인 payload는 후속 Product hydration 단계에서 GraphQL 조회 대상으로 넘기지 않는다. +- 기존 `curation_keyword`, `curation_keyword_alcohol_ids`, 기존 curation endpoint는 유지한다. +- 신규 schema 변경은 Phase 2 Task 1에서 `git.environment-variables/storage/mysql/changelog/` 아래 changelog로 추가했고, 개발 DB와 운영 DB에 Liquibase CLI로 적용했다. +- Phase 2 Task 3에서 Admin spec 기반 endpoint를 추가했다. ~~최종 URL은 admin-api context-path를 포함해 `/admin/api/v1/curation-specs`, `/admin/api/v1/spec-based-curations`다.~~ 정정: 2026-05-18 기준 최종 URL은 `/admin/api/v2/curation-specs`, `/admin/api/v2/curations`다. +- Phase 3 Task 1에서 Product spec 기반 endpoint를 추가했다. 최종 URL은 `/api/v2/curations`, `/api/v2/curations/{curationId}`다. +- 2026-05-17 기준 Product v2 endpoint는 노출 기간 필터를 적용한다. `isActive = true`이고 오늘 날짜가 노출 시작/종료일 사이에 있는 큐레이션만 목록/상세에서 조회된다. null 노출일은 열린 구간으로 본다. +- GraphQL hydration 실패는 Product v2 상세 응답에서 부분 성공으로 처리하지 않는다. 내부 로그를 남기고 500 계열 `CURATION_GRAPHQL_EXECUTION_FAILED`로 닫는다. + +## Next Work + +### Next: Release Verification + +Product V2 Runtime Hardening까지 이번 브랜치에 반영했다. 다음 작업은 배포 전 검증과 PR 상태 확인이다. + +- CI 결과를 확인한다. +- 필요하면 `.env` 기반 product-api runtime smoke를 한 번 더 수행한다. +- 기존 curation keyword endpoint는 계속 유지한다.