cc-codeconductor 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +319 -0
- package/dist/index.js +12741 -0
- package/package.json +51 -0
- package/policy.yml +124 -0
- package/presets/claude/CLAUDE.md +598 -0
- package/presets/claude/commands/cc/feature.md +115 -0
- package/presets/claude/commands/cc/fix.md +121 -0
- package/presets/claude/commands/cc/refactor.md +148 -0
- package/presets/claude/commands/cc/review.md +126 -0
- package/presets/claude/commands/cc/tdd-cycle.md +226 -0
- package/presets/claude/commands/cc/test-plan.md +138 -0
- package/presets/claude/settings.json +37 -0
- package/presets/claude/skills/api-versioning/SKILL.md +389 -0
- package/presets/claude/skills/django-orm/SKILL.md +455 -0
- package/presets/claude/skills/django-testing/SKILL.md +409 -0
- package/presets/claude/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/claude/skills/python/SKILL.md +606 -0
- package/presets/claude/skills/python-django-stack/SKILL.md +492 -0
- package/presets/claude/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/claude/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/claude/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/claude/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/claude/skills/testing-strategy/SKILL.md +479 -0
- package/presets/codex/AGENTS.md +883 -0
- package/presets/codex/README.md +102 -0
- package/presets/codex/skills/api-versioning/SKILL.md +389 -0
- package/presets/codex/skills/django-orm/SKILL.md +455 -0
- package/presets/codex/skills/django-testing/SKILL.md +409 -0
- package/presets/codex/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/codex/skills/python/SKILL.md +606 -0
- package/presets/codex/skills/python-django-stack/SKILL.md +492 -0
- package/presets/codex/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/codex/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/codex/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/codex/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/codex/skills/testing-strategy/SKILL.md +479 -0
- package/presets/opencode/README.md +191 -0
- package/presets/opencode/agents/architect.md +133 -0
- package/presets/opencode/agents/docs.md +113 -0
- package/presets/opencode/agents/implementer.md +116 -0
- package/presets/opencode/agents/orchestrator.md +248 -0
- package/presets/opencode/agents/repo-explorer.md +119 -0
- package/presets/opencode/agents/reviewer.md +130 -0
- package/presets/opencode/agents/task-coach.md +103 -0
- package/presets/opencode/agents/tester.md +269 -0
- package/presets/opencode/commands/cc-feature.md +114 -0
- package/presets/opencode/commands/cc-fix.md +123 -0
- package/presets/opencode/commands/cc-refactor.md +148 -0
- package/presets/opencode/commands/cc-review.md +141 -0
- package/presets/opencode/commands/cc-tdd-cycle.md +225 -0
- package/presets/opencode/commands/cc-test-plan.md +144 -0
- package/presets/opencode/opencode.jsonc +89 -0
- package/presets/opencode/prompts/v0.1.0/architect.md +213 -0
- package/presets/opencode/prompts/v0.1.0/docs.md +181 -0
- package/presets/opencode/prompts/v0.1.0/implementer.md +154 -0
- package/presets/opencode/prompts/v0.1.0/orchestrator.md +169 -0
- package/presets/opencode/prompts/v0.1.0/repo-explorer.md +102 -0
- package/presets/opencode/prompts/v0.1.0/reviewer.md +183 -0
- package/presets/opencode/prompts/v0.1.0/task-coach.md +142 -0
- package/presets/opencode/prompts/v0.1.0/tester.md +160 -0
- package/presets/opencode/prompts/v0.2.0/architect.md +219 -0
- package/presets/opencode/prompts/v0.2.0/docs.md +187 -0
- package/presets/opencode/prompts/v0.2.0/implementer.md +160 -0
- package/presets/opencode/prompts/v0.2.0/orchestrator.md +238 -0
- package/presets/opencode/prompts/v0.2.0/repo-explorer.md +108 -0
- package/presets/opencode/prompts/v0.2.0/reviewer.md +190 -0
- package/presets/opencode/prompts/v0.2.0/task-coach.md +153 -0
- package/presets/opencode/prompts/v0.2.0/tester.md +249 -0
- package/presets/opencode/skills/api-versioning/SKILL.md +388 -0
- package/presets/opencode/skills/astro/SKILL.md +313 -0
- package/presets/opencode/skills/code-review/SKILL.md +202 -0
- package/presets/opencode/skills/django-orm/SKILL.md +455 -0
- package/presets/opencode/skills/django-testing/SKILL.md +408 -0
- package/presets/opencode/skills/django-uv/SKILL.md +400 -0
- package/presets/opencode/skills/jpa-postgres/SKILL.md +617 -0
- package/presets/opencode/skills/nextjs-typescript/SKILL.md +385 -0
- package/presets/opencode/skills/python/SKILL.md +605 -0
- package/presets/opencode/skills/python-django-stack/SKILL.md +491 -0
- package/presets/opencode/skills/python-fastapi-stack/SKILL.md +456 -0
- package/presets/opencode/skills/security/SKILL.md +376 -0
- package/presets/opencode/skills/spring-boot-feature/SKILL.md +557 -0
- package/presets/opencode/skills/spring-boot-kotlin/SKILL.md +399 -0
- package/presets/opencode/skills/spring-boot-testing-strategy/SKILL.md +470 -0
- package/presets/opencode/skills/sqlalchemy/SKILL.md +465 -0
- package/presets/opencode/skills/testing-tdd/SKILL.md +586 -0
- package/src/presets/council/council.yml +59 -0
- package/src/presets/manifests/claude.yml +23 -0
- package/src/presets/manifests/codex.yml +12 -0
- package/src/presets/manifests/opencode.yml +18 -0
- package/src/presets/models/claude.yml +37 -0
- package/src/presets/models/codex.yml +38 -0
- package/src/presets/models/opencode.yml +37 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: spring-boot-feature
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
name: Spring Boot Feature Creation
|
|
5
|
+
description:
|
|
6
|
+
Guides the creation of complete Spring Boot features following a structured,
|
|
7
|
+
layer-by-layer workflow entity, repository, service, controller, and tests.
|
|
8
|
+
Applies Kotlin idioms, Bean Validation, and MockK test patterns throughout.
|
|
9
|
+
|
|
10
|
+
compatibility:
|
|
11
|
+
tools: [claude, codex, opencode]
|
|
12
|
+
stacks:
|
|
13
|
+
languages: [kotlin]
|
|
14
|
+
frameworks: [spring-boot, spring-mvc, spring-data-jpa, mockk, junit5]
|
|
15
|
+
databases: [postgresql, h2]
|
|
16
|
+
|
|
17
|
+
risk:
|
|
18
|
+
level: medium
|
|
19
|
+
can_execute_shell: false
|
|
20
|
+
can_modify_files: true
|
|
21
|
+
requires_network: false
|
|
22
|
+
|
|
23
|
+
inputs:
|
|
24
|
+
- feature description
|
|
25
|
+
- existing domain context (if any)
|
|
26
|
+
- build.gradle.kts
|
|
27
|
+
- application.yml
|
|
28
|
+
|
|
29
|
+
outputs:
|
|
30
|
+
- domain entity
|
|
31
|
+
- JPA repository interface
|
|
32
|
+
- service class with business logic
|
|
33
|
+
- REST controller with validations
|
|
34
|
+
- request/response DTOs
|
|
35
|
+
- unit tests (MockK)
|
|
36
|
+
- controller integration tests (MockMvc)
|
|
37
|
+
|
|
38
|
+
quality:
|
|
39
|
+
reviewed_by: codeconductor-core
|
|
40
|
+
version: 0.1.0
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
# Spring Boot Feature Creation
|
|
44
|
+
|
|
45
|
+
When asked to create a feature, follow these steps **in order**. Do not skip
|
|
46
|
+
steps or combine layers. Each layer has a single responsibility.
|
|
47
|
+
|
|
48
|
+
**When to ask questions:** Only ask when there is genuine ambiguity in business
|
|
49
|
+
logic — for example, what happens when a duplicate is found, or whether soft
|
|
50
|
+
delete is required. Do not ask about technical choices (naming, package
|
|
51
|
+
structure, framework configuration) — apply the conventions in this skill.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Step 1 — Entity + Repository
|
|
56
|
+
|
|
57
|
+
Create the JPA entity and its repository before any other layer.
|
|
58
|
+
|
|
59
|
+
### Entity
|
|
60
|
+
|
|
61
|
+
```kotlin
|
|
62
|
+
@Entity
|
|
63
|
+
@Table(
|
|
64
|
+
name = "orders",
|
|
65
|
+
indexes = [Index(columnList = "customer_id")]
|
|
66
|
+
)
|
|
67
|
+
class Order(
|
|
68
|
+
@Id
|
|
69
|
+
@GeneratedValue(strategy = GenerationType.UUID)
|
|
70
|
+
val id: UUID = UUID.randomUUID(),
|
|
71
|
+
|
|
72
|
+
@Column(name = "customer_id", nullable = false)
|
|
73
|
+
val customerId: UUID,
|
|
74
|
+
|
|
75
|
+
@Column(name = "status", nullable = false)
|
|
76
|
+
@Enumerated(EnumType.STRING)
|
|
77
|
+
var status: OrderStatus = OrderStatus.PENDING,
|
|
78
|
+
|
|
79
|
+
@Column(name = "total_amount", nullable = false)
|
|
80
|
+
var totalAmount: BigDecimal,
|
|
81
|
+
|
|
82
|
+
@Column(name = "created_at", nullable = false, updatable = false)
|
|
83
|
+
@CreatedDate
|
|
84
|
+
val createdAt: Instant = Instant.now(),
|
|
85
|
+
|
|
86
|
+
@Column(name = "updated_at", nullable = false)
|
|
87
|
+
@LastModifiedDate
|
|
88
|
+
var updatedAt: Instant = Instant.now()
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
enum class OrderStatus { PENDING, CONFIRMED, CANCELLED }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Rules:
|
|
95
|
+
|
|
96
|
+
- `@Table` with explicit `name` — never rely on inferred table names
|
|
97
|
+
- `@Column(nullable = false)` always explicit for non-nullable fields
|
|
98
|
+
- UUID primary keys with `GenerationType.UUID`
|
|
99
|
+
- Enums stored as `STRING`, not `ORDINAL`
|
|
100
|
+
- Include `createdAt` and `updatedAt` on every entity
|
|
101
|
+
- Never use `data class` for JPA entities — Hibernate requires a no-arg
|
|
102
|
+
constructor and mutable state; use `class`
|
|
103
|
+
- `equals`/`hashCode` based on `id` only, or omit (reference equality is safe
|
|
104
|
+
for JPA entities when managed by the same `EntityManager`)
|
|
105
|
+
|
|
106
|
+
### Repository
|
|
107
|
+
|
|
108
|
+
```kotlin
|
|
109
|
+
@Repository
|
|
110
|
+
interface OrderRepository : JpaRepository<Order, UUID> {
|
|
111
|
+
fun findByCustomerId(customerId: UUID): List<Order>
|
|
112
|
+
fun findByCustomerIdAndStatus(customerId: UUID, status: OrderStatus): List<Order>
|
|
113
|
+
fun existsByCustomerIdAndStatus(customerId: UUID, status: OrderStatus): Boolean
|
|
114
|
+
|
|
115
|
+
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.createdAt < :before")
|
|
116
|
+
fun findExpiredPending(
|
|
117
|
+
@Param("status") status: OrderStatus,
|
|
118
|
+
@Param("before") before: Instant
|
|
119
|
+
): List<Order>
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Rules:
|
|
124
|
+
|
|
125
|
+
- Extend `JpaRepository<Entity, IdType>`
|
|
126
|
+
- Use derived query method names for simple conditions
|
|
127
|
+
- Use `@Query` with JPQL for joins, multi-condition queries, or aggregations
|
|
128
|
+
- Use native SQL only when JPQL cannot express it — add `nativeQuery = true`
|
|
129
|
+
- Never add `@Transactional` to repository methods — Spring Data handles it
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Step 2 — Service
|
|
134
|
+
|
|
135
|
+
The service is the single owner of all business logic. No logic in controllers,
|
|
136
|
+
no logic in repositories.
|
|
137
|
+
|
|
138
|
+
```kotlin
|
|
139
|
+
@Service
|
|
140
|
+
@Transactional
|
|
141
|
+
class OrderService(
|
|
142
|
+
private val orderRepository: OrderRepository,
|
|
143
|
+
private val customerRepository: CustomerRepository
|
|
144
|
+
) {
|
|
145
|
+
|
|
146
|
+
@Transactional(readOnly = true)
|
|
147
|
+
fun getById(id: UUID): Order =
|
|
148
|
+
orderRepository.findById(id).orElseThrow { OrderNotFoundException(id) }
|
|
149
|
+
|
|
150
|
+
@Transactional(readOnly = true)
|
|
151
|
+
fun listByCustomer(customerId: UUID): List<Order> =
|
|
152
|
+
orderRepository.findByCustomerId(customerId)
|
|
153
|
+
|
|
154
|
+
fun create(customerId: UUID, request: CreateOrderRequest): Order {
|
|
155
|
+
if (!customerRepository.existsById(customerId)) {
|
|
156
|
+
throw CustomerNotFoundException(customerId)
|
|
157
|
+
}
|
|
158
|
+
val order = Order(
|
|
159
|
+
customerId = customerId,
|
|
160
|
+
totalAmount = request.totalAmount
|
|
161
|
+
)
|
|
162
|
+
return orderRepository.save(order)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun cancel(id: UUID): Order {
|
|
166
|
+
val order = getById(id)
|
|
167
|
+
if (order.status == OrderStatus.CANCELLED) {
|
|
168
|
+
throw OrderAlreadyCancelledException(id)
|
|
169
|
+
}
|
|
170
|
+
order.status = OrderStatus.CANCELLED
|
|
171
|
+
return orderRepository.save(order)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Rules:
|
|
177
|
+
|
|
178
|
+
- `@Transactional` at class level — applies to all public methods
|
|
179
|
+
- `readOnly = true` on read-only methods — prevents dirty checking, faster
|
|
180
|
+
- Never put `@Transactional` on private methods — Spring proxies cannot
|
|
181
|
+
intercept them
|
|
182
|
+
- Throw typed domain exceptions — never `RuntimeException` or `Exception`
|
|
183
|
+
directly
|
|
184
|
+
- Never inject repositories into controllers — only services cross that boundary
|
|
185
|
+
|
|
186
|
+
### Domain Exceptions
|
|
187
|
+
|
|
188
|
+
Define typed exceptions per feature:
|
|
189
|
+
|
|
190
|
+
```kotlin
|
|
191
|
+
class OrderNotFoundException(id: UUID) :
|
|
192
|
+
RuntimeException("Order not found: $id")
|
|
193
|
+
|
|
194
|
+
class OrderAlreadyCancelledException(id: UUID) :
|
|
195
|
+
RuntimeException("Order already cancelled: $id")
|
|
196
|
+
|
|
197
|
+
class CustomerNotFoundException(id: UUID) :
|
|
198
|
+
RuntimeException("Customer not found: $id")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Step 3 — Controller + DTOs
|
|
204
|
+
|
|
205
|
+
The controller translates HTTP into service calls. No business logic here.
|
|
206
|
+
|
|
207
|
+
### DTOs
|
|
208
|
+
|
|
209
|
+
Define request and response data classes with Bean Validation annotations:
|
|
210
|
+
|
|
211
|
+
```kotlin
|
|
212
|
+
data class CreateOrderRequest(
|
|
213
|
+
@field:NotNull(message = "totalAmount is required")
|
|
214
|
+
@field:DecimalMin(value = "0.01", message = "totalAmount must be greater than zero")
|
|
215
|
+
val totalAmount: BigDecimal
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
data class OrderResponse(
|
|
219
|
+
val id: UUID,
|
|
220
|
+
val customerId: UUID,
|
|
221
|
+
val status: String,
|
|
222
|
+
val totalAmount: BigDecimal,
|
|
223
|
+
val createdAt: Instant
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
fun Order.toResponse(): OrderResponse = OrderResponse(
|
|
227
|
+
id = id,
|
|
228
|
+
customerId = customerId,
|
|
229
|
+
status = status.name,
|
|
230
|
+
totalAmount = totalAmount,
|
|
231
|
+
createdAt = createdAt
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Rules for DTOs:
|
|
236
|
+
|
|
237
|
+
- Use `data class` — immutable, no `@Entity`
|
|
238
|
+
- Use `@field:` prefix on validation annotations — Kotlin applies annotations to
|
|
239
|
+
the property by default, not the backing field; Spring reads the field
|
|
240
|
+
- Define a `toResponse()` extension function on the entity — do not expose
|
|
241
|
+
entities directly in responses
|
|
242
|
+
- Never return `@Entity` from a controller method
|
|
243
|
+
|
|
244
|
+
### Controller
|
|
245
|
+
|
|
246
|
+
```kotlin
|
|
247
|
+
@RestController
|
|
248
|
+
@RequestMapping("/api/v1/customers/{customerId}/orders")
|
|
249
|
+
class OrderController(private val orderService: OrderService) {
|
|
250
|
+
|
|
251
|
+
@GetMapping
|
|
252
|
+
fun list(@PathVariable customerId: UUID): ResponseEntity<List<OrderResponse>> =
|
|
253
|
+
ResponseEntity.ok(orderService.listByCustomer(customerId).map { it.toResponse() })
|
|
254
|
+
|
|
255
|
+
@GetMapping("/{id}")
|
|
256
|
+
fun get(
|
|
257
|
+
@PathVariable customerId: UUID,
|
|
258
|
+
@PathVariable id: UUID
|
|
259
|
+
): ResponseEntity<OrderResponse> =
|
|
260
|
+
ResponseEntity.ok(orderService.getById(id).toResponse())
|
|
261
|
+
|
|
262
|
+
@PostMapping
|
|
263
|
+
fun create(
|
|
264
|
+
@PathVariable customerId: UUID,
|
|
265
|
+
@Valid @RequestBody request: CreateOrderRequest
|
|
266
|
+
): ResponseEntity<OrderResponse> {
|
|
267
|
+
val order = orderService.create(customerId, request)
|
|
268
|
+
return ResponseEntity.status(HttpStatus.CREATED).body(order.toResponse())
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@DeleteMapping("/{id}/cancel")
|
|
272
|
+
fun cancel(
|
|
273
|
+
@PathVariable customerId: UUID,
|
|
274
|
+
@PathVariable id: UUID
|
|
275
|
+
): ResponseEntity<OrderResponse> =
|
|
276
|
+
ResponseEntity.ok(orderService.cancel(id).toResponse())
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Rules:
|
|
281
|
+
|
|
282
|
+
- One controller per feature
|
|
283
|
+
- `@Valid` on every `@RequestBody` parameter
|
|
284
|
+
- Return `ResponseEntity<T>` — explicit HTTP status control
|
|
285
|
+
- No try/catch in controllers — use `GlobalExceptionHandler`
|
|
286
|
+
- Map entities to DTOs before returning — never return the entity itself
|
|
287
|
+
|
|
288
|
+
### Global Exception Handler
|
|
289
|
+
|
|
290
|
+
Add new exception mappings to the existing `GlobalExceptionHandler`. Do not
|
|
291
|
+
create a second one.
|
|
292
|
+
|
|
293
|
+
```kotlin
|
|
294
|
+
@ControllerAdvice
|
|
295
|
+
class GlobalExceptionHandler {
|
|
296
|
+
|
|
297
|
+
@ExceptionHandler(OrderNotFoundException::class)
|
|
298
|
+
fun handleOrderNotFound(ex: OrderNotFoundException): ResponseEntity<ErrorResponse> =
|
|
299
|
+
ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
300
|
+
.body(ErrorResponse(error = ex.message ?: "Order not found", code = "ORDER_NOT_FOUND"))
|
|
301
|
+
|
|
302
|
+
@ExceptionHandler(OrderAlreadyCancelledException::class)
|
|
303
|
+
fun handleAlreadyCancelled(ex: OrderAlreadyCancelledException): ResponseEntity<ErrorResponse> =
|
|
304
|
+
ResponseEntity.status(HttpStatus.CONFLICT)
|
|
305
|
+
.body(ErrorResponse(error = ex.message ?: "Order already cancelled", code = "ORDER_ALREADY_CANCELLED"))
|
|
306
|
+
|
|
307
|
+
@ExceptionHandler(MethodArgumentNotValidException::class)
|
|
308
|
+
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
|
|
309
|
+
val message = ex.bindingResult.fieldErrors
|
|
310
|
+
.joinToString(", ") { "${it.field}: ${it.defaultMessage}" }
|
|
311
|
+
return ResponseEntity.badRequest()
|
|
312
|
+
.body(ErrorResponse(error = message, code = "VALIDATION_ERROR"))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
data class ErrorResponse(val error: String, val code: String)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Step 4 — Tests with MockK
|
|
322
|
+
|
|
323
|
+
Write three test classes per feature: unit tests for the service, controller
|
|
324
|
+
tests with MockMvc, and repository tests with `@DataJpaTest`.
|
|
325
|
+
|
|
326
|
+
### Service Unit Tests (MockK)
|
|
327
|
+
|
|
328
|
+
```kotlin
|
|
329
|
+
@ExtendWith(MockKExtension::class)
|
|
330
|
+
class OrderServiceTest {
|
|
331
|
+
|
|
332
|
+
@MockK
|
|
333
|
+
private lateinit var orderRepository: OrderRepository
|
|
334
|
+
|
|
335
|
+
@MockK
|
|
336
|
+
private lateinit var customerRepository: CustomerRepository
|
|
337
|
+
|
|
338
|
+
private lateinit var orderService: OrderService
|
|
339
|
+
|
|
340
|
+
@BeforeEach
|
|
341
|
+
fun setUp() {
|
|
342
|
+
orderService = OrderService(orderRepository, customerRepository)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@Nested
|
|
346
|
+
inner class Create {
|
|
347
|
+
|
|
348
|
+
@Test
|
|
349
|
+
fun `should create order when customer exists`() {
|
|
350
|
+
// Arrange
|
|
351
|
+
val customerId = UUID.randomUUID()
|
|
352
|
+
val request = CreateOrderRequest(totalAmount = BigDecimal("99.99"))
|
|
353
|
+
val savedOrder = Order(customerId = customerId, totalAmount = request.totalAmount)
|
|
354
|
+
every { customerRepository.existsById(customerId) } returns true
|
|
355
|
+
every { orderRepository.save(any()) } returns savedOrder
|
|
356
|
+
|
|
357
|
+
// Act
|
|
358
|
+
val result = orderService.create(customerId, request)
|
|
359
|
+
|
|
360
|
+
// Assert
|
|
361
|
+
assertThat(result.customerId).isEqualTo(customerId)
|
|
362
|
+
assertThat(result.totalAmount).isEqualByComparingTo(BigDecimal("99.99"))
|
|
363
|
+
verify(exactly = 1) { orderRepository.save(any()) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
@Test
|
|
367
|
+
fun `should throw CustomerNotFoundException when customer does not exist`() {
|
|
368
|
+
// Arrange
|
|
369
|
+
val customerId = UUID.randomUUID()
|
|
370
|
+
every { customerRepository.existsById(customerId) } returns false
|
|
371
|
+
|
|
372
|
+
// Act + Assert
|
|
373
|
+
assertThrows<CustomerNotFoundException> {
|
|
374
|
+
orderService.create(customerId, CreateOrderRequest(totalAmount = BigDecimal("10.00")))
|
|
375
|
+
}
|
|
376
|
+
verify(exactly = 0) { orderRepository.save(any()) }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@Nested
|
|
381
|
+
inner class Cancel {
|
|
382
|
+
|
|
383
|
+
@Test
|
|
384
|
+
fun `should cancel order when status is PENDING`() {
|
|
385
|
+
// Arrange
|
|
386
|
+
val order = Order(customerId = UUID.randomUUID(), totalAmount = BigDecimal("50.00"))
|
|
387
|
+
every { orderRepository.findById(order.id) } returns Optional.of(order)
|
|
388
|
+
every { orderRepository.save(any()) } answers { firstArg() }
|
|
389
|
+
|
|
390
|
+
// Act
|
|
391
|
+
val result = orderService.cancel(order.id)
|
|
392
|
+
|
|
393
|
+
// Assert
|
|
394
|
+
assertThat(result.status).isEqualTo(OrderStatus.CANCELLED)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@Test
|
|
398
|
+
fun `should throw OrderAlreadyCancelledException when order is already cancelled`() {
|
|
399
|
+
// Arrange
|
|
400
|
+
val order = Order(
|
|
401
|
+
customerId = UUID.randomUUID(),
|
|
402
|
+
totalAmount = BigDecimal("50.00"),
|
|
403
|
+
status = OrderStatus.CANCELLED
|
|
404
|
+
)
|
|
405
|
+
every { orderRepository.findById(order.id) } returns Optional.of(order)
|
|
406
|
+
|
|
407
|
+
// Act + Assert
|
|
408
|
+
assertThrows<OrderAlreadyCancelledException> {
|
|
409
|
+
orderService.cancel(order.id)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Controller Tests (MockMvc)
|
|
417
|
+
|
|
418
|
+
```kotlin
|
|
419
|
+
@SpringBootTest
|
|
420
|
+
@AutoConfigureMockMvc
|
|
421
|
+
class OrderControllerTest {
|
|
422
|
+
|
|
423
|
+
@Autowired
|
|
424
|
+
private lateinit var mockMvc: MockMvc
|
|
425
|
+
|
|
426
|
+
@Autowired
|
|
427
|
+
private lateinit var objectMapper: ObjectMapper
|
|
428
|
+
|
|
429
|
+
@MockkBean
|
|
430
|
+
private lateinit var orderService: OrderService
|
|
431
|
+
|
|
432
|
+
@Test
|
|
433
|
+
fun `should return 201 and created order when request is valid`() {
|
|
434
|
+
// Arrange
|
|
435
|
+
val customerId = UUID.randomUUID()
|
|
436
|
+
val order = Order(customerId = customerId, totalAmount = BigDecimal("99.99"))
|
|
437
|
+
every { orderService.create(customerId, any()) } returns order
|
|
438
|
+
|
|
439
|
+
val body = mapOf("totalAmount" to "99.99")
|
|
440
|
+
|
|
441
|
+
// Act + Assert
|
|
442
|
+
mockMvc.perform(
|
|
443
|
+
post("/api/v1/customers/$customerId/orders")
|
|
444
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
445
|
+
.content(objectMapper.writeValueAsString(body))
|
|
446
|
+
)
|
|
447
|
+
.andExpect(status().isCreated)
|
|
448
|
+
.andExpect(jsonPath("$.customerId").value(customerId.toString()))
|
|
449
|
+
.andExpect(jsonPath("$.status").value("PENDING"))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
@Test
|
|
453
|
+
fun `should return 400 when totalAmount is missing`() {
|
|
454
|
+
mockMvc.perform(
|
|
455
|
+
post("/api/v1/customers/${UUID.randomUUID()}/orders")
|
|
456
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
457
|
+
.content("{}")
|
|
458
|
+
)
|
|
459
|
+
.andExpect(status().isBadRequest)
|
|
460
|
+
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@Test
|
|
464
|
+
fun `should return 404 when order is not found`() {
|
|
465
|
+
val customerId = UUID.randomUUID()
|
|
466
|
+
val orderId = UUID.randomUUID()
|
|
467
|
+
every { orderService.getById(orderId) } throws OrderNotFoundException(orderId)
|
|
468
|
+
|
|
469
|
+
mockMvc.perform(get("/api/v1/customers/$customerId/orders/$orderId"))
|
|
470
|
+
.andExpect(status().isNotFound)
|
|
471
|
+
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Repository Tests
|
|
477
|
+
|
|
478
|
+
```kotlin
|
|
479
|
+
@DataJpaTest
|
|
480
|
+
class OrderRepositoryTest {
|
|
481
|
+
|
|
482
|
+
@Autowired
|
|
483
|
+
private lateinit var orderRepository: OrderRepository
|
|
484
|
+
|
|
485
|
+
@Test
|
|
486
|
+
fun `should find orders by customer id`() {
|
|
487
|
+
// Arrange
|
|
488
|
+
val customerId = UUID.randomUUID()
|
|
489
|
+
orderRepository.save(Order(customerId = customerId, totalAmount = BigDecimal("10.00")))
|
|
490
|
+
orderRepository.save(Order(customerId = UUID.randomUUID(), totalAmount = BigDecimal("20.00")))
|
|
491
|
+
|
|
492
|
+
// Act
|
|
493
|
+
val result = orderRepository.findByCustomerId(customerId)
|
|
494
|
+
|
|
495
|
+
// Assert
|
|
496
|
+
assertThat(result).hasSize(1)
|
|
497
|
+
assertThat(result.first().customerId).isEqualTo(customerId)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
MockK rules:
|
|
503
|
+
|
|
504
|
+
- Use `@ExtendWith(MockKExtension::class)` — not `@MockBean` for unit tests
|
|
505
|
+
- Use `@MockkBean` (MockK Spring integration) only in `@SpringBootTest` context
|
|
506
|
+
- `every { }` for stubbing, `verify { }` for verification
|
|
507
|
+
- `answers { firstArg() }` to return the argument passed to `save()`
|
|
508
|
+
- `verify(exactly = 0) { }` to assert a method was never called
|
|
509
|
+
- Never use `relaxed = true` unless you genuinely do not care about any return
|
|
510
|
+
value
|
|
511
|
+
- Test names: `should [expected outcome] when [condition]`
|
|
512
|
+
- Test structure: Arrange / Act / Assert, separated by blank lines
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Step 5 — When to Ask
|
|
517
|
+
|
|
518
|
+
Only pause to ask when there is genuine ambiguity in business logic. Do not ask
|
|
519
|
+
about technical choices — apply the conventions in this skill.
|
|
520
|
+
|
|
521
|
+
**Ask when:**
|
|
522
|
+
|
|
523
|
+
- The feature description does not specify what happens on conflict (duplicate,
|
|
524
|
+
constraint violation, concurrent modification)
|
|
525
|
+
- It is unclear whether delete means hard delete or soft delete
|
|
526
|
+
- Authorization rules are not specified and cannot be inferred from context
|
|
527
|
+
- A domain invariant is referenced but not defined (e.g., "validate the order"
|
|
528
|
+
without specifying which fields or rules)
|
|
529
|
+
|
|
530
|
+
**Do not ask about:**
|
|
531
|
+
|
|
532
|
+
- Package structure — always feature-oriented MVC
|
|
533
|
+
- Naming conventions — follow the existing codebase
|
|
534
|
+
- Whether to use MockK or Mockito — always MockK
|
|
535
|
+
- Whether to add `@Transactional` — always at the service class level
|
|
536
|
+
- Whether to validate at controller or service — always at DTO layer
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## Package Structure
|
|
541
|
+
|
|
542
|
+
Always feature-oriented. One package per feature, not one package per layer.
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
src/main/kotlin/{base-package}/{feature}/
|
|
546
|
+
controller/ # HTTP only — OrderController, DTOs, toResponse() extensions
|
|
547
|
+
service/ # Business logic — OrderService, domain exceptions
|
|
548
|
+
repository/ # Data access — OrderRepository
|
|
549
|
+
domain/ # JPA entities — Order, OrderStatus
|
|
550
|
+
|
|
551
|
+
src/test/kotlin/{base-package}/{feature}/
|
|
552
|
+
controller/ # OrderControllerTest (MockMvc)
|
|
553
|
+
service/ # OrderServiceTest (MockK)
|
|
554
|
+
repository/ # OrderRepositoryTest (@DataJpaTest)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Never create `src/main/kotlin/controllers/`, `src/main/kotlin/services/`, etc.
|
|
558
|
+
Layer-first structure does not scale and breaks feature cohesion.
|