@c0x12c/spartan-ai-toolkit 1.0.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/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +12 -0
- package/README.md +300 -0
- package/VERSION +1 -0
- package/agents/idea-killer.md +72 -0
- package/agents/micronaut-backend-expert.md +45 -0
- package/agents/research-planner.md +70 -0
- package/agents/solution-architect-cto.md +49 -0
- package/bin/cli.js +589 -0
- package/claude-md/00-header.md +39 -0
- package/claude-md/01-core.md +94 -0
- package/claude-md/05-database.md +20 -0
- package/claude-md/11-backend-micronaut.md +36 -0
- package/claude-md/20-frontend-react.md +23 -0
- package/claude-md/30-project-mgmt.md +91 -0
- package/claude-md/40-product.md +36 -0
- package/claude-md/50-ops.md +34 -0
- package/claude-md/60-research.md +75 -0
- package/claude-md/90-footer.md +21 -0
- package/commands/spartan/brainstorm.md +134 -0
- package/commands/spartan/brownfield.md +157 -0
- package/commands/spartan/careful.md +94 -0
- package/commands/spartan/content.md +17 -0
- package/commands/spartan/context-save.md +161 -0
- package/commands/spartan/daily.md +42 -0
- package/commands/spartan/debug.md +156 -0
- package/commands/spartan/deep-dive.md +55 -0
- package/commands/spartan/deploy.md +207 -0
- package/commands/spartan/e2e.md +264 -0
- package/commands/spartan/env-setup.md +166 -0
- package/commands/spartan/fe-review.md +134 -0
- package/commands/spartan/figma-to-code.md +244 -0
- package/commands/spartan/forensics.md +46 -0
- package/commands/spartan/freeze.md +84 -0
- package/commands/spartan/full-run.md +78 -0
- package/commands/spartan/fundraise.md +53 -0
- package/commands/spartan/gsd-upgrade.md +376 -0
- package/commands/spartan/guard.md +42 -0
- package/commands/spartan/init-project.md +178 -0
- package/commands/spartan/interview.md +154 -0
- package/commands/spartan/kickoff.md +52 -0
- package/commands/spartan/kotlin-service.md +109 -0
- package/commands/spartan/lean-canvas.md +222 -0
- package/commands/spartan/map-codebase.md +72 -0
- package/commands/spartan/migration.md +82 -0
- package/commands/spartan/next-app.md +317 -0
- package/commands/spartan/next-feature.md +197 -0
- package/commands/spartan/outreach.md +16 -0
- package/commands/spartan/phase.md +119 -0
- package/commands/spartan/pitch.md +18 -0
- package/commands/spartan/pr-ready.md +200 -0
- package/commands/spartan/project.md +106 -0
- package/commands/spartan/quickplan.md +122 -0
- package/commands/spartan/research.md +19 -0
- package/commands/spartan/review.md +102 -0
- package/commands/spartan/teardown.md +161 -0
- package/commands/spartan/testcontainer.md +97 -0
- package/commands/spartan/think.md +221 -0
- package/commands/spartan/unfreeze.md +13 -0
- package/commands/spartan/update.md +81 -0
- package/commands/spartan/validate.md +193 -0
- package/commands/spartan/workstreams.md +109 -0
- package/commands/spartan/write.md +16 -0
- package/commands/spartan.md +222 -0
- package/frameworks/00-framework-comparison-guide.md +317 -0
- package/frameworks/01-lean-canvas.md +196 -0
- package/frameworks/02-design-sprint.md +304 -0
- package/frameworks/03-foundation-sprint.md +337 -0
- package/frameworks/04-business-model-canvas.md +391 -0
- package/frameworks/05-customer-development.md +426 -0
- package/frameworks/06-jobs-to-be-done.md +358 -0
- package/frameworks/07-mom-test.md +392 -0
- package/frameworks/08-value-proposition-canvas.md +488 -0
- package/frameworks/09-javelin-board.md +428 -0
- package/frameworks/10-build-measure-learn.md +467 -0
- package/frameworks/11-mvp-approaches.md +533 -0
- package/frameworks/think-before-build.md +593 -0
- package/lib/assembler.js +52 -0
- package/lib/packs.js +16 -0
- package/lib/resolver.js +144 -0
- package/lib/resolver.test.js +140 -0
- package/package.json +48 -0
- package/packs/backend-micronaut.yaml +34 -0
- package/packs/backend-nodejs.yaml +15 -0
- package/packs/backend-python.yaml +15 -0
- package/packs/core.yaml +25 -0
- package/packs/database.yaml +21 -0
- package/packs/frontend-react.yaml +23 -0
- package/packs/ops.yaml +16 -0
- package/packs/packs.compiled.json +281 -0
- package/packs/product.yaml +20 -0
- package/packs/project-mgmt.yaml +21 -0
- package/packs/research.yaml +39 -0
- package/packs/shared-backend.yaml +14 -0
- package/rules/backend-micronaut/API_DESIGN.md +250 -0
- package/rules/backend-micronaut/CONTROLLERS.md +755 -0
- package/rules/backend-micronaut/KOTLIN.md +483 -0
- package/rules/backend-micronaut/RETROFIT_PLACEMENT.md +258 -0
- package/rules/backend-micronaut/SERVICES_AND_BEANS.md +673 -0
- package/rules/core/NAMING_CONVENTIONS.md +208 -0
- package/rules/database/ORM_AND_REPO.md +393 -0
- package/rules/database/SCHEMA.md +146 -0
- package/rules/database/TRANSACTIONS.md +311 -0
- package/rules/frontend-react/FRONTEND.md +344 -0
- package/rules/shared-backend/ARCHITECTURE.md +46 -0
- package/skills/api-endpoint-creator/SKILL.md +560 -0
- package/skills/api-endpoint-creator/error-handling-guide.md +244 -0
- package/skills/api-endpoint-creator/examples.md +522 -0
- package/skills/api-endpoint-creator/testing-patterns.md +302 -0
- package/skills/article-writing/SKILL.md +95 -0
- package/skills/backend-api-design/SKILL.md +187 -0
- package/skills/brainstorm/SKILL.md +85 -0
- package/skills/competitive-teardown/SKILL.md +105 -0
- package/skills/content-engine/SKILL.md +101 -0
- package/skills/database-patterns/SKILL.md +145 -0
- package/skills/database-table-creator/SKILL.md +588 -0
- package/skills/database-table-creator/examples.md +552 -0
- package/skills/database-table-creator/migration-template.sql +68 -0
- package/skills/database-table-creator/validation-checklist.md +337 -0
- package/skills/deep-research/SKILL.md +94 -0
- package/skills/idea-validation/SKILL.md +115 -0
- package/skills/investor-materials/SKILL.md +115 -0
- package/skills/investor-outreach/SKILL.md +98 -0
- package/skills/kotlin-best-practices/SKILL.md +145 -0
- package/skills/market-research/SKILL.md +113 -0
- package/skills/security-checklist/SKILL.md +150 -0
- package/skills/startup-pipeline/SKILL.md +125 -0
- package/skills/testing-strategies/SKILL.md +156 -0
- package/skills/ui-ux-pro-max/SKILL.md +377 -0
- package/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/skills/ui-ux-pro-max/data/colors.csv +97 -0
- package/skills/ui-ux-pro-max/data/icons.csv +101 -0
- package/skills/ui-ux-pro-max/data/landing.csv +31 -0
- package/skills/ui-ux-pro-max/data/products.csv +97 -0
- package/skills/ui-ux-pro-max/data/react-performance.csv +45 -0
- package/skills/ui-ux-pro-max/data/stacks/astro.csv +54 -0
- package/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- package/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
- package/skills/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- package/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- package/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- package/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/skills/ui-ux-pro-max/data/stacks/react.csv +54 -0
- package/skills/ui-ux-pro-max/data/stacks/shadcn.csv +61 -0
- package/skills/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- package/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/skills/ui-ux-pro-max/data/styles.csv +68 -0
- package/skills/ui-ux-pro-max/data/typography.csv +58 -0
- package/skills/ui-ux-pro-max/data/ui-reasoning.csv +101 -0
- package/skills/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- package/skills/ui-ux-pro-max/data/web-interface.csv +31 -0
- package/skills/ui-ux-pro-max/scripts/core.py +253 -0
- package/skills/ui-ux-pro-max/scripts/design_system.py +1067 -0
- package/skills/ui-ux-pro-max/scripts/search.py +114 -0
- package/templates/competitor-analysis.md +60 -0
- package/templates/content/AGENT_TEMPLATE.md +47 -0
- package/templates/content/COMMAND_TEMPLATE.md +27 -0
- package/templates/content/RULE_TEMPLATE.md +40 -0
- package/templates/content/SKILL_TEMPLATE.md +41 -0
- package/templates/idea-canvas.md +47 -0
- package/templates/prd-template.md +84 -0
- package/templates/project-readme.md +35 -0
- package/templates/user-interview.md +69 -0
- package/templates/validation-checklist.md +108 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
# Controller Rules
|
|
2
|
+
|
|
3
|
+
> Full guide: use `/api-endpoint-creator` skill
|
|
4
|
+
|
|
5
|
+
## Required Annotations
|
|
6
|
+
|
|
7
|
+
Every controller class MUST have these annotations:
|
|
8
|
+
|
|
9
|
+
```kotlin
|
|
10
|
+
@ExecuteOn(TaskExecutors.IO) // REQUIRED: Enables coroutine suspension
|
|
11
|
+
@Validated // Input validation
|
|
12
|
+
@Controller("/api/v1/...") // Endpoint path
|
|
13
|
+
@Secured(...) // Security rule
|
|
14
|
+
class MyController(
|
|
15
|
+
private val myManager: MyManager // ONLY managers, never repositories
|
|
16
|
+
) {
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Why @ExecuteOn(TaskExecutors.IO) is Required
|
|
20
|
+
|
|
21
|
+
- Controllers use `suspend` functions for async operations
|
|
22
|
+
- Without this annotation, suspend functions may not run right
|
|
23
|
+
- It offloads blocking operations to the IO thread pool
|
|
24
|
+
- Stops blocking the main event loop
|
|
25
|
+
|
|
26
|
+
## Dependency Rules
|
|
27
|
+
|
|
28
|
+
| Allowed | Not Allowed |
|
|
29
|
+
|---------|-------------|
|
|
30
|
+
| Managers (`*Manager`) | Repositories (`*Repository`) |
|
|
31
|
+
| Detectors (`*Detector`) | Database context |
|
|
32
|
+
| - | Direct entity access |
|
|
33
|
+
| - | External API clients |
|
|
34
|
+
|
|
35
|
+
### Example: Correct Controller
|
|
36
|
+
|
|
37
|
+
```kotlin
|
|
38
|
+
@ExecuteOn(TaskExecutors.IO)
|
|
39
|
+
@Controller("/api/v1/admin")
|
|
40
|
+
@Secured(OAuthSecurityRule.ADMIN)
|
|
41
|
+
class ProjectHealthController(
|
|
42
|
+
private val projectHealthManager: ProjectHealthManager, // ✓ Manager
|
|
43
|
+
private val projectRiskDetector: ProjectRiskDetector // ✓ Detector/Manager
|
|
44
|
+
) {
|
|
45
|
+
@Get("/project/health")
|
|
46
|
+
suspend fun getProjectHealth(@QueryValue id: UUID): ProjectHealthResponse {
|
|
47
|
+
return projectHealthManager.computeProjectHealth(id, ...).throwOrValue()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Example: Incorrect Controller (VIOLATION)
|
|
53
|
+
|
|
54
|
+
```kotlin
|
|
55
|
+
// ❌ WRONG - Controller directly uses repositories
|
|
56
|
+
@Controller("/api/v1/admin")
|
|
57
|
+
class ProjectHealthController(
|
|
58
|
+
private val projectHealthManager: ProjectHealthManager,
|
|
59
|
+
private val projectAlertRepository: ProjectAlertRepository, // ❌ NO!
|
|
60
|
+
private val projectRepository: ProjectRepository // ❌ NO!
|
|
61
|
+
) {
|
|
62
|
+
@Get("/project/alerts")
|
|
63
|
+
suspend fun getProjectAlerts(@QueryValue id: UUID): List<ProjectAlertSummary> {
|
|
64
|
+
val alerts = projectAlertRepository.byProjectId(id) // ❌ Direct repo access
|
|
65
|
+
return alerts.map { ProjectAlertSummary.from(it) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Thin Controller Pattern
|
|
71
|
+
|
|
72
|
+
Controllers should ONLY:
|
|
73
|
+
|
|
74
|
+
1. **Parse and validate HTTP input** (query params, body, headers)
|
|
75
|
+
2. **Delegate to managers** for business logic
|
|
76
|
+
3. **Transform manager results** to HTTP responses
|
|
77
|
+
4. **Handle authentication context** (get current user, etc.)
|
|
78
|
+
|
|
79
|
+
Controllers should NEVER:
|
|
80
|
+
|
|
81
|
+
1. Access repositories directly
|
|
82
|
+
2. Contain business logic
|
|
83
|
+
3. Make database queries
|
|
84
|
+
4. Call external APIs
|
|
85
|
+
5. Manage transactions
|
|
86
|
+
6. **Define inline data classes** (see below)
|
|
87
|
+
|
|
88
|
+
```kotlin
|
|
89
|
+
@Get("/project/health")
|
|
90
|
+
suspend fun getProjectHealth(
|
|
91
|
+
@QueryValue id: UUID,
|
|
92
|
+
@QueryValue date: String?,
|
|
93
|
+
@QueryValue window: Int?
|
|
94
|
+
): ProjectHealthResponse {
|
|
95
|
+
// 1. Parse input
|
|
96
|
+
val targetDate = date?.let { LocalDate.parse(it) } ?: LocalDate.now()
|
|
97
|
+
val windowDays = window ?: 7
|
|
98
|
+
|
|
99
|
+
// 2. Delegate to manager and return
|
|
100
|
+
return projectHealthManager.computeProjectHealth(id, targetDate, windowDays).throwOrValue()
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Error Handling Pattern
|
|
105
|
+
|
|
106
|
+
```kotlin
|
|
107
|
+
@Post("/project/alert/acknowledge")
|
|
108
|
+
suspend fun acknowledgeAlert(
|
|
109
|
+
@QueryValue id: UUID,
|
|
110
|
+
@QueryValue acknowledgedBy: UUID
|
|
111
|
+
): ProjectAlertSummary {
|
|
112
|
+
// Manager returns Either<ClientException, T>
|
|
113
|
+
// .throwOrValue() unwraps or throws the exception
|
|
114
|
+
return projectHealthManager.acknowledgeAlert(id, acknowledgedBy).throwOrValue()
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## No Inline Data Classes
|
|
119
|
+
|
|
120
|
+
**NEVER define `data class` declarations inside or at the bottom of controller files.**
|
|
121
|
+
|
|
122
|
+
All request/response models MUST live in `module-client`:
|
|
123
|
+
- Requests: `module-client/src/main/kotlin/com/yourcompany/client/request/`
|
|
124
|
+
- Responses: `module-client/src/main/kotlin/com/yourcompany/client/response/`
|
|
125
|
+
|
|
126
|
+
### Bad - Inline Data Classes (VIOLATION)
|
|
127
|
+
|
|
128
|
+
```kotlin
|
|
129
|
+
// ❌ WRONG - data classes at bottom of controller file
|
|
130
|
+
@Controller("/api/v1/admin/github")
|
|
131
|
+
class GitHubController(private val manager: GitHubManager) {
|
|
132
|
+
|
|
133
|
+
@Post("/project-sources/orgs/set")
|
|
134
|
+
suspend fun setProjectOrgs(
|
|
135
|
+
@QueryValue projectId: UUID,
|
|
136
|
+
@Body request: SetProjectOrgsRequest
|
|
137
|
+
): List<ProjectOrgAssignment> {
|
|
138
|
+
return manager.setProjectOrgs(projectId, request.organizations).throwOrValue()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ❌ NEVER DO THIS - data classes defined in controller file
|
|
143
|
+
data class SetProjectOrgsRequest(
|
|
144
|
+
val organizations: List<OrgAssignmentInput>
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
data class OrgAssignmentInput(
|
|
148
|
+
val orgLogin: String,
|
|
149
|
+
val includeAllRepos: Boolean = true
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Good - Separate Request File in module-client
|
|
154
|
+
|
|
155
|
+
```kotlin
|
|
156
|
+
// ✓ CORRECT - In module-client/request/GitHubProjectSourcesRequest.kt
|
|
157
|
+
package com.yourcompany.client.request
|
|
158
|
+
|
|
159
|
+
import io.micronaut.serde.annotation.Serdeable
|
|
160
|
+
import java.util.UUID
|
|
161
|
+
|
|
162
|
+
@Serdeable
|
|
163
|
+
data class SetProjectOrgsRequest(
|
|
164
|
+
val organizations: List<OrgAssignmentInput>
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@Serdeable
|
|
168
|
+
data class OrgAssignmentInput(
|
|
169
|
+
val orgLogin: String,
|
|
170
|
+
val includeAllRepos: Boolean = true
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```kotlin
|
|
175
|
+
// ✓ CORRECT - Controller imports from module-client
|
|
176
|
+
import com.yourcompany.client.request.SetProjectOrgsRequest
|
|
177
|
+
import com.yourcompany.client.request.OrgAssignmentInput
|
|
178
|
+
|
|
179
|
+
@Controller("/api/v1/admin/github")
|
|
180
|
+
class GitHubController(private val manager: GitHubManager) {
|
|
181
|
+
// Uses imported request classes
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Why This Rule Exists
|
|
186
|
+
|
|
187
|
+
1. **Single source of truth** - Models defined once, used everywhere
|
|
188
|
+
2. **Client generation** - External clients can import from module-client
|
|
189
|
+
3. **Consistency** - All teams know where to find request/response models
|
|
190
|
+
4. **Maintainability** - Changes to models are tracked in one place
|
|
191
|
+
5. **Testing** - Retrofit clients in tests use the same models
|
|
192
|
+
|
|
193
|
+
### How to Fix Violations
|
|
194
|
+
|
|
195
|
+
1. Create the right file in `module-client/request/` or `module-client/response/`
|
|
196
|
+
2. Move data class definitions there with `@Serdeable` annotation
|
|
197
|
+
3. Add proper imports in controller
|
|
198
|
+
4. Delete inline definitions from controller file
|
|
199
|
+
|
|
200
|
+
## No Private Converter Functions
|
|
201
|
+
|
|
202
|
+
**NEVER define private extension functions like `.toResponse()` in controller files.**
|
|
203
|
+
|
|
204
|
+
### Bad - Private Converter Functions (VIOLATION)
|
|
205
|
+
|
|
206
|
+
```kotlin
|
|
207
|
+
// ❌ WRONG - private converter functions in controller
|
|
208
|
+
class DataSyncController(private val manager: DataSyncManager) {
|
|
209
|
+
|
|
210
|
+
@Post("/sync")
|
|
211
|
+
suspend fun sync(): List<SyncResultResponse> {
|
|
212
|
+
return manager.sync().throwOrValue().map { it.toResponse() }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ❌ NEVER DO THIS
|
|
216
|
+
private fun SyncResult.toResponse() = SyncResultResponse(
|
|
217
|
+
success = success,
|
|
218
|
+
resourceType = resourceType,
|
|
219
|
+
// ...
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Preferred: Manager Returns Response DTOs
|
|
225
|
+
|
|
226
|
+
The best pattern is for managers to return Response DTOs directly:
|
|
227
|
+
|
|
228
|
+
```kotlin
|
|
229
|
+
// ✓ BEST - Manager returns Response DTOs
|
|
230
|
+
class DataSyncController(private val manager: DataSyncManager) {
|
|
231
|
+
|
|
232
|
+
@Post("/sync")
|
|
233
|
+
suspend fun sync(): List<SyncResultResponse> {
|
|
234
|
+
return manager.sync().throwOrValue() // Manager already returns Response type
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Acceptable: Inline Mapping in Controller
|
|
240
|
+
|
|
241
|
+
When the manager returns domain models, use inline mapping:
|
|
242
|
+
|
|
243
|
+
```kotlin
|
|
244
|
+
// ✓ ACCEPTABLE - Inline mapping (no private functions)
|
|
245
|
+
class DataSyncController(private val manager: DataSyncManager) {
|
|
246
|
+
|
|
247
|
+
@Post("/sync")
|
|
248
|
+
suspend fun sync(): List<SyncResultResponse> {
|
|
249
|
+
return manager.sync()
|
|
250
|
+
.throwOrValue()
|
|
251
|
+
.map {
|
|
252
|
+
SyncResultResponse(
|
|
253
|
+
success = it.success,
|
|
254
|
+
resourceType = it.resourceType,
|
|
255
|
+
itemsSynced = it.itemsSynced,
|
|
256
|
+
errors = it.errors
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### When Companion Objects Work
|
|
264
|
+
|
|
265
|
+
If `module-client` can depend on the model's module without circular dependency:
|
|
266
|
+
|
|
267
|
+
```kotlin
|
|
268
|
+
// In module-client/response/...
|
|
269
|
+
data class UserResponse(...) {
|
|
270
|
+
companion object {
|
|
271
|
+
fun from(entity: UserEntity) = UserResponse(...)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Then use: `users.map { UserResponse.from(it) }`
|
|
277
|
+
|
|
278
|
+
When circular dependencies stop companion objects from working, use inline mapping or have the manager return Response DTOs directly.
|
|
279
|
+
|
|
280
|
+
## How to Fix Controller Violations
|
|
281
|
+
|
|
282
|
+
When a controller directly uses repositories, follow these steps:
|
|
283
|
+
|
|
284
|
+
### 1. Identify the Operations
|
|
285
|
+
|
|
286
|
+
Look at what repository methods the controller is calling:
|
|
287
|
+
- `repository.byId(id)`
|
|
288
|
+
- `repository.byStatus(status)`
|
|
289
|
+
- `repository.update(...)`
|
|
290
|
+
|
|
291
|
+
### 2. Add Methods to the Manager Interface
|
|
292
|
+
|
|
293
|
+
```kotlin
|
|
294
|
+
// In the Manager interface (e.g., ProjectHealthManager.kt)
|
|
295
|
+
interface ProjectHealthManager {
|
|
296
|
+
// ... existing methods ...
|
|
297
|
+
|
|
298
|
+
// Add new methods for operations that were in the controller
|
|
299
|
+
suspend fun getProjectAlerts(projectId: UUID): Either<ClientException, List<ProjectAlertSummary>>
|
|
300
|
+
suspend fun listAlerts(status: String?): Either<ClientException, List<ProjectAlertSummary>>
|
|
301
|
+
suspend fun acknowledgeAlert(alertId: UUID, acknowledgedBy: UUID): Either<ClientException, ProjectAlertSummary>
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 3. Implement in the Manager
|
|
306
|
+
|
|
307
|
+
```kotlin
|
|
308
|
+
// In DefaultProjectHealthManager.kt
|
|
309
|
+
class DefaultProjectHealthManager(
|
|
310
|
+
// ... existing dependencies ...
|
|
311
|
+
private val projectAlertRepository: ProjectAlertRepository // Move repo here
|
|
312
|
+
) : ProjectHealthManager {
|
|
313
|
+
|
|
314
|
+
override suspend fun getProjectAlerts(projectId: UUID): Either<ClientException, List<ProjectAlertSummary>> {
|
|
315
|
+
val alerts = projectAlertRepository.byProjectId(projectId)
|
|
316
|
+
return alerts.map { ProjectAlertSummary.from(it) }.right()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### 4. Update the Controller
|
|
322
|
+
|
|
323
|
+
```kotlin
|
|
324
|
+
// Remove repository dependency, use manager instead
|
|
325
|
+
class ProjectHealthController(
|
|
326
|
+
private val projectHealthManager: ProjectHealthManager
|
|
327
|
+
// Repository dependency REMOVED
|
|
328
|
+
) {
|
|
329
|
+
@Get("/project/alerts")
|
|
330
|
+
suspend fun getProjectAlerts(@QueryValue id: UUID): List<ProjectAlertSummary> {
|
|
331
|
+
return projectHealthManager.getProjectAlerts(id).throwOrValue()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### 5. Update the Factory
|
|
337
|
+
|
|
338
|
+
```kotlin
|
|
339
|
+
// In EvaluationManagerFactory.kt
|
|
340
|
+
@Singleton
|
|
341
|
+
fun provideProjectHealthManager(
|
|
342
|
+
// ... existing params ...
|
|
343
|
+
projectAlertRepository: ProjectAlertRepository // Add new dependency
|
|
344
|
+
): ProjectHealthManager {
|
|
345
|
+
return DefaultProjectHealthManager(
|
|
346
|
+
// ... existing args ...
|
|
347
|
+
projectAlertRepository = projectAlertRepository
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Either Extension for Controllers
|
|
353
|
+
|
|
354
|
+
```kotlin
|
|
355
|
+
// Extension to convert Either to HTTP response
|
|
356
|
+
fun <T> Either<ClientException, T>.getOrThrow(): T =
|
|
357
|
+
fold({ throw it }, { it })
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Controller Test Standards
|
|
363
|
+
|
|
364
|
+
### 1. Test Structure Requirements
|
|
365
|
+
|
|
366
|
+
**ALWAYS follow this pattern for controller tests:**
|
|
367
|
+
|
|
368
|
+
1. **Extend AbstractControllerTest** - Never write standalone tests
|
|
369
|
+
2. **Use @TestInstance(TestInstance.Lifecycle.PER_CLASS)** - For proper lifecycle management
|
|
370
|
+
3. **Create a client interface** - Use Retrofit client for making API calls
|
|
371
|
+
4. **Generate JWT tokens** - Use `accessToken()` method to create real JWT tokens
|
|
372
|
+
5. **Test through HTTP** - Always test the full HTTP stack, not mocked managers
|
|
373
|
+
|
|
374
|
+
### 2. Required Test Setup
|
|
375
|
+
|
|
376
|
+
```kotlin
|
|
377
|
+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
|
378
|
+
class YourControllerTest : AbstractControllerTest() {
|
|
379
|
+
|
|
380
|
+
private lateinit var yourClient: YourClient
|
|
381
|
+
private lateinit var testUser: UserEntity
|
|
382
|
+
private lateinit var repository: YourRepository
|
|
383
|
+
|
|
384
|
+
// Override to manage cleanup manually
|
|
385
|
+
override fun cleanDataAfterTest(): Boolean = false
|
|
386
|
+
|
|
387
|
+
@BeforeAll
|
|
388
|
+
override fun beforeAll() {
|
|
389
|
+
// 1. Create Retrofit client
|
|
390
|
+
val url = embeddedServer.url.toString()
|
|
391
|
+
val jackson = ObjectMapper().configured()
|
|
392
|
+
val retrofit = Retrofits
|
|
393
|
+
.newBuilder(
|
|
394
|
+
url = url.toHttpUrl(),
|
|
395
|
+
jackson = jackson
|
|
396
|
+
)
|
|
397
|
+
.build()
|
|
398
|
+
yourClient = retrofit.create(YourClient::class.java)
|
|
399
|
+
|
|
400
|
+
// 2. Initialize repositories
|
|
401
|
+
repository = DefaultYourRepository(database)
|
|
402
|
+
|
|
403
|
+
// 3. Create test users
|
|
404
|
+
testUser = prepareUser(
|
|
405
|
+
email = "test@test.com",
|
|
406
|
+
displayName = "Test User",
|
|
407
|
+
status = UserStatus.ACTIVE,
|
|
408
|
+
role = UserRole.USER
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
// 4. Clean up any existing test data
|
|
412
|
+
cleanTestData()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
@AfterAll
|
|
416
|
+
override fun afterAll() {
|
|
417
|
+
// Clean up all test data
|
|
418
|
+
cleanTestData()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### 3. Test Pattern
|
|
424
|
+
|
|
425
|
+
```kotlin
|
|
426
|
+
@Test
|
|
427
|
+
fun `test name should describe the behavior`() = runBlocking {
|
|
428
|
+
// Given - Setup test data
|
|
429
|
+
val authToken = accessToken(testUser) // Generate real JWT
|
|
430
|
+
val request = CreateRequest(...)
|
|
431
|
+
|
|
432
|
+
// When - Make API call through client
|
|
433
|
+
val result = yourClient.createSomething(authToken, request)
|
|
434
|
+
|
|
435
|
+
// Then - Assert using Strikt
|
|
436
|
+
expectThat(result) {
|
|
437
|
+
get { field }.isEqualTo(expectedValue)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### 4. Authentication Testing
|
|
443
|
+
|
|
444
|
+
**ALWAYS test authentication scenarios:**
|
|
445
|
+
|
|
446
|
+
```kotlin
|
|
447
|
+
@Test
|
|
448
|
+
fun `should fail without authentication`() = runBlocking {
|
|
449
|
+
// When/Then
|
|
450
|
+
expectThrows<HttpClientResponseException> {
|
|
451
|
+
yourClient.someMethod("")
|
|
452
|
+
}.and {
|
|
453
|
+
get { status }.isEqualTo(HttpStatus.UNAUTHORIZED)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
@Test
|
|
458
|
+
fun `should fail when not owner`() = runBlocking {
|
|
459
|
+
// Given
|
|
460
|
+
val otherUser = prepareUser(email = "other@test.com")
|
|
461
|
+
val otherToken = accessToken(otherUser)
|
|
462
|
+
|
|
463
|
+
// When/Then
|
|
464
|
+
expectThrows<HttpClientResponseException> {
|
|
465
|
+
yourClient.accessResource(otherToken, resourceId)
|
|
466
|
+
}.and {
|
|
467
|
+
get { status }.isEqualTo(HttpStatus.NOT_FOUND) // Use NOT_FOUND for security
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### 5. Client Interface Requirements
|
|
473
|
+
|
|
474
|
+
**Create a proper Retrofit client in module-client:**
|
|
475
|
+
|
|
476
|
+
```kotlin
|
|
477
|
+
interface YourClient {
|
|
478
|
+
@POST("/api/resource")
|
|
479
|
+
suspend fun create(
|
|
480
|
+
@Header("Authorization") authorization: String,
|
|
481
|
+
@Body request: CreateRequest
|
|
482
|
+
): Response
|
|
483
|
+
|
|
484
|
+
@GET("/api/resource/detail") // Use query params, not path variables
|
|
485
|
+
suspend fun get(
|
|
486
|
+
@Header("Authorization") authorization: String,
|
|
487
|
+
@Query("id") id: UUID
|
|
488
|
+
): Response
|
|
489
|
+
|
|
490
|
+
@PUT("/api/resource/update")
|
|
491
|
+
suspend fun update(
|
|
492
|
+
@Header("Authorization") authorization: String,
|
|
493
|
+
@Query("id") id: UUID,
|
|
494
|
+
@Body request: UpdateRequest
|
|
495
|
+
): Response
|
|
496
|
+
|
|
497
|
+
@DELETE("/api/resource/delete")
|
|
498
|
+
suspend fun delete(
|
|
499
|
+
@Header("Authorization") authorization: String,
|
|
500
|
+
@Query("id") id: UUID
|
|
501
|
+
): Unit
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### 6. Data Cleanup Pattern
|
|
506
|
+
|
|
507
|
+
```kotlin
|
|
508
|
+
private fun hardDeleteTestData() {
|
|
509
|
+
try {
|
|
510
|
+
transaction(database.primary) {
|
|
511
|
+
// Delete in correct order (foreign keys first)
|
|
512
|
+
ChildTable.deleteWhere {
|
|
513
|
+
ChildTable.parentId inList testIds
|
|
514
|
+
}
|
|
515
|
+
ParentTable.deleteWhere {
|
|
516
|
+
ParentTable.userId eq testUser.id
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch (e: Exception) {
|
|
520
|
+
// Ignore if data doesn't exist
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### 7. Common Mistakes to Avoid
|
|
526
|
+
|
|
527
|
+
**DON'T:**
|
|
528
|
+
- Mock the manager or repository in controller tests
|
|
529
|
+
- Use @MockBean for dependencies
|
|
530
|
+
- Test without authentication
|
|
531
|
+
- Forget to clean up test data
|
|
532
|
+
- Use path variables in client interfaces
|
|
533
|
+
- Skip authorization/ownership tests
|
|
534
|
+
|
|
535
|
+
**DO:**
|
|
536
|
+
- Test the full HTTP stack end-to-end
|
|
537
|
+
- Use real JWT tokens via `accessToken()`
|
|
538
|
+
- Test all error scenarios
|
|
539
|
+
- Track created entities for cleanup
|
|
540
|
+
- Use query parameters exclusively
|
|
541
|
+
- Test one-to-one/one-to-many relationships
|
|
542
|
+
|
|
543
|
+
### 8. Assertion Pattern
|
|
544
|
+
|
|
545
|
+
Use Strikt for readable assertions:
|
|
546
|
+
|
|
547
|
+
```kotlin
|
|
548
|
+
expectThat(result) {
|
|
549
|
+
get { id }.isNotNull()
|
|
550
|
+
get { status }.isEqualTo(expectedStatus)
|
|
551
|
+
get { nestedObject }.isNotNull().and {
|
|
552
|
+
get { field }.isEqualTo(value)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
expectThat(list) {
|
|
557
|
+
get { size }.isGreaterThanOrEqualTo(2)
|
|
558
|
+
get { map { it.name } }.contains("Name1", "Name2")
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
expectThrows<HttpClientResponseException> {
|
|
562
|
+
// action that should throw
|
|
563
|
+
}.and {
|
|
564
|
+
get { status }.isEqualTo(HttpStatus.BAD_REQUEST)
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### 9. Test Coverage Requirements
|
|
569
|
+
|
|
570
|
+
Every controller should test:
|
|
571
|
+
1. Happy path for each endpoint
|
|
572
|
+
2. Missing authentication (401)
|
|
573
|
+
3. Wrong ownership/authorization (403/404)
|
|
574
|
+
4. Invalid input validation (400)
|
|
575
|
+
5. Resource not found (404)
|
|
576
|
+
6. Business rule violations
|
|
577
|
+
7. One-to-one/one-to-many relationships
|
|
578
|
+
8. Soft delete behavior
|
|
579
|
+
9. Update scenarios (partial updates)
|
|
580
|
+
10. List/filter operations
|
|
581
|
+
|
|
582
|
+
### 10. Example Full Test
|
|
583
|
+
|
|
584
|
+
See `ProjectControllerTest` and `UserControllerTest` for complete examples that follow all these patterns correctly.
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## API Testing — Use Retrofit Clients
|
|
589
|
+
|
|
590
|
+
**Never construct raw HttpRequest objects for API endpoint testing.**
|
|
591
|
+
|
|
592
|
+
Use the Retrofit clients in `module-client` instead of building HTTP requests by hand.
|
|
593
|
+
|
|
594
|
+
Why:
|
|
595
|
+
- **Type safety**: Retrofit clients give compile-time type checking
|
|
596
|
+
- **Single source of truth**: API contracts are defined once in client interfaces
|
|
597
|
+
- **Consistency**: Tests use the same client code as production consumers
|
|
598
|
+
- **Maintainability**: API changes only need updates in one place
|
|
599
|
+
- **Discoverability**: IDE autocomplete shows all available endpoints
|
|
600
|
+
|
|
601
|
+
### Bad - Raw HttpRequest Construction
|
|
602
|
+
```kotlin
|
|
603
|
+
// DON'T DO THIS
|
|
604
|
+
@Test
|
|
605
|
+
fun `should get conversation`() {
|
|
606
|
+
val request = HttpRequest.GET<Any>("/api/v1/conversations/by-id?id=$conversationId")
|
|
607
|
+
.bearerAuth(token.removePrefix("Bearer "))
|
|
608
|
+
.accept(MediaType.APPLICATION_JSON)
|
|
609
|
+
|
|
610
|
+
val response = client.toBlocking().exchange(request, ConversationResponse::class.java)
|
|
611
|
+
// assertions
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Good - Retrofit Client Usage
|
|
616
|
+
```kotlin
|
|
617
|
+
// DO THIS
|
|
618
|
+
@Test
|
|
619
|
+
fun `should get conversation`() = runTest {
|
|
620
|
+
val response = conversationClient.getConversation(
|
|
621
|
+
authorization = "Bearer $token",
|
|
622
|
+
id = conversationId
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
assertThat(response.id).isEqualTo(conversationId)
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Client Location
|
|
630
|
+
|
|
631
|
+
All Retrofit clients are in `module-client/src/main/kotlin/com/yourcompany/client/`:
|
|
632
|
+
|
|
633
|
+
```
|
|
634
|
+
module-client/
|
|
635
|
+
├── ConversationClient.kt # Conversation API endpoints
|
|
636
|
+
├── ContactClient.kt # Contact API endpoints
|
|
637
|
+
├── UserClient.kt # User API endpoints
|
|
638
|
+
└── ...
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
### Test Setup with Retrofit Clients
|
|
642
|
+
|
|
643
|
+
```kotlin
|
|
644
|
+
@MicronautTest(environments = ["test"])
|
|
645
|
+
class ConversationControllerTest {
|
|
646
|
+
|
|
647
|
+
@Inject
|
|
648
|
+
lateinit var conversationClient: ConversationClient
|
|
649
|
+
|
|
650
|
+
@Test
|
|
651
|
+
fun `should list conversations`() = runTest {
|
|
652
|
+
val response = conversationClient.listConversations(
|
|
653
|
+
authorization = bearerToken(testUser),
|
|
654
|
+
request = ListConversationsRequest(/* ... */)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
assertThat(response.items).isNotEmpty()
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### When Raw HttpRequest Is Acceptable
|
|
663
|
+
|
|
664
|
+
Raw HttpRequest is ONLY acceptable for:
|
|
665
|
+
|
|
666
|
+
1. **SSE/WebSocket connections** - Streaming protocols not supported by Retrofit
|
|
667
|
+
2. **Testing error responses** - When you need to test malformed requests
|
|
668
|
+
3. **Testing authentication failures** - When testing without/with invalid tokens
|
|
669
|
+
4. **Non-standard HTTP behaviors** - Testing edge cases Retrofit abstracts away
|
|
670
|
+
|
|
671
|
+
```kotlin
|
|
672
|
+
// Acceptable: SSE connection test
|
|
673
|
+
@Test
|
|
674
|
+
fun `should establish SSE connection`() {
|
|
675
|
+
val request = HttpRequest.GET<Any>("/api/v1/realtime/sse")
|
|
676
|
+
.bearerAuth(token)
|
|
677
|
+
.accept(MediaType.TEXT_EVENT_STREAM) // SSE not supported by Retrofit
|
|
678
|
+
// ...
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Acceptable: Testing invalid request format
|
|
682
|
+
@Test
|
|
683
|
+
fun `should return 400 for malformed request`() {
|
|
684
|
+
val request = HttpRequest.POST("/api/v1/conversations", """{"invalid": "json"}""")
|
|
685
|
+
.bearerAuth(token)
|
|
686
|
+
// ...
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### Adding New Endpoints
|
|
691
|
+
|
|
692
|
+
When adding a new API endpoint:
|
|
693
|
+
|
|
694
|
+
1. **Add to Retrofit client FIRST** in `module-client`
|
|
695
|
+
2. **Write tests using the client**
|
|
696
|
+
3. **Implement the controller**
|
|
697
|
+
|
|
698
|
+
This makes sure the client contract is defined before the implementation.
|
|
699
|
+
|
|
700
|
+
### Test Naming Convention
|
|
701
|
+
```kotlin
|
|
702
|
+
@Test
|
|
703
|
+
fun `{action} - {expected outcome}`() { }
|
|
704
|
+
|
|
705
|
+
// Examples:
|
|
706
|
+
fun `createConversation - returns conversation with correct channel`() { }
|
|
707
|
+
fun `getMessages - returns empty list for new conversation`() { }
|
|
708
|
+
fun `sendMessage - fails with 404 for non-existent conversation`() { }
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### Test Structure (AAA Pattern)
|
|
712
|
+
```kotlin
|
|
713
|
+
@Test
|
|
714
|
+
fun `should create conversation successfully`() = runTest {
|
|
715
|
+
// Arrange
|
|
716
|
+
val contact = createTestContact()
|
|
717
|
+
val request = CreateConversationRequest(contactId = contact.id)
|
|
718
|
+
|
|
719
|
+
// Act
|
|
720
|
+
val response = conversationClient.createConversation(
|
|
721
|
+
authorization = bearerToken(testUser),
|
|
722
|
+
request = request
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
// Assert
|
|
726
|
+
assertThat(response.contactId).isEqualTo(contact.id)
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Quick Reference
|
|
731
|
+
|
|
732
|
+
| Scenario | Use Retrofit Client | Use Raw HttpRequest |
|
|
733
|
+
|----------|:------------------:|:------------------:|
|
|
734
|
+
| Standard API calls | Yes | No |
|
|
735
|
+
| SSE/WebSocket | No | Yes |
|
|
736
|
+
| Testing malformed requests | No | Yes |
|
|
737
|
+
| Testing auth failures | No | Yes |
|
|
738
|
+
| Integration tests | Yes | No |
|
|
739
|
+
| E2E tests | Yes | No |
|
|
740
|
+
|
|
741
|
+
**Default to Retrofit clients. Only use raw HttpRequest when technically needed.**
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## Enforcement Checklist
|
|
746
|
+
|
|
747
|
+
Before committing controller changes:
|
|
748
|
+
|
|
749
|
+
- [ ] Controller class has `@ExecuteOn(TaskExecutors.IO)` annotation
|
|
750
|
+
- [ ] Controller only injects Managers/Detectors (no Repositories)
|
|
751
|
+
- [ ] All database operations are delegated to managers
|
|
752
|
+
- [ ] Controller methods are thin (just input parsing and delegation)
|
|
753
|
+
- [ ] Business logic is in managers, not controllers
|
|
754
|
+
- [ ] **NO inline data classes** - all models in `module-client`
|
|
755
|
+
- [ ] **NO private converter functions** - use inline mapping or manager returns Response DTOs
|