docs: enhance TDD guidelines in CLAUDE.md and INSTRUCTIONS.md

This commit is contained in:
2026-03-26 16:23:12 -03:00
parent ca2e645f02
commit 7f5f66ebe9
2 changed files with 397 additions and 135 deletions

View File

@@ -6,6 +6,29 @@ right knowledge.
---
## Default Workflow: Test-Driven Development (TDD)
> **Every implementation step in this project follows TDD. This is non-negotiable.**
The cycle for every step is:
| Phase | Action | Gate |
|------------|-------------------------------------------------------------------------|------------------------------------|
| **Red** | Write test file(s) for the step. Run the test suite. | New tests must **fail**. |
| **Green** | Write the minimum implementation to make all tests pass. | All tests must **pass**. |
| **Refactor** | Clean up the implementation. | Tests must stay **green**. |
| **Done** | Mark step ✅ only when the full build is green. | `./gradlew build` / `npm run build && npm run test` |
**Rules:**
- Never write implementation code before the test file exists and the tests fail.
- Never mark a step Done unless the full test suite passes.
- Test method names (Kotlin/JUnit): `should_[expectedBehavior]_when_[condition]`.
- Backend mocking: **MockK** only (not Mockito).
- Backend integration tests: `@SpringBootTest` with **H2 in-memory** database.
- Frontend tests: **Vitest** + **React Testing Library**, mocked Axios.
---
## Project Overview
- **Type:** Monorepo (backend + frontend in the same repository)
@@ -361,17 +384,32 @@ docker compose down
## Testing Guidelines
> **This project follows strict TDD.** For every implementation step, tests are written
> first (Red), then the implementation is added until all tests pass (Green), then code is
> cleaned up (Refactor). Never write implementation code before the tests exist and fail.
### TDD Workflow (apply to every step)
1. **Red** — Write the test file(s). Run `./gradlew test` (backend) or `npm run test`
(frontend) and confirm the new tests **fail** (compile errors are acceptable at this stage).
2. **Green** — Write the minimum implementation needed to make all tests pass.
3. **Refactor** — Clean up the implementation while keeping tests green.
4. A step is only ✅ Done when `./gradlew build` (backend) or
`npm run build && npm run test` (frontend) is fully green.
### Backend
- Every service class must have a corresponding unit test class.
- Every service class must have a corresponding unit test class **written before the service**.
- Use **MockK** for mocking (not Mockito).
- Integration tests use `@SpringBootTest` and an **H2 in-memory** database.
- Test method names follow: `should_[expectedBehavior]_when_[condition]`.
- Minimum 80% code coverage for service classes.
- Test files live in `src/test/kotlin/` mirroring the `src/main/kotlin/` package structure.
### Frontend
- Every page component must have at least a smoke test (renders without crashing).
- Every page component must have at least a smoke test (renders without crashing),
**written before the component**.
- API layer functions must be tested with mocked Axios responses.
- Use **Vitest** as the test runner and **React Testing Library** for component tests.
- Test files live in `src/__tests__/` mirroring the `src/` structure.
---
@@ -458,6 +496,8 @@ BODY:
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:`
- Scope your commits: `feat(backend):`, `feat(frontend):`, `chore(docker):`
- **TDD commit order per step:** first `test(<scope>): add failing tests for <step>`, then
`feat(<scope>): implement <step> — all tests passing`.
- PRs require all CI checks to pass before merging.
- Never commit directly to `main`.

View File

@@ -27,9 +27,16 @@ employee is an AI-powered entity that:
- Follow steps **in order** — each step builds on the previous one.
- At the start of each AI session, share the contents of `CLAUDE.md` for context.
- **Each step follows TDD: write the tests first, then implement until all tests pass.**
- After completing each step, mark it ✅ **Done** and note any decisions made.
- If anything changes (new library, schema change, etc.), update `CLAUDE.md` too.
### TDD Workflow Per Step
1. **Red** — AI writes the test file(s) for the step. `./gradlew test` or `npm run test` must fail (or show the new tests failing).
2. **Green** — AI writes the implementation until all tests pass.
3. **Refactor** — Clean up while keeping tests green.
4. Mark the step ✅ Done only when `./gradlew build` (backend) or `npm run build && npm run test` (frontend) is fully green.
---
## Progress Tracker
@@ -48,10 +55,11 @@ employee is an AI-powered entity that:
| 9 | REST Controllers & DTOs | ⬜ Pending |
| 10 | Authentication (JWT login) | ⬜ Pending |
| 11 | React Frontend | ⬜ Pending |
| 12 | Unit & Integration Tests | ⬜ Pending |
| 13 | Docker Compose (dev + prod) | ⬜ Pending |
| 14 | All-in-one Docker image | ⬜ Pending |
| 15 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending |
| 12 | Docker Compose (dev + prod) | ⬜ Pending |
| 13 | All-in-one Docker image | ⬜ Pending |
| 14 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending |
> ⚠️ **Steps 211 each follow TDD.** The AI writes failing tests first, then implements until green. See "TDD Workflow Per Step" above.
---
@@ -219,14 +227,35 @@ A record of every AI generation + email send attempt.
| `error_message` | String | Nullable — stores failure reason if FAILED |
| `dispatched_at` | LocalDateTime | When the dispatch was triggered |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the two JPA entities: `VirtualEntity` and `DispatchLog`.
> Place them in the `model/` package. Use UUIDs as primary keys and proper JPA annotations.
> `DispatchLog` has a `@ManyToOne` relationship to `VirtualEntity`."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_persistVirtualEntity_when_allFieldsProvided
// should_enforceUniqueEmail_when_duplicateEmailInserted
// should_persistDispatchLog_when_linkedToVirtualEntity
// should_setCreatedAtAutomatically_when_virtualEntitySaved
// should_defaultActiveToTrue_when_virtualEntityCreated
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write an integration test class `EntityMappingTest` in
> `src/test/kotlin/com/condado/newsletter/model/`. Use `@DataJpaTest` with H2. Write tests
> that verify: VirtualEntity persists all fields, email is unique, DispatchLog links to
> VirtualEntity, createdAt is auto-set, active defaults to true. Do NOT create the entities
> yet — tests should fail to compile."
**Prompt — Phase 2 (implementation):**
> "Now create the two JPA entities `VirtualEntity` and `DispatchLog` in `model/`. Use UUIDs
> as primary keys and proper JPA annotations. `DispatchLog` has a `@ManyToOne` to
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
**Done when:**
- [ ] `EntityMappingTest.kt` exists with all 5 tests.
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
- [ ] `./gradlew build` compiles cleanly.
- [ ] `./gradlew test` is green.
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
---
@@ -238,15 +267,34 @@ A record of every AI generation + email send attempt.
| Repository | Entity | Custom queries needed |
|--------------------------|-----------------|----------------------------------------------------------|
| `VirtualEntityRepository`| `VirtualEntity` | `findAllByActiveTrue()`, `findByEmail()` |
| `DispatchLogRepository` | `DispatchLog` | `findAllByEntityId()`, `findTopByEntityIdOrderByDispatchedAtDesc()` |
| `DispatchLogRepository` | `DispatchLog` | `findAllByVirtualEntity()`, `findTopByVirtualEntityOrderByDispatchedAtDesc()` |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the two JPA repositories in the `repository/` package.
> Each must extend `JpaRepository` and include the custom query methods listed in the instructions."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled
// should_findEntityByEmail_when_emailExists
// should_returnEmptyOptional_when_emailNotFound
// should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled
// should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a `@DataJpaTest` class `RepositoryTest` in
> `src/test/kotlin/com/condado/newsletter/repository/`. Test all custom query methods for
> both repositories using H2. Do NOT create the repositories yet."
**Prompt — Phase 2 (implementation):**
> "Create `VirtualEntityRepository` and `DispatchLogRepository` in `repository/`, each
> extending `JpaRepository`, with the custom query methods needed to make `RepositoryTest` pass."
**Done when:**
- [ ] `RepositoryTest.kt` exists with all 5 tests.
- [ ] Both repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
- [ ] `./gradlew build` compiles cleanly.
- [ ] `./gradlew test` is green.
---
@@ -277,16 +325,33 @@ data class EmailContext(
)
```
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the `EmailReaderService` class in `service/`. It must
> connect to an IMAP server using env vars (`IMAP_HOST`, `IMAP_PORT`, `MAIL_USERNAME`,
> `MAIL_PASSWORD`, `IMAP_INBOX_FOLDER`), fetch emails from the last N days, and return them
> as a list of `EmailContext` data objects sorted chronologically. Use Jakarta Mail."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnEmailsSortedChronologically_when_multipleEmailsFetched
// should_returnEmptyList_when_imapConnectionFails
// should_filterEmailsOlderThanContextWindow_when_windowIs3Days
// should_stripHtml_when_emailBodyContainsHtmlTags
```
The IMAP `Session`/`Store` must be injected or overridable so tests can mock it with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EmailReaderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock the Jakarta Mail `Store` and
> `Folder`. Write the 4 tests listed. Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `EmailContext` data class and `EmailReaderService` in `service/`. Use Jakarta Mail
> for IMAP. The Store must be injectable/mockable. Make all `EmailReaderServiceTest` tests pass."
**Done when:**
- [ ] `EmailReaderService.kt` exists in `service/`.
- [ ] `EmailContext.kt` data class exists.
- [ ] Service reads real emails when tested against a live IMAP account.
- [ ] `EmailReaderServiceTest.kt` exists with all 4 tests.
- [ ] `EmailReaderService.kt` and `EmailContext.kt` exist in their packages.
- [ ] `./gradlew test` is green.
- [ ] Returns empty list (not exception) on connection failure.
---
@@ -308,15 +373,37 @@ It must be followed exactly, with the entity fields and email context filled in
fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String
```
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `PromptBuilderService` in `service/`. It must implement
> `buildPrompt(entity, emailContext)` following the prompt template defined in CLAUDE.md exactly.
> This is the only class allowed to build prompt strings."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_containEntityName_when_buildPromptCalled
// should_containEntityJobTitle_when_buildPromptCalled
// should_containEntityPersonality_when_buildPromptCalled
// should_containContextWindowDays_when_buildPromptCalled
// should_containEachEmailSenderAndSubject_when_emailContextProvided
// should_containFormatInstruction_when_buildPromptCalled // verifies SUBJECT:/BODY: instruction
// should_returnPromptWithNoEmails_when_emailContextIsEmpty
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a pure unit test class `PromptBuilderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. No mocks needed — just build a
> `VirtualEntity` and `List<EmailContext>` and assert the output string. Write all 7 tests.
> Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `PromptBuilderService` in `service/`. It must implement `buildPrompt(entity,
> emailContext)` following the prompt template in CLAUDE.md exactly. Make all
> `PromptBuilderServiceTest` tests pass."
**Done when:**
- [ ] `PromptBuilderServiceTest.kt` exists with all 7 tests.
- [ ] `PromptBuilderService.kt` exists in `service/`.
- [ ] Output prompt matches the template from `CLAUDE.md` with fields correctly substituted.
- [ ] Unit test verifies the prompt structure.
- [ ] `./gradlew test` is green.
- [ ] Output prompt matches the template from `CLAUDE.md` with all fields correctly substituted.
---
@@ -331,8 +418,7 @@ fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String
- Use the model configured in `OPENAI_MODEL` env var (default: `gpt-4o`).
- Send the prompt as a `user` message.
- Return the AI's response as a plain `String`.
- Parse the response to extract `subject` and `body` — the AI should be instructed to return
them in a structured format:
- Parse the response to extract `subject` and `body` — the AI is instructed to return:
```
SUBJECT: <generated subject>
BODY:
@@ -342,15 +428,34 @@ fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String
**HTTP Client:** Use Spring's `RestClient` (Spring Boot 3.2+) — do NOT use WebClient or Feign.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `AiService` in `service/`. It must call the OpenAI
> Chat Completions API using Spring's `RestClient`, using the `OPENAI_API_KEY` and `OPENAI_MODEL`
> env vars. Return the AI's text response. Also implement a `parseResponse(raw: String)` method
> that extracts the subject and body from a response formatted as `SUBJECT: ...\nBODY:\n...`."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_returnAiResponseText_when_apiCallSucceeds
// should_throwAiServiceException_when_apiReturnsError
// should_extractSubjectAndBody_when_responseIsWellFormatted
// should_throwParseException_when_responseIsMissingSubjectLine
// should_throwParseException_when_responseIsMissingBodySection
```
Mock `RestClient` with MockK. `parseResponse()` can be tested without mocking.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `AiServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock Spring's `RestClient` chain.
> Write all 5 tests listed. Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `AiService` in `service/` using Spring `RestClient` for the OpenAI API. Implement
> `parseResponse(raw: String)` that extracts SUBJECT and BODY. Make all `AiServiceTest` tests pass."
**Done when:**
- [ ] `AiServiceTest.kt` exists with all 5 tests.
- [ ] `AiService.kt` exists in `service/`.
- [ ] Calling the service with a real API key returns a valid AI-generated email.
- [ ] `./gradlew test` is green.
- [ ] `parseResponse()` correctly extracts subject and body.
---
@@ -367,21 +472,37 @@ fun send(from: String, to: List<String>, subject: String, body: String)
```
- `from` is the `VirtualEntity.email` (the fictional employee's address).
- `to` is the list of all real participants' emails (loaded from config or DB — TBD in Step 9).
- `to` is the list of all real participants' emails (from `app.recipients` config).
- `body` may be plain text or simple HTML — send as both `text/plain` and `text/html` (multipart).
- Log every send attempt.
**Note on recipients:** For now, the list of recipients (the real friends' emails) will be stored
as a comma-separated string in `application.yml` under `app.recipients`. This can be made dynamic later.
### TDD — Write tests first
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `EmailSenderService` in `service/`. It must use
> `JavaMailSender` to send emails. The `from` address comes from the entity, the `to` list
> comes from `app.recipients` in config, and the body is sent as both plain text and HTML."
**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled
// should_sendToAllRecipients_when_multipleRecipientsConfigured
// should_sendMultipartMessage_when_sendCalled // verifies both text/plain and text/html parts
// should_logSendAttempt_when_sendCalled
```
Mock `JavaMailSender` and `MimeMessage` with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EmailSenderServiceTest` in
> `src/test/kotlin/com/condado/newsletter/service/`. Mock `JavaMailSender`. Write all 4 tests.
> Do NOT create the service yet."
**Prompt — Phase 2 (implementation):**
> "Create `EmailSenderService` in `service/` using `JavaMailSender`. Send emails as multipart
> (text/plain + text/html). Make all `EmailSenderServiceTest` tests pass."
**Done when:**
- [ ] `EmailSenderServiceTest.kt` exists with all 4 tests.
- [ ] `EmailSenderService.kt` exists in `service/`.
- [ ] Email is received in a real inbox when tested with valid SMTP credentials.
- [ ] `./gradlew test` is green.
---
@@ -392,29 +513,47 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
**Class:** `EntityScheduler` in `scheduler/` package.
**Approach:**
- Spring's `@Scheduled` with a fixed-rate tick (every 60 seconds) checks which entities are due.
- Use a `ScheduledTaskRegistrar` (or dynamic scheduling) to register a task per entity using its
`schedule_cron` expression.
- Use `SchedulingConfigurer` to register a cron task per active entity on startup.
- A `@Scheduled(fixedRate = 60_000)` refresh method re-reads entities and re-registers tasks
when entities are added/updated.
- When triggered, orchestrate the full pipeline:
1. Read emails via `EmailReaderService` (using `entity.contextWindowDays`).
2. Build prompt via `PromptBuilderService`.
3. Call AI via `AiService`.
4. Parse AI response (subject + body).
5. Send email via `EmailSenderService`.
6. Save a `DispatchLog` record with status SENT or FAILED.
6. Save a `DispatchLog` with status SENT.
- If the pipeline fails at any step, save a `DispatchLog` with status FAILED and the error message.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `EntityScheduler` in `scheduler/`. It must dynamically
> schedule a cron job per active `VirtualEntity` using `SchedulingConfigurer`. When triggered,
> it must run the full pipeline: read emails → build prompt → call AI → send email → save
> `DispatchLog`. Handle failures gracefully and always write a `DispatchLog`."
### TDD — Write tests first
**Test file:** `src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt`
Tests to write (all should **fail** before implementation):
```kotlin
// should_runFullPipeline_when_entityIsTriggered
// should_saveDispatchLogWithStatusSent_when_pipelineSucceeds
// should_saveDispatchLogWithStatusFailed_when_aiServiceThrows
// should_saveDispatchLogWithStatusFailed_when_emailSenderThrows
// should_notTrigger_when_entityIsInactive
```
Mock all five services with MockK.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write a MockK unit test class `EntitySchedulerTest` in
> `src/test/kotlin/com/condado/newsletter/scheduler/`. Mock all five service dependencies.
> Write all 5 tests listed. Do NOT create the scheduler yet."
**Prompt — Phase 2 (implementation):**
> "Create `EntityScheduler` in `scheduler/` using `SchedulingConfigurer`. Orchestrate the full
> pipeline and always persist a `DispatchLog`. Make all `EntitySchedulerTest` tests pass."
**Done when:**
- [ ] `EntitySchedulerTest.kt` exists with all 5 tests.
- [ ] `EntityScheduler.kt` exists in `scheduler/`.
- [ ] `@EnableScheduling` is present on the main app or a config class.
- [ ] An active entity triggers at its scheduled time and a `DispatchLog` record is created.
- [ ] End-to-end test: entity fires → email arrives in inbox.
- [ ] `./gradlew test` is green.
- [ ] `@EnableScheduling` is on `CondadoApplication`.
---
@@ -425,14 +564,14 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
**Controllers:**
### `VirtualEntityController` — `/api/v1/virtual-entities`
| Method | Path | Description |
|--------|---------------|--------------------------------------|
| POST | `/` | Create a new virtual entity |
| GET | `/` | List all virtual entities |
| GET | `/{id}` | Get one entity by ID |
| PUT | `/{id}` | Update an entity |
| DELETE | `/{id}` | Deactivate an entity (soft delete) |
| POST | `/{id}/trigger` | Manually trigger the entity pipeline |
| Method | Path | Description |
|--------|-------------------|--------------------------------------|
| POST | `/` | Create a new virtual entity |
| GET | `/` | List all virtual entities |
| GET | `/{id}` | Get one entity by ID |
| PUT | `/{id}` | Update an entity |
| DELETE | `/{id}` | Deactivate an entity (soft delete) |
| POST | `/{id}/trigger` | Manually trigger the entity pipeline |
### `DispatchLogController` — `/api/v1/dispatch-logs`
| Method | Path | Description |
@@ -440,21 +579,52 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
| GET | `/` | List all dispatch logs |
| GET | `/entity/{id}` | List logs for a specific entity |
**DTOs to create (in `dto/` package):**
**DTOs (in `dto/` package):**
- `VirtualEntityCreateDto` — name, email, jobTitle, personality, scheduleCron, contextWindowDays
- `VirtualEntityUpdateDto` — same fields, all optional
- `VirtualEntityResponseDto` — full entity fields + id + createdAt
- `DispatchLogResponseDto` — all DispatchLog fields
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create `VirtualEntityController` and `DispatchLogController` in
> `controller/`, and all DTOs in `dto/`. Controllers return `ResponseEntity<T>`. The `/trigger`
> endpoint manually runs the entity pipeline. DTOs use validation annotations."
### TDD — Write tests first
**Test files:**
- `src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt`
- `src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt`
Tests to write — `VirtualEntityControllerTest` (all should **fail** before implementation):
```kotlin
// should_return201AndBody_when_postWithValidPayload
// should_return400_when_postWithMissingRequiredField
// should_return200AndList_when_getAllEntities
// should_return200AndEntity_when_getById
// should_return404_when_getByIdNotFound
// should_return200_when_putWithValidPayload
// should_return200AndDeactivated_when_delete
// should_return200_when_triggerEndpointCalled
```
Tests to write — `DispatchLogControllerTest`:
```kotlin
// should_return200AndAllLogs_when_getAllLogs
// should_return200AndFilteredLogs_when_getByEntityId
```
Use `@SpringBootTest` + `MockMvc` + H2. Mock `EntityScheduler` so trigger tests don't run the pipeline.
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write `@SpringBootTest` integration test classes
> `VirtualEntityControllerTest` and `DispatchLogControllerTest` using MockMvc and H2. Write
> all tests listed. Do NOT create the controllers or DTOs yet."
**Prompt — Phase 2 (implementation):**
> "Create `VirtualEntityController`, `DispatchLogController`, and all DTOs in their packages.
> Controllers return `ResponseEntity<T>`. Make all controller tests pass."
**Done when:**
- [ ] Both test files exist with all tests listed above.
- [ ] All controller and DTO files exist.
- [ ] `./gradlew test` is green.
- [ ] Swagger UI shows all endpoints.
- [ ] CRUD via Swagger UI or Postman works end-to-end.
---
@@ -464,34 +634,59 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca
**Approach:**
- `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var.
- On success, generates a JWT (signed with `JWT_SECRET`, expiry from `JWT_EXPIRATION_MS`) and
sets it as an `httpOnly` cookie in the response.
- Spring Security `JwtAuthFilter` (extends `OncePerRequestFilter`) validates the cookie on every
protected request.
- Public paths: `POST /api/auth/login`, `/swagger-ui.html`, `/v3/api-docs/**`.
- On success, generates a JWT signed with `JWT_SECRET` and sets it as an `httpOnly` cookie.
- `JwtAuthFilter` (`OncePerRequestFilter`) validates the cookie on every protected request.
- Public paths: `POST /api/auth/login`, `GET /api/auth/me`, `/swagger-ui/**`, `/v3/api-docs/**`.
- There is **no user table** — the password lives only in the environment variable.
**Classes to create:**
- `AuthController` — `POST /api/auth/login` endpoint
- `AuthController` — `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`
- `AuthService` — validates password, generates JWT
- `JwtService` — signs and validates JWT tokens
- `JwtAuthFilter` — reads cookie, validates JWT, sets `SecurityContext`
- `SecurityConfig` — Spring Security HTTP config (permit login + swagger, protect everything else)
- `SecurityConfig` — Spring Security HTTP config
**DTOs:**
- `LoginRequest` — `{ "password": String }`
- `AuthResponse` — `{ "message": String }` (cookie is set on the response; no token in body)
- `AuthResponse` — `{ "message": String }`
**Prompt to use with AI:**
> "Using the CLAUDE.md context, implement JWT authentication for the single-admin model.
> Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`.
> `POST /api/auth/login` validates against `APP_PASSWORD` env var and returns a JWT in an
> httpOnly cookie. All other endpoints require the JWT cookie. Swagger UI is public."
### TDD — Write tests first
**Test files:**
- `src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt`
- `src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt`
Tests to write — `AuthServiceTest` (all should **fail** before implementation):
```kotlin
// should_returnJwtToken_when_correctPasswordProvided
// should_throwUnauthorizedException_when_wrongPasswordProvided
// should_returnValidClaims_when_jwtTokenParsed
// should_returnFalse_when_expiredTokenValidated
```
Tests to write — `AuthControllerTest`:
```kotlin
// should_return200AndSetCookie_when_correctPasswordPosted
// should_return401_when_wrongPasswordPosted
// should_return200_when_getMeWithValidCookie
// should_return401_when_getMeWithNoCookie
// should_return401_when_protectedEndpointAccessedWithoutCookie
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write MockK unit tests for `AuthService` and a `@SpringBootTest`
> integration test `AuthControllerTest` using MockMvc and H2. Write all tests listed.
> Do NOT create the auth classes yet."
**Prompt — Phase 2 (implementation):**
> "Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`.
> `POST /api/auth/login` validates against `APP_PASSWORD`, returns JWT in an httpOnly cookie.
> Make all auth tests pass."
**Done when:**
- [ ] `POST /api/auth/login` with correct password sets an `httpOnly` JWT cookie and returns `200`.
- [ ] `POST /api/auth/login` with wrong password returns `401`.
- [ ] All `/api/v1/**` endpoints return `401` without a valid JWT cookie.
- [ ] Both test files exist with all tests listed above.
- [ ] All auth class files exist.
- [ ] `./gradlew test` is green.
- [ ] Swagger UI remains accessible without auth.
- [ ] Password and JWT secret are never hardcoded.
@@ -539,61 +734,88 @@ router/
- Login form submits to `POST /api/auth/login` — on success React Query invalidates and
React Router navigates to `/`.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, build the React frontend. Create all four pages (Login,
> Dashboard, Entities, Logs) with React Query for data fetching, shadcn/ui for components,
> and React Router for navigation. Implement `ProtectedRoute` using `GET /api/auth/me`.
> All API calls must go through the `src/api/` layer."
### TDD — Write tests first
**Test files:**
- `src/__tests__/api/authApi.test.ts`
- `src/__tests__/api/entitiesApi.test.ts`
- `src/__tests__/api/logsApi.test.ts`
- `src/__tests__/pages/LoginPage.test.tsx`
- `src/__tests__/pages/EntitiesPage.test.tsx`
- `src/__tests__/pages/DashboardPage.test.tsx`
- `src/__tests__/pages/LogsPage.test.tsx`
- `src/__tests__/components/ProtectedRoute.test.tsx`
Tests to write — **API layer** (all should **fail** before implementation):
```typescript
// authApi.test.ts
// should_callLoginEndpoint_when_loginCalled
// should_callLogoutEndpoint_when_logoutCalled
// should_callMeEndpoint_when_getMeCalled
// entitiesApi.test.ts
// should_callGetEndpoint_when_getAllEntitiesCalled
// should_callPostEndpoint_when_createEntityCalled
// should_callPutEndpoint_when_updateEntityCalled
// should_callDeleteEndpoint_when_deleteEntityCalled
// should_callTriggerEndpoint_when_triggerEntityCalled
// logsApi.test.ts
// should_callGetAllLogsEndpoint_when_getAllLogsCalled
// should_callGetByEntityEndpoint_when_getLogsByEntityCalled
```
Tests to write — **Pages & Components** (all should **fail** before implementation):
```typescript
// LoginPage.test.tsx
// should_renderLoginForm_when_pageLoads
// should_callLoginApi_when_formSubmitted
// should_showErrorMessage_when_loginFails
// should_redirectToDashboard_when_loginSucceeds
// EntitiesPage.test.tsx
// should_renderEntityList_when_entitiesLoaded
// should_openCreateDialog_when_addButtonClicked
// should_callDeleteApi_when_deleteConfirmed
// DashboardPage.test.tsx
// should_renderEntityCount_when_pageLoads
// should_renderRecentLogs_when_pageLoads
// LogsPage.test.tsx
// should_renderLogTable_when_logsLoaded
// should_filterLogsByEntity_when_filterSelected
// ProtectedRoute.test.tsx
// should_renderChildren_when_sessionIsValid
// should_redirectToLogin_when_sessionIsInvalid
```
**Prompt — Phase 1 (tests):**
> "Using the CLAUDE.md context, write Vitest + React Testing Library test files for the
> frontend. Create tests for all three API modules (`authApi`, `entitiesApi`, `logsApi`) —
> mock Axios and assert that each function calls the correct endpoint. Create smoke tests for
> all four pages and for `ProtectedRoute`. Mock React Query and the API layer. Write all tests
> listed above. Do NOT create any implementation files yet — tests should fail."
**Prompt — Phase 2 (implementation):**
> "Using the CLAUDE.md context, implement the full React frontend. Create all API modules in
> `src/api/`, all components in `src/components/`, all pages in `src/pages/`, and the router
> in `src/router/index.tsx`. Use React Query for all server state, shadcn/ui for UI components,
> and React Router for navigation. Make all frontend tests pass."
**Done when:**
- [ ] All test files exist with all tests listed above.
- [ ] `npm run test` is green (all tests pass).
- [ ] `npm run build` succeeds with no TypeScript errors.
- [ ] `npm run dev` serves the app and login flow works end-to-end.
- [ ] Unauthenticated users are redirected to `/login`.
- [ ] Entities can be created, edited, toggled active, and deleted via the UI.
- [ ] Dispatch logs are visible and filterable by entity.
- [ ] All pages have at least a smoke test (`npm run test` passes).
---
## Step 12 — Unit & Integration Tests
**Goal:** Test every service class and one integration test per controller.
### Backend tests
| Test Class | Type | Covers |
|---------------------------------|-------------|----------------------------------------------|
| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error |
| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted |
| `AiServiceTest` | Unit | API call mocked, `parseResponse` parsing |
| `EmailSenderServiceTest` | Unit | `JavaMailSender` called with correct args |
| `AuthServiceTest` | Unit | Correct/incorrect password, JWT generation |
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
| `DispatchLogControllerTest` | Integration | List all, list by entity |
### Frontend tests
| Test File | Covers |
|---------------------------------|--------------------------------------------------------|
| `LoginPage.test.tsx` | Renders, submits form, shows error on wrong password |
| `EntitiesPage.test.tsx` | Lists entities, opens create dialog, handles delete |
| `authApi.test.ts` | `login()` calls correct endpoint with correct payload |
| `entitiesApi.test.ts` | CRUD functions call correct endpoints |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK,
> integration tests for controllers using `@SpringBootTest` with H2, and frontend component and
> API layer tests using Vitest + React Testing Library. Follow naming convention
> `should_[expectedBehavior]_when_[condition]` for backend tests."
**Done when:**
- [ ] `./gradlew test` passes all backend tests green.
- [ ] Backend service class coverage ≥ 80%.
- [ ] `npm run test` passes all frontend tests green.
---
## Step 13 — Docker Compose (Dev + Prod)
## Step 12 — Docker Compose (Dev + Prod)
**Goal:** Containerize both services and wire them together for local dev and production.
@@ -629,7 +851,7 @@ condado-news-letter/
---
## Step 14 — All-in-one Docker Image
## Step 13 — All-in-one Docker Image
**Goal:** Build a single Docker image that runs the entire stack (Nginx + Spring Boot + PostgreSQL)
under Supervisor, deployable with a single `docker run` command.
@@ -710,7 +932,7 @@ docker run -d \
---
## Step 15 — CI/CD (GitHub Actions + Docker Hub)
## Step 14 — CI/CD (GitHub Actions + Docker Hub)
**Goal:** Automate testing on every PR and publish the all-in-one image to Docker Hub on every
merge to `main`.