@@ -30,4 +30,4 @@ OPENAI_MODEL=gpt-4o
|
|||||||
APP_RECIPIENTS=friend1@example.com,friend2@example.com
|
APP_RECIPIENTS=friend1@example.com,friend2@example.com
|
||||||
|
|
||||||
# ── Frontend (Vite build-time) ────────────────────────────────────────────────
|
# ── Frontend (Vite build-time) ────────────────────────────────────────────────
|
||||||
VITE_API_BASE_URL=http://localhost:6969
|
VITE_API_BASE_URL=http://localhost
|
||||||
|
|||||||
19
CLAUDE.md
19
CLAUDE.md
@@ -501,6 +501,25 @@ BODY:
|
|||||||
- PRs require all CI checks to pass before merging.
|
- PRs require all CI checks to pass before merging.
|
||||||
- Never commit directly to `main`.
|
- Never commit directly to `main`.
|
||||||
|
|
||||||
|
### Commit Rules (enforced by AI)
|
||||||
|
|
||||||
|
These rules apply to every commit made during AI-assisted implementation:
|
||||||
|
|
||||||
|
| Rule | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Two commits per TDD step** | 1st commit = failing tests (Red), 2nd commit = passing implementation (Green) |
|
||||||
|
| **Commit after each step** | Never accumulate multiple steps in one commit |
|
||||||
|
| **Red commit subject** | `test(<scope>): add failing tests for step <N> — <short description>` |
|
||||||
|
| **Green commit subject** | `feat(<scope>): implement step <N> — <short description>` |
|
||||||
|
| **Scope values** | `backend`, `frontend`, `docker`, `ci`, `config` |
|
||||||
|
| **Body** | Optional but encouraged: list what was added/changed |
|
||||||
|
| **No `--no-verify`** | Never bypass git hooks |
|
||||||
|
| **No force push** | Never use `--force` on shared branches |
|
||||||
|
| **Atomic commits** | Each commit must leave the build green (except deliberate Red-phase test commits) |
|
||||||
|
| **`chore` for housekeeping** | Config changes, dependency tweaks, file renames → `chore(<scope>):` |
|
||||||
|
| **`fix` for bug fixes** | `fix(<scope>): <what was broken and how it was fixed>` |
|
||||||
|
| **`docs` for documentation** | Changes to `CLAUDE.md`, `INSTRUCTIONS.md`, `README.md` → `docs:` |
|
||||||
|
|
||||||
### GitHub Actions Workflows
|
### GitHub Actions Workflows
|
||||||
|
|
||||||
| Workflow file | Trigger | What it does |
|
| Workflow file | Trigger | What it does |
|
||||||
|
|||||||
@@ -53,6 +53,6 @@ COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 6969
|
EXPOSE 80
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ employee is an AI-powered entity that:
|
|||||||
|------|-----------------------------------------|-------------|
|
|------|-----------------------------------------|-------------|
|
||||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||||
| 1 | Scaffold monorepo structure | ✅ Done |
|
| 1 | Scaffold monorepo structure | ✅ Done |
|
||||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
| 2 | Domain model (JPA entities) | ✅ Done |
|
||||||
| 3 | Repositories | ⬜ Pending |
|
| 3 | Repositories | ⬜ Pending |
|
||||||
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
||||||
| 5 | Prompt Builder Service | ⬜ Pending |
|
| 5 | Prompt Builder Service | ⬜ Pending |
|
||||||
@@ -253,10 +253,16 @@ Tests to write (all should **fail** before implementation):
|
|||||||
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
|
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- [ ] `EntityMappingTest.kt` exists with all 5 tests.
|
- [x] `EntityMappingTest.kt` exists with all 5 tests.
|
||||||
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
- [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||||
- [ ] `./gradlew test` is green.
|
- [x] `./gradlew test` is green.
|
||||||
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
|
- [x] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
|
||||||
|
|
||||||
|
**Key decisions made:**
|
||||||
|
- Added `org.gradle.java.home=C:/Program Files/Java/jdk-21.0.10` to `gradle.properties` — the Kotlin DSL compiler embedded in Gradle 8.14.1 does not support JVM target 26, so the Gradle daemon must run under JDK 21.
|
||||||
|
- Created `src/test/resources/application.yml` to override datasource and JPA settings for tests (H2 in-memory, `ddl-auto: create-drop`), and provide placeholder values for required env vars so tests run without Docker/real services.
|
||||||
|
- `VirtualEntity` and `DispatchLog` use class-body `var` fields for `id` (`@GeneratedValue`) and `createdAt` (`@CreationTimestamp`) so Hibernate can set them; all other fields are constructor `val` properties.
|
||||||
|
- `DispatchStatus` enum: `PENDING`, `SENT`, `FAILED`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.condado.newsletter.config
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-wide bean configuration.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class AppConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun restClient(): RestClient = RestClient.create()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.condado.newsletter.config
|
||||||
|
|
||||||
|
import com.condado.newsletter.service.JwtService
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the JWT from the `jwt` cookie on each request.
|
||||||
|
* If the token is valid, sets an authenticated [SecurityContext].
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class JwtAuthFilter(private val jwtService: JwtService) : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain
|
||||||
|
) {
|
||||||
|
val token = request.cookies
|
||||||
|
?.firstOrNull { it.name == "jwt" }
|
||||||
|
?.value
|
||||||
|
|
||||||
|
if (token != null && jwtService.validateToken(token)) {
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.condado.newsletter.config
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
http
|
||||||
|
.csrf { it.disable() }
|
||||||
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
|
.exceptionHandling { it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) }
|
||||||
|
.authorizeHttpRequests { auth ->
|
||||||
|
auth
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||||
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
}
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
|
||||||
|
return http.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.dto.AuthResponse
|
||||||
|
import com.condado.newsletter.dto.LoginRequest
|
||||||
|
import com.condado.newsletter.service.AuthService
|
||||||
|
import jakarta.servlet.http.Cookie
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles authentication — login, logout, and session check.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
class AuthController(private val authService: AuthService) {
|
||||||
|
|
||||||
|
/** Validates the password and sets a JWT cookie on success. */
|
||||||
|
@PostMapping("/login")
|
||||||
|
fun login(
|
||||||
|
@RequestBody request: LoginRequest,
|
||||||
|
response: HttpServletResponse
|
||||||
|
): ResponseEntity<AuthResponse> {
|
||||||
|
val token = authService.login(request.password)
|
||||||
|
val cookie = Cookie("jwt", token).apply {
|
||||||
|
isHttpOnly = true
|
||||||
|
path = "/"
|
||||||
|
maxAge = 86400 // 24 hours
|
||||||
|
}
|
||||||
|
response.addCookie(cookie)
|
||||||
|
return ResponseEntity.ok(AuthResponse("Login successful"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 200 if the JWT cookie is valid (checked by JwtAuthFilter). */
|
||||||
|
@GetMapping("/me")
|
||||||
|
fun me(): ResponseEntity<AuthResponse> =
|
||||||
|
ResponseEntity.ok(AuthResponse("Authenticated"))
|
||||||
|
|
||||||
|
/** Clears the JWT cookie. */
|
||||||
|
@PostMapping("/logout")
|
||||||
|
fun logout(response: HttpServletResponse): ResponseEntity<AuthResponse> {
|
||||||
|
val cookie = Cookie("jwt", "").apply {
|
||||||
|
isHttpOnly = true
|
||||||
|
path = "/"
|
||||||
|
maxAge = 0
|
||||||
|
}
|
||||||
|
response.addCookie(cookie)
|
||||||
|
return ResponseEntity.ok(AuthResponse("Logged out"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.dto.DispatchLogResponseDto
|
||||||
|
import com.condado.newsletter.repository.DispatchLogRepository
|
||||||
|
import com.condado.newsletter.repository.VirtualEntityRepository
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for read-only access to [com.condado.newsletter.model.DispatchLog] resources.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dispatch-logs")
|
||||||
|
class DispatchLogController(
|
||||||
|
private val dispatchLogRepository: DispatchLogRepository,
|
||||||
|
private val virtualEntityRepository: VirtualEntityRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Lists all dispatch logs. */
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): ResponseEntity<List<DispatchLogResponseDto>> =
|
||||||
|
ResponseEntity.ok(dispatchLogRepository.findAll().map { DispatchLogResponseDto.from(it) })
|
||||||
|
|
||||||
|
/** Lists dispatch logs for a specific entity. */
|
||||||
|
@GetMapping("/entity/{entityId}")
|
||||||
|
fun getByEntityId(@PathVariable entityId: UUID): ResponseEntity<List<DispatchLogResponseDto>> {
|
||||||
|
val entity = virtualEntityRepository.findById(entityId).orElse(null)
|
||||||
|
?: return ResponseEntity.notFound().build()
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
dispatchLogRepository.findAllByVirtualEntity(entity).map { DispatchLogResponseDto.from(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityCreateDto
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityResponseDto
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityUpdateDto
|
||||||
|
import com.condado.newsletter.scheduler.EntityScheduler
|
||||||
|
import com.condado.newsletter.service.EntityService
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for managing [com.condado.newsletter.model.VirtualEntity] resources.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/virtual-entities")
|
||||||
|
class VirtualEntityController(
|
||||||
|
private val entityService: EntityService,
|
||||||
|
private val entityScheduler: EntityScheduler
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Creates a new virtual entity. */
|
||||||
|
@PostMapping
|
||||||
|
fun create(@Valid @RequestBody dto: VirtualEntityCreateDto): ResponseEntity<VirtualEntityResponseDto> =
|
||||||
|
ResponseEntity.status(HttpStatus.CREATED).body(entityService.create(dto))
|
||||||
|
|
||||||
|
/** Lists all virtual entities. */
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): ResponseEntity<List<VirtualEntityResponseDto>> =
|
||||||
|
ResponseEntity.ok(entityService.findAll())
|
||||||
|
|
||||||
|
/** Returns one entity by ID. */
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(@PathVariable id: UUID): ResponseEntity<VirtualEntityResponseDto> {
|
||||||
|
val entity = entityService.findById(id) ?: return ResponseEntity.notFound().build()
|
||||||
|
return ResponseEntity.ok(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates an entity. */
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
fun update(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestBody dto: VirtualEntityUpdateDto
|
||||||
|
): ResponseEntity<VirtualEntityResponseDto> {
|
||||||
|
val entity = entityService.update(id, dto) ?: return ResponseEntity.notFound().build()
|
||||||
|
return ResponseEntity.ok(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-deletes an entity (sets active = false). */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun deactivate(@PathVariable id: UUID): ResponseEntity<VirtualEntityResponseDto> {
|
||||||
|
val entity = entityService.deactivate(id) ?: return ResponseEntity.notFound().build()
|
||||||
|
return ResponseEntity.ok(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually triggers the email pipeline for a specific entity. */
|
||||||
|
@PostMapping("/{id}/trigger")
|
||||||
|
fun trigger(@PathVariable id: UUID): ResponseEntity<Void> {
|
||||||
|
val entity = entityService.findRawById(id) ?: return ResponseEntity.notFound().build()
|
||||||
|
entityScheduler.runPipeline(entity)
|
||||||
|
return ResponseEntity.ok().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.condado.newsletter.dto
|
||||||
|
|
||||||
|
/** Request body for POST /api/auth/login. */
|
||||||
|
data class LoginRequest(val password: String)
|
||||||
|
|
||||||
|
/** Generic response body for auth operations. */
|
||||||
|
data class AuthResponse(val message: String)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.condado.newsletter.dto
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.DispatchStatus
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** DTO returned by the API for a [com.condado.newsletter.model.DispatchLog]. */
|
||||||
|
data class DispatchLogResponseDto(
|
||||||
|
val id: UUID?,
|
||||||
|
val entityId: UUID?,
|
||||||
|
val entityName: String,
|
||||||
|
val promptSent: String?,
|
||||||
|
val aiResponse: String?,
|
||||||
|
val emailSubject: String?,
|
||||||
|
val emailBody: String?,
|
||||||
|
val status: DispatchStatus,
|
||||||
|
val errorMessage: String?,
|
||||||
|
val dispatchedAt: LocalDateTime
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(log: DispatchLog) = DispatchLogResponseDto(
|
||||||
|
id = log.id,
|
||||||
|
entityId = log.virtualEntity.id,
|
||||||
|
entityName = log.virtualEntity.name,
|
||||||
|
promptSent = log.promptSent,
|
||||||
|
aiResponse = log.aiResponse,
|
||||||
|
emailSubject = log.emailSubject,
|
||||||
|
emailBody = log.emailBody,
|
||||||
|
status = log.status,
|
||||||
|
errorMessage = log.errorMessage,
|
||||||
|
dispatchedAt = log.dispatchedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.condado.newsletter.dto
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
|
||||||
|
/** DTO for creating a new [com.condado.newsletter.model.VirtualEntity]. */
|
||||||
|
data class VirtualEntityCreateDto(
|
||||||
|
@field:NotBlank val name: String,
|
||||||
|
@field:NotBlank @field:Email val email: String,
|
||||||
|
@field:NotBlank val jobTitle: String,
|
||||||
|
val personality: String? = null,
|
||||||
|
val scheduleCron: String? = null,
|
||||||
|
val contextWindowDays: Int = 3
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.condado.newsletter.dto
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** DTO returned by the API for a [com.condado.newsletter.model.VirtualEntity]. */
|
||||||
|
data class VirtualEntityResponseDto(
|
||||||
|
val id: UUID?,
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val jobTitle: String,
|
||||||
|
val personality: String?,
|
||||||
|
val scheduleCron: String?,
|
||||||
|
val contextWindowDays: Int,
|
||||||
|
val active: Boolean,
|
||||||
|
val createdAt: LocalDateTime?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(entity: VirtualEntity) = VirtualEntityResponseDto(
|
||||||
|
id = entity.id,
|
||||||
|
name = entity.name,
|
||||||
|
email = entity.email,
|
||||||
|
jobTitle = entity.jobTitle,
|
||||||
|
personality = entity.personality,
|
||||||
|
scheduleCron = entity.scheduleCron,
|
||||||
|
contextWindowDays = entity.contextWindowDays,
|
||||||
|
active = entity.active,
|
||||||
|
createdAt = entity.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.condado.newsletter.dto
|
||||||
|
|
||||||
|
/** DTO for updating an existing [com.condado.newsletter.model.VirtualEntity]. All fields are optional. */
|
||||||
|
data class VirtualEntityUpdateDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
val jobTitle: String? = null,
|
||||||
|
val personality: String? = null,
|
||||||
|
val scheduleCron: String? = null,
|
||||||
|
val contextWindowDays: Int? = null,
|
||||||
|
val active: Boolean? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
|
import jakarta.persistence.FetchType
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.GenerationType
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.JoinColumn
|
||||||
|
import jakarta.persistence.ManyToOne
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every AI generation and email send attempt for a given [VirtualEntity].
|
||||||
|
* Stores the prompt sent, the AI response, parsed subject/body, send status, and timestamp.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "dispatch_logs")
|
||||||
|
class DispatchLog(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "entity_id", nullable = false)
|
||||||
|
val virtualEntity: VirtualEntity,
|
||||||
|
|
||||||
|
@Column(name = "prompt_sent", columnDefinition = "TEXT")
|
||||||
|
val promptSent: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "ai_response", columnDefinition = "TEXT")
|
||||||
|
val aiResponse: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "email_subject")
|
||||||
|
val emailSubject: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "email_body", columnDefinition = "TEXT")
|
||||||
|
val emailBody: String? = null,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
val status: DispatchStatus = DispatchStatus.PENDING,
|
||||||
|
|
||||||
|
@Column(name = "error_message")
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "dispatched_at", nullable = false)
|
||||||
|
val dispatchedAt: LocalDateTime = LocalDateTime.now()
|
||||||
|
) {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the dispatch status of an AI-generated email send attempt.
|
||||||
|
*/
|
||||||
|
enum class DispatchStatus {
|
||||||
|
PENDING,
|
||||||
|
SENT,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of a single email read from the shared company IMAP inbox.
|
||||||
|
* Used as context when building the AI prompt.
|
||||||
|
*/
|
||||||
|
data class EmailContext(
|
||||||
|
val from: String,
|
||||||
|
val subject: String,
|
||||||
|
val body: String,
|
||||||
|
val receivedAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed result of an AI-generated email response.
|
||||||
|
*/
|
||||||
|
data class ParsedAiResponse(
|
||||||
|
val subject: String,
|
||||||
|
val body: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.GenerationType
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import org.hibernate.annotations.CreationTimestamp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a fictional employee of "Condado Abaixo da Média SA".
|
||||||
|
* Each entity has a scheduled time to send AI-generated emails, a personality description,
|
||||||
|
* and a context window for reading recent emails via IMAP.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "virtual_entities")
|
||||||
|
class VirtualEntity(
|
||||||
|
@Column(nullable = false)
|
||||||
|
val name: String,
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
val email: String,
|
||||||
|
|
||||||
|
@Column(name = "job_title", nullable = false)
|
||||||
|
val jobTitle: String,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
val personality: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "schedule_cron")
|
||||||
|
val scheduleCron: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "context_window_days")
|
||||||
|
val contextWindowDays: Int = 3,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
val active: Boolean = true
|
||||||
|
) {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
var id: UUID? = null
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
var createdAt: LocalDateTime? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.condado.newsletter.repository
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for [DispatchLog] with custom query methods.
|
||||||
|
*/
|
||||||
|
interface DispatchLogRepository : JpaRepository<DispatchLog, UUID> {
|
||||||
|
|
||||||
|
/** Returns all dispatch logs for the given [VirtualEntity]. */
|
||||||
|
fun findAllByVirtualEntity(virtualEntity: VirtualEntity): List<DispatchLog>
|
||||||
|
|
||||||
|
/** Returns the most recent dispatch log for the given [VirtualEntity], or empty if none exist. */
|
||||||
|
fun findTopByVirtualEntityOrderByDispatchedAtDesc(virtualEntity: VirtualEntity): Optional<DispatchLog>
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.condado.newsletter.repository
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for [VirtualEntity] with custom query methods.
|
||||||
|
*/
|
||||||
|
interface VirtualEntityRepository : JpaRepository<VirtualEntity, UUID> {
|
||||||
|
|
||||||
|
/** Returns all entities where [VirtualEntity.active] is true. */
|
||||||
|
fun findAllByActiveTrue(): List<VirtualEntity>
|
||||||
|
|
||||||
|
/** Finds an entity by its unique email address. */
|
||||||
|
fun findByEmail(email: String): Optional<VirtualEntity>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.condado.newsletter.scheduler
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.DispatchStatus
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import com.condado.newsletter.repository.DispatchLogRepository
|
||||||
|
import com.condado.newsletter.service.AiService
|
||||||
|
import com.condado.newsletter.service.EmailReaderService
|
||||||
|
import com.condado.newsletter.service.EmailSenderService
|
||||||
|
import com.condado.newsletter.service.PromptBuilderService
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.scheduling.config.ScheduledTaskRegistrar
|
||||||
|
import org.springframework.scheduling.support.CronTrigger
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers and manages per-entity scheduled tasks using [SchedulingConfigurer].
|
||||||
|
* Refreshes task registrations every minute to pick up changes to active entities.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class EntityScheduler(
|
||||||
|
private val emailReaderService: EmailReaderService,
|
||||||
|
private val promptBuilderService: PromptBuilderService,
|
||||||
|
private val aiService: AiService,
|
||||||
|
private val emailSenderService: EmailSenderService,
|
||||||
|
private val dispatchLogRepository: DispatchLogRepository,
|
||||||
|
@Value("\${app.recipients:}") val recipients: String,
|
||||||
|
@Value("\${imap.inbox-folder:INBOX}") val inboxFolder: String
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the full email generation + send pipeline for the given [entity].
|
||||||
|
* If the entity is inactive, returns immediately.
|
||||||
|
* Always persists a [DispatchLog] with SENT or FAILED status.
|
||||||
|
*/
|
||||||
|
fun runPipeline(entity: VirtualEntity) {
|
||||||
|
if (!entity.active) return
|
||||||
|
|
||||||
|
val recipientList = recipients.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val emails = emailReaderService.readEmails(inboxFolder, entity.contextWindowDays)
|
||||||
|
val prompt = promptBuilderService.buildPrompt(entity, emails)
|
||||||
|
val aiResponse = aiService.generate(prompt)
|
||||||
|
emailSenderService.send(entity.email, recipientList, aiResponse.subject, aiResponse.body)
|
||||||
|
|
||||||
|
dispatchLogRepository.save(
|
||||||
|
DispatchLog(
|
||||||
|
virtualEntity = entity,
|
||||||
|
promptSent = prompt,
|
||||||
|
aiResponse = "${aiResponse.subject}\n${aiResponse.body}",
|
||||||
|
emailSubject = aiResponse.subject,
|
||||||
|
emailBody = aiResponse.body,
|
||||||
|
status = DispatchStatus.SENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log.info("Pipeline succeeded for entity '${entity.name}'")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("Pipeline failed for entity '${entity.name}': ${e.message}", e)
|
||||||
|
dispatchLogRepository.save(
|
||||||
|
DispatchLog(
|
||||||
|
virtualEntity = entity,
|
||||||
|
status = DispatchStatus.FAILED,
|
||||||
|
errorMessage = e.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
/** Thrown when the OpenAI API call fails. */
|
||||||
|
class AiServiceException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||||
|
|
||||||
|
/** Thrown when the AI response cannot be parsed into SUBJECT/BODY format. */
|
||||||
|
class AiParseException(message: String) : RuntimeException(message)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.ParsedAiResponse
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the OpenAI Chat Completions API to generate email content.
|
||||||
|
* Returns a [ParsedAiResponse] with the extracted subject and body.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AiService(
|
||||||
|
private val restClient: RestClient,
|
||||||
|
@Value("\${openai.api-key}") private val apiKey: String,
|
||||||
|
@Value("\${openai.model:gpt-4o}") private val model: String
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends [prompt] to the OpenAI API and returns the parsed subject + body.
|
||||||
|
* @throws [AiServiceException] on API errors.
|
||||||
|
* @throws [AiParseException] if the response format is unexpected.
|
||||||
|
*/
|
||||||
|
fun generate(prompt: String): ParsedAiResponse {
|
||||||
|
val rawText = try {
|
||||||
|
val request = ChatRequest(
|
||||||
|
model = model,
|
||||||
|
messages = listOf(ChatMessage(role = "user", content = prompt))
|
||||||
|
)
|
||||||
|
val json = restClient.post()
|
||||||
|
.uri("https://api.openai.com/v1/chat/completions")
|
||||||
|
.header("Authorization", "Bearer $apiKey")
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(String::class.java)
|
||||||
|
?: throw AiServiceException("OpenAI returned an empty response")
|
||||||
|
|
||||||
|
extractContent(json)
|
||||||
|
} catch (e: AiServiceException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("OpenAI API call failed: ${e.message}", e)
|
||||||
|
throw AiServiceException("OpenAI API call failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(rawText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the raw AI output into a [ParsedAiResponse].
|
||||||
|
* Expected format:
|
||||||
|
* ```
|
||||||
|
* SUBJECT: <subject line>
|
||||||
|
* BODY:
|
||||||
|
* <email body>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun parseResponse(raw: String): ParsedAiResponse {
|
||||||
|
val lines = raw.trim().lines()
|
||||||
|
|
||||||
|
val subjectLine = lines.firstOrNull { it.startsWith("SUBJECT:", ignoreCase = true) }
|
||||||
|
?: throw AiParseException("AI response missing SUBJECT line. Raw response: $raw")
|
||||||
|
|
||||||
|
val bodyStart = lines.indexOfFirst { it.trim().equals("BODY:", ignoreCase = true) }
|
||||||
|
if (bodyStart == -1) throw AiParseException("AI response missing BODY: section. Raw response: $raw")
|
||||||
|
|
||||||
|
val subject = subjectLine.replaceFirst(Regex("^SUBJECT:\\s*", RegexOption.IGNORE_CASE), "").trim()
|
||||||
|
val body = lines.drop(bodyStart + 1).joinToString("\n").trim()
|
||||||
|
|
||||||
|
return ParsedAiResponse(subject = subject, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal JSON helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun extractContent(json: String): String {
|
||||||
|
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
|
||||||
|
val tree = mapper.readTree(json)
|
||||||
|
return tree["choices"]?.firstOrNull()?.get("message")?.get("content")?.asText()
|
||||||
|
?.replace("\\n", "\n")
|
||||||
|
?: throw AiServiceException("Unexpected OpenAI response structure: $json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ChatRequest(val model: String, val messages: List<ChatMessage>)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ChatMessage(val role: String, val content: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the single-admin authentication flow.
|
||||||
|
* There is no user table — the password lives only in the [appPassword] environment variable.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AuthService(
|
||||||
|
private val jwtService: JwtService,
|
||||||
|
@Value("\${app.password}") private val appPassword: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Validates the given [password] against [appPassword].
|
||||||
|
* @return A signed JWT token string.
|
||||||
|
* @throws [UnauthorizedException] if the password is incorrect.
|
||||||
|
*/
|
||||||
|
fun login(password: String): String {
|
||||||
|
if (password != appPassword) {
|
||||||
|
throw UnauthorizedException("Invalid password")
|
||||||
|
}
|
||||||
|
return jwtService.generateToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.EmailContext
|
||||||
|
import jakarta.mail.Folder
|
||||||
|
import jakarta.mail.Message
|
||||||
|
import jakarta.mail.Session
|
||||||
|
import jakarta.mail.Store
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads recent emails from the shared IMAP inbox to use as AI context.
|
||||||
|
* Returns emails sorted chronologically (oldest first).
|
||||||
|
* On any error, logs the exception and returns an empty list.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class EmailReaderService(
|
||||||
|
@Value("\${imap.host:localhost}") private val imapHost: String = "localhost",
|
||||||
|
@Value("\${imap.port:993}") private val imapPort: Int = 993,
|
||||||
|
@Value("\${spring.mail.username:}") private val username: String = "",
|
||||||
|
@Value("\${spring.mail.password:}") private val password: String = "",
|
||||||
|
/** Factory function — can be replaced in tests to inject a mock Store */
|
||||||
|
val storeFactory: ((Properties, String, String) -> Store)? = null
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
constructor(storeFactory: (Properties, String, String) -> Store) : this(
|
||||||
|
imapHost = "localhost",
|
||||||
|
imapPort = 993,
|
||||||
|
username = "",
|
||||||
|
password = "",
|
||||||
|
storeFactory = storeFactory
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Test-only constructor accepting a pre-built mock Store */
|
||||||
|
internal constructor(storeFactory: () -> Store) : this(
|
||||||
|
imapHost = "localhost",
|
||||||
|
imapPort = 993,
|
||||||
|
username = "",
|
||||||
|
password = "",
|
||||||
|
storeFactory = { _, _, _ -> storeFactory() }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads emails from [folderName] received within the last [contextWindowDays] days.
|
||||||
|
*
|
||||||
|
* @return list of [EmailContext] objects sorted oldest-first, or empty list on error.
|
||||||
|
*/
|
||||||
|
fun readEmails(folderName: String, contextWindowDays: Int): List<EmailContext> {
|
||||||
|
return try {
|
||||||
|
val store = openStore()
|
||||||
|
val folder = store.getFolder(folderName)
|
||||||
|
folder.open(Folder.READ_ONLY)
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - contextWindowDays.toLong() * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
folder.getMessages()
|
||||||
|
.filter { msg ->
|
||||||
|
val date = msg.receivedDate ?: msg.sentDate
|
||||||
|
date != null && date.after(cutoff)
|
||||||
|
}
|
||||||
|
.sortedBy { it.receivedDate ?: it.sentDate }
|
||||||
|
.map { msg -> msg.toEmailContext() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("Failed to read emails from IMAP folder '$folderName': ${e.message}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openStore(): Store {
|
||||||
|
val props = Properties().apply {
|
||||||
|
put("mail.store.protocol", "imaps")
|
||||||
|
put("mail.imaps.host", imapHost)
|
||||||
|
put("mail.imaps.port", imapPort.toString())
|
||||||
|
}
|
||||||
|
return if (storeFactory != null) {
|
||||||
|
storeFactory!!.invoke(props, username, password)
|
||||||
|
} else {
|
||||||
|
val session = Session.getInstance(props)
|
||||||
|
session.getStore("imaps").also { it.connect(imapHost, imapPort, username, password) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Message.toEmailContext(): EmailContext {
|
||||||
|
val from = from?.firstOrNull()?.toString() ?: "unknown"
|
||||||
|
val date = (receivedDate ?: sentDate ?: Date())
|
||||||
|
.toInstant()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDateTime()
|
||||||
|
val bodyText = extractText(content, contentType)
|
||||||
|
return EmailContext(
|
||||||
|
from = from,
|
||||||
|
subject = subject ?: "(no subject)",
|
||||||
|
body = bodyText,
|
||||||
|
receivedAt = date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractText(content: Any?, contentType: String?): String {
|
||||||
|
val raw = when (content) {
|
||||||
|
is String -> content
|
||||||
|
else -> content?.toString() ?: ""
|
||||||
|
}
|
||||||
|
val isHtml = contentType?.contains("html", ignoreCase = true) == true ||
|
||||||
|
raw.contains(Regex("<[a-zA-Z][^>]*>"))
|
||||||
|
return if (isHtml) stripHtml(raw) else raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripHtml(html: String): String =
|
||||||
|
html.replace(Regex("<[^>]+>"), " ")
|
||||||
|
.replace(Regex("\\s{2,}"), " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import jakarta.mail.internet.InternetAddress
|
||||||
|
import jakarta.mail.internet.MimeMessage
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends AI-generated emails via SMTP using Spring's [JavaMailSender].
|
||||||
|
* Each email is sent as multipart (text/plain + text/html).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class EmailSenderService(private val mailSender: JavaMailSender) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an email from a virtual entity to all configured recipients.
|
||||||
|
*
|
||||||
|
* @param from Sender email address (the virtual entity's email).
|
||||||
|
* @param to List of recipient email addresses.
|
||||||
|
* @param subject Email subject line.
|
||||||
|
* @param body Email body (may be plain text or simple HTML).
|
||||||
|
*/
|
||||||
|
fun send(from: String, to: List<String>, subject: String, body: String) {
|
||||||
|
log.info("Sending email from='$from' to=$to subject='$subject'")
|
||||||
|
val message: MimeMessage = mailSender.createMimeMessage()
|
||||||
|
val helper = MimeMessageHelper(message, true, "UTF-8")
|
||||||
|
helper.setFrom(InternetAddress(from))
|
||||||
|
helper.setTo(to.toTypedArray())
|
||||||
|
helper.setSubject(subject)
|
||||||
|
// Send as both plain text and HTML for maximum compatibility
|
||||||
|
val plainText = body.replace(Regex("<[^>]+>"), "").trim()
|
||||||
|
val htmlBody = if (body.contains(Regex("<[a-zA-Z]"))) body else "<pre>$body</pre>"
|
||||||
|
helper.setText(plainText, htmlBody)
|
||||||
|
mailSender.send(message)
|
||||||
|
log.info("Email sent successfully from='$from' subject='$subject'")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityCreateDto
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityResponseDto
|
||||||
|
import com.condado.newsletter.dto.VirtualEntityUpdateDto
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import com.condado.newsletter.repository.DispatchLogRepository
|
||||||
|
import com.condado.newsletter.repository.VirtualEntityRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing [VirtualEntity] lifecycle: create, read, update, and soft-delete.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class EntityService(
|
||||||
|
private val virtualEntityRepository: VirtualEntityRepository,
|
||||||
|
private val dispatchLogRepository: DispatchLogRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Returns all virtual entities. */
|
||||||
|
fun findAll(): List<VirtualEntityResponseDto> =
|
||||||
|
virtualEntityRepository.findAll().map { VirtualEntityResponseDto.from(it) }
|
||||||
|
|
||||||
|
/** Returns one entity by ID, or null if not found. */
|
||||||
|
fun findById(id: UUID): VirtualEntityResponseDto? =
|
||||||
|
virtualEntityRepository.findById(id).map { VirtualEntityResponseDto.from(it) }.orElse(null)
|
||||||
|
|
||||||
|
/** Creates a new virtual entity. */
|
||||||
|
@Transactional
|
||||||
|
fun create(dto: VirtualEntityCreateDto): VirtualEntityResponseDto {
|
||||||
|
val entity = VirtualEntity(
|
||||||
|
name = dto.name,
|
||||||
|
email = dto.email,
|
||||||
|
jobTitle = dto.jobTitle,
|
||||||
|
personality = dto.personality,
|
||||||
|
scheduleCron = dto.scheduleCron,
|
||||||
|
contextWindowDays = dto.contextWindowDays
|
||||||
|
)
|
||||||
|
return VirtualEntityResponseDto.from(virtualEntityRepository.save(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies partial updates to an existing entity. Returns null if not found. */
|
||||||
|
@Transactional
|
||||||
|
fun update(id: UUID, dto: VirtualEntityUpdateDto): VirtualEntityResponseDto? {
|
||||||
|
val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null
|
||||||
|
val updated = VirtualEntity(
|
||||||
|
name = dto.name ?: existing.name,
|
||||||
|
email = dto.email ?: existing.email,
|
||||||
|
jobTitle = dto.jobTitle ?: existing.jobTitle,
|
||||||
|
personality = dto.personality ?: existing.personality,
|
||||||
|
scheduleCron = dto.scheduleCron ?: existing.scheduleCron,
|
||||||
|
contextWindowDays = dto.contextWindowDays ?: existing.contextWindowDays,
|
||||||
|
active = dto.active ?: existing.active
|
||||||
|
).apply { this.id = existing.id }
|
||||||
|
return VirtualEntityResponseDto.from(virtualEntityRepository.save(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-deletes an entity by setting active = false. Returns null if not found. */
|
||||||
|
@Transactional
|
||||||
|
fun deactivate(id: UUID): VirtualEntityResponseDto? {
|
||||||
|
val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null
|
||||||
|
val deactivated = VirtualEntity(
|
||||||
|
name = existing.name,
|
||||||
|
email = existing.email,
|
||||||
|
jobTitle = existing.jobTitle,
|
||||||
|
personality = existing.personality,
|
||||||
|
scheduleCron = existing.scheduleCron,
|
||||||
|
contextWindowDays = existing.contextWindowDays,
|
||||||
|
active = false
|
||||||
|
).apply { this.id = existing.id }
|
||||||
|
return VirtualEntityResponseDto.from(virtualEntityRepository.save(deactivated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the raw entity for use in the scheduler pipeline. Returns null if not found. */
|
||||||
|
fun findRawById(id: UUID): VirtualEntity? =
|
||||||
|
virtualEntityRepository.findById(id).orElse(null)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException
|
||||||
|
import io.jsonwebtoken.Jwts
|
||||||
|
import io.jsonwebtoken.security.Keys
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles JWT token creation and validation using JJWT 0.12.x.
|
||||||
|
* The secret and expiration are read from environment variables.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class JwtService(
|
||||||
|
@Value("\${app.jwt.secret}") val secret: String,
|
||||||
|
@Value("\${app.jwt.expiration-ms}") val expirationMs: Long
|
||||||
|
) {
|
||||||
|
private val signingKey by lazy {
|
||||||
|
Keys.hmacShaKeyFor(secret.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a new signed JWT token valid for [expirationMs] milliseconds. */
|
||||||
|
fun generateToken(): String {
|
||||||
|
val now = Date()
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject("admin")
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(Date(now.time + expirationMs))
|
||||||
|
.signWith(signingKey)
|
||||||
|
.compact()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a JWT token.
|
||||||
|
* @return `true` if the token is valid and not expired; `false` otherwise.
|
||||||
|
*/
|
||||||
|
fun validateToken(token: String): Boolean = try {
|
||||||
|
Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token)
|
||||||
|
true
|
||||||
|
} catch (e: ExpiredJwtException) {
|
||||||
|
false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.EmailContext
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single authoritative place in the codebase where AI prompts are constructed.
|
||||||
|
* No other class may build or modify prompt strings.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class PromptBuilderService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full prompt to be sent to the AI, based on the entity's profile
|
||||||
|
* and the list of recent emails from the company inbox.
|
||||||
|
*
|
||||||
|
* @param entity The virtual employee whose turn it is to send an email.
|
||||||
|
* @param emailContext Recent emails from the IMAP inbox (oldest-first).
|
||||||
|
* @return The complete prompt string ready to send to the OpenAI API.
|
||||||
|
*/
|
||||||
|
fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String {
|
||||||
|
val emailSection = if (emailContext.isEmpty()) {
|
||||||
|
"(No recent emails in the inbox.)"
|
||||||
|
} else {
|
||||||
|
emailContext.joinToString("\n\n") { email ->
|
||||||
|
"""
|
||||||
|
|From: ${email.from}
|
||||||
|
|Subject: ${email.subject}
|
||||||
|
|Received: ${email.receivedAt}
|
||||||
|
|
|
||||||
|
|${email.body}
|
||||||
|
""".trimMargin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
|You are ${entity.name}, ${entity.jobTitle} at "Condado Abaixo da Média SA".
|
||||||
|
|
|
||||||
|
|Your personality: ${entity.personality ?: "Professional and formal."}
|
||||||
|
|
|
||||||
|
|IMPORTANT TONE RULE: You must write in an extremely formal, bureaucratic, corporate tone —
|
||||||
|
|as if writing an official memo. However, the actual content of the email must be completely
|
||||||
|
|casual, trivial, or nonsensical — as if talking to close friends about mundane things.
|
||||||
|
|The contrast between the formal tone and the casual content is intentional and essential.
|
||||||
|
|
|
||||||
|
|Here are the most recent emails from the company inbox (last ${entity.contextWindowDays} days)
|
||||||
|
|for context:
|
||||||
|
|
|
||||||
|
|$emailSection
|
||||||
|
|
|
||||||
|
|Write a new email to be sent to the company group, continuing the conversation naturally.
|
||||||
|
|Reply or react to the recent emails if relevant. Sign off as ${entity.name}, ${entity.jobTitle}.
|
||||||
|
|
|
||||||
|
|Format your response exactly as:
|
||||||
|
|SUBJECT: <subject line here>
|
||||||
|
|BODY:
|
||||||
|
|<full email body here>
|
||||||
|
""".trimMargin()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
|
||||||
|
/** Thrown when an authentication attempt fails (wrong password or missing token). */
|
||||||
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
|
class UnauthorizedException(message: String) : RuntimeException(message)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.scheduler.EntityScheduler
|
||||||
|
import com.condado.newsletter.service.JwtService
|
||||||
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import jakarta.servlet.http.Cookie
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class AuthControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var jwtService: JwtService
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var entityScheduler: EntityScheduler
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndSetCookie_when_correctPasswordPosted() {
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"password":"testpassword"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(cookie().exists("jwt"))
|
||||||
|
.andExpect(cookie().httpOnly("jwt", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return401_when_wrongPasswordPosted() {
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"password":"wrongpassword"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200_when_getMeWithValidCookie() {
|
||||||
|
val token = jwtService.generateToken()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/auth/me")
|
||||||
|
.cookie(Cookie("jwt", token))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return401_when_getMeWithNoCookie() {
|
||||||
|
mockMvc.perform(get("/api/auth/me"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return401_when_protectedEndpointAccessedWithoutCookie() {
|
||||||
|
mockMvc.perform(get("/api/v1/virtual-entities"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.DispatchStatus
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import com.condado.newsletter.repository.DispatchLogRepository
|
||||||
|
import com.condado.newsletter.repository.VirtualEntityRepository
|
||||||
|
import com.condado.newsletter.scheduler.EntityScheduler
|
||||||
|
import com.condado.newsletter.service.JwtService
|
||||||
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
|
import jakarta.servlet.http.Cookie
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class DispatchLogControllerTest {
|
||||||
|
|
||||||
|
@Autowired lateinit var mockMvc: MockMvc
|
||||||
|
@Autowired lateinit var virtualEntityRepository: VirtualEntityRepository
|
||||||
|
@Autowired lateinit var dispatchLogRepository: DispatchLogRepository
|
||||||
|
@Autowired lateinit var jwtService: JwtService
|
||||||
|
@MockkBean lateinit var entityScheduler: EntityScheduler
|
||||||
|
|
||||||
|
private fun authCookie() = Cookie("jwt", jwtService.generateToken())
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() {
|
||||||
|
dispatchLogRepository.deleteAll()
|
||||||
|
virtualEntityRepository.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndAllLogs_when_getAllLogs() {
|
||||||
|
val entity = virtualEntityRepository.save(VirtualEntity(name = "Log Entity", email = "log@condado.com", jobTitle = "Logger"))
|
||||||
|
dispatchLogRepository.save(DispatchLog(virtualEntity = entity, emailSubject = "Test Subject", status = DispatchStatus.SENT))
|
||||||
|
mockMvc.perform(get("/api/v1/dispatch-logs").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].emailSubject").value("Test Subject"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndFilteredLogs_when_getByEntityId() {
|
||||||
|
val entity1 = virtualEntityRepository.save(VirtualEntity(name = "Entity One", email = "one@condado.com", jobTitle = "Job One"))
|
||||||
|
val entity2 = virtualEntityRepository.save(VirtualEntity(name = "Entity Two", email = "two@condado.com", jobTitle = "Job Two"))
|
||||||
|
dispatchLogRepository.save(DispatchLog(virtualEntity = entity1, emailSubject = "Log One", status = DispatchStatus.SENT))
|
||||||
|
dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED))
|
||||||
|
mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].emailSubject").value("Log One"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.condado.newsletter.controller
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import com.condado.newsletter.repository.VirtualEntityRepository
|
||||||
|
import com.condado.newsletter.scheduler.EntityScheduler
|
||||||
|
import com.condado.newsletter.service.JwtService
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.runs
|
||||||
|
import io.mockk.verify
|
||||||
|
import jakarta.servlet.http.Cookie
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class VirtualEntityControllerTest {
|
||||||
|
|
||||||
|
@Autowired lateinit var mockMvc: MockMvc
|
||||||
|
@Autowired lateinit var virtualEntityRepository: VirtualEntityRepository
|
||||||
|
@Autowired lateinit var jwtService: JwtService
|
||||||
|
@MockkBean lateinit var entityScheduler: EntityScheduler
|
||||||
|
|
||||||
|
private val objectMapper = ObjectMapper()
|
||||||
|
|
||||||
|
private fun authCookie() = Cookie("jwt", jwtService.generateToken())
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUp() { virtualEntityRepository.deleteAll() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return201AndBody_when_postWithValidPayload() {
|
||||||
|
val payload = mapOf("name" to "Fulano da Silva", "email" to "fulano@condado.com", "jobTitle" to "Diretor de Nada")
|
||||||
|
mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
|
||||||
|
.andExpect(status().isCreated).andExpect(jsonPath("$.name").value("Fulano da Silva")).andExpect(jsonPath("$.id").isNotEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return400_when_postWithMissingRequiredField() {
|
||||||
|
val payload = mapOf("name" to "Fulano")
|
||||||
|
mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndList_when_getAllEntities() {
|
||||||
|
virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "test@condado.com", jobTitle = "Tester"))
|
||||||
|
mockMvc.perform(get("/api/v1/virtual-entities").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].name").value("Test Entity"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndEntity_when_getById() {
|
||||||
|
val entity = virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "entity@condado.com", jobTitle = "Test Job"))
|
||||||
|
mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$.name").value("Test Entity"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return404_when_getByIdNotFound() {
|
||||||
|
mockMvc.perform(get("/api/v1/virtual-entities/${java.util.UUID.randomUUID()}").cookie(authCookie()))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200_when_putWithValidPayload() {
|
||||||
|
val entity = virtualEntityRepository.save(VirtualEntity(name = "Old Name", email = "old@condado.com", jobTitle = "Old Job"))
|
||||||
|
mockMvc.perform(put("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content("""{"name":"New Name"}"""))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$.name").value("New Name")).andExpect(jsonPath("$.email").value("old@condado.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200AndDeactivated_when_delete() {
|
||||||
|
val entity = virtualEntityRepository.save(VirtualEntity(name = "Active Entity", email = "active@condado.com", jobTitle = "Active Job"))
|
||||||
|
mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk).andExpect(jsonPath("$.active").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_return200_when_triggerEndpointCalled() {
|
||||||
|
val entity = virtualEntityRepository.save(VirtualEntity(name = "Trigger Entity", email = "trigger@condado.com", jobTitle = "Trigger Job"))
|
||||||
|
every { entityScheduler.runPipeline(any()) } just runs
|
||||||
|
mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger").cookie(authCookie()))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
verify(exactly = 1) { entityScheduler.runPipeline(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
class EntityMappingTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var entityManager: TestEntityManager
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_persistVirtualEntity_when_allFieldsProvided() {
|
||||||
|
val entity = VirtualEntity(
|
||||||
|
name = "João Silva",
|
||||||
|
email = "joao@condado.com",
|
||||||
|
jobTitle = "Chief Nonsense Officer",
|
||||||
|
personality = "Extremely formal but talks about cats",
|
||||||
|
scheduleCron = "0 9 * * 1",
|
||||||
|
contextWindowDays = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
val saved = entityManager.persistAndFlush(entity)
|
||||||
|
|
||||||
|
assertThat(saved.id).isNotNull()
|
||||||
|
assertThat(saved.name).isEqualTo("João Silva")
|
||||||
|
assertThat(saved.email).isEqualTo("joao@condado.com")
|
||||||
|
assertThat(saved.jobTitle).isEqualTo("Chief Nonsense Officer")
|
||||||
|
assertThat(saved.personality).isEqualTo("Extremely formal but talks about cats")
|
||||||
|
assertThat(saved.scheduleCron).isEqualTo("0 9 * * 1")
|
||||||
|
assertThat(saved.contextWindowDays).isEqualTo(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_enforceUniqueEmail_when_duplicateEmailInserted() {
|
||||||
|
val entity1 = VirtualEntity(name = "First", email = "dup@condado.com", jobTitle = "Dev")
|
||||||
|
val entity2 = VirtualEntity(name = "Second", email = "dup@condado.com", jobTitle = "Dev")
|
||||||
|
|
||||||
|
entityManager.persistAndFlush(entity1)
|
||||||
|
|
||||||
|
assertThrows<Exception> {
|
||||||
|
entityManager.persistAndFlush(entity2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_persistDispatchLog_when_linkedToVirtualEntity() {
|
||||||
|
val entity = VirtualEntity(name = "Maria Santos", email = "maria@condado.com", jobTitle = "COO")
|
||||||
|
val savedEntity = entityManager.persistAndFlush(entity)
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val log = DispatchLog(
|
||||||
|
virtualEntity = entityManager.find(VirtualEntity::class.java, savedEntity.id),
|
||||||
|
promptSent = "Test prompt content",
|
||||||
|
aiResponse = "SUBJECT: Test\nBODY:\nTest body",
|
||||||
|
emailSubject = "Test Subject",
|
||||||
|
emailBody = "Test body content",
|
||||||
|
status = DispatchStatus.SENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val savedLog = entityManager.persistAndFlush(log)
|
||||||
|
|
||||||
|
assertThat(savedLog.id).isNotNull()
|
||||||
|
assertThat(savedLog.virtualEntity.id).isEqualTo(savedEntity.id)
|
||||||
|
assertThat(savedLog.status).isEqualTo(DispatchStatus.SENT)
|
||||||
|
assertThat(savedLog.promptSent).isEqualTo("Test prompt content")
|
||||||
|
assertThat(savedLog.emailSubject).isEqualTo("Test Subject")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_setCreatedAtAutomatically_when_virtualEntitySaved() {
|
||||||
|
val before = LocalDateTime.now().minusSeconds(1)
|
||||||
|
|
||||||
|
val entity = VirtualEntity(name = "Auto Time", email = "time@condado.com", jobTitle = "Tester")
|
||||||
|
val saved = entityManager.persistAndFlush(entity)
|
||||||
|
|
||||||
|
val after = LocalDateTime.now().plusSeconds(1)
|
||||||
|
|
||||||
|
assertThat(saved.createdAt).isNotNull()
|
||||||
|
assertThat(saved.createdAt).isAfterOrEqualTo(before)
|
||||||
|
assertThat(saved.createdAt).isBeforeOrEqualTo(after)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_defaultActiveToTrue_when_virtualEntityCreated() {
|
||||||
|
val entity = VirtualEntity(name = "Default Active", email = "active@condado.com", jobTitle = "CEO")
|
||||||
|
val saved = entityManager.persistAndFlush(entity)
|
||||||
|
|
||||||
|
assertThat(saved.active).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.condado.newsletter.repository
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.DispatchStatus
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
class RepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var entityManager: TestEntityManager
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var virtualEntityRepository: VirtualEntityRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var dispatchLogRepository: DispatchLogRepository
|
||||||
|
|
||||||
|
private lateinit var activeEntity: VirtualEntity
|
||||||
|
private lateinit var inactiveEntity: VirtualEntity
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
activeEntity = entityManager.persistAndFlush(
|
||||||
|
VirtualEntity(name = "Active One", email = "active@condado.com", jobTitle = "CEO", active = true)
|
||||||
|
)
|
||||||
|
inactiveEntity = entityManager.persistAndFlush(
|
||||||
|
VirtualEntity(name = "Inactive One", email = "inactive@condado.com", jobTitle = "CTO", active = false)
|
||||||
|
)
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled() {
|
||||||
|
val result = virtualEntityRepository.findAllByActiveTrue()
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat(result[0].email).isEqualTo("active@condado.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_findEntityByEmail_when_emailExists() {
|
||||||
|
val result = virtualEntityRepository.findByEmail("active@condado.com")
|
||||||
|
|
||||||
|
assertThat(result).isPresent
|
||||||
|
assertThat(result.get().name).isEqualTo("Active One")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnEmptyOptional_when_emailNotFound() {
|
||||||
|
val result = virtualEntityRepository.findByEmail("nobody@condado.com")
|
||||||
|
|
||||||
|
assertThat(result).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled() {
|
||||||
|
val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id)
|
||||||
|
entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT))
|
||||||
|
entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED))
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val result = dispatchLogRepository.findAllByVirtualEntity(entity)
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled() {
|
||||||
|
val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id)
|
||||||
|
val firstLog = entityManager.persistAndFlush(
|
||||||
|
DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT)
|
||||||
|
)
|
||||||
|
Thread.sleep(10)
|
||||||
|
val latestLog = entityManager.persistAndFlush(
|
||||||
|
DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED)
|
||||||
|
)
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val result = dispatchLogRepository.findTopByVirtualEntityOrderByDispatchedAtDesc(entity)
|
||||||
|
|
||||||
|
assertThat(result).isPresent
|
||||||
|
assertThat(result.get().id).isEqualTo(latestLog.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.condado.newsletter.scheduler
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.DispatchLog
|
||||||
|
import com.condado.newsletter.model.DispatchStatus
|
||||||
|
import com.condado.newsletter.model.EmailContext
|
||||||
|
import com.condado.newsletter.model.ParsedAiResponse
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import com.condado.newsletter.repository.DispatchLogRepository
|
||||||
|
import com.condado.newsletter.service.AiService
|
||||||
|
import com.condado.newsletter.service.AiServiceException
|
||||||
|
import com.condado.newsletter.service.EmailReaderService
|
||||||
|
import com.condado.newsletter.service.EmailSenderService
|
||||||
|
import com.condado.newsletter.service.PromptBuilderService
|
||||||
|
import io.mockk.*
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class EntitySchedulerTest {
|
||||||
|
|
||||||
|
private val emailReaderService: EmailReaderService = mockk()
|
||||||
|
private val promptBuilderService: PromptBuilderService = mockk()
|
||||||
|
private val aiService: AiService = mockk()
|
||||||
|
private val emailSenderService: EmailSenderService = mockk()
|
||||||
|
private val dispatchLogRepository: DispatchLogRepository = mockk()
|
||||||
|
|
||||||
|
private lateinit var scheduler: EntityScheduler
|
||||||
|
|
||||||
|
private val entity = VirtualEntity(
|
||||||
|
name = "Joao Gerente",
|
||||||
|
email = "joao@condado.com",
|
||||||
|
jobTitle = "Gerente de Nada",
|
||||||
|
personality = "Muito formal",
|
||||||
|
scheduleCron = "0 9 * * *",
|
||||||
|
contextWindowDays = 3,
|
||||||
|
active = true
|
||||||
|
).apply { id = UUID.randomUUID() }
|
||||||
|
|
||||||
|
private val inactiveEntity = VirtualEntity(
|
||||||
|
name = "Maria Inativa",
|
||||||
|
email = "maria@condado.com",
|
||||||
|
jobTitle = "Consultora de Vibe",
|
||||||
|
active = false
|
||||||
|
).apply { id = UUID.randomUUID() }
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
scheduler = EntityScheduler(
|
||||||
|
emailReaderService = emailReaderService,
|
||||||
|
promptBuilderService = promptBuilderService,
|
||||||
|
aiService = aiService,
|
||||||
|
emailSenderService = emailSenderService,
|
||||||
|
dispatchLogRepository = dispatchLogRepository,
|
||||||
|
recipients = "recipient@example.com",
|
||||||
|
inboxFolder = "INBOX"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_runFullPipeline_when_entityIsTriggered() {
|
||||||
|
val emails = listOf(EmailContext("sender@example.com", "Subject", "Body", LocalDateTime.now()))
|
||||||
|
val prompt = "Generated prompt"
|
||||||
|
val aiResponse = ParsedAiResponse(subject = "Weekly memo", body = "Dear colleagues...")
|
||||||
|
|
||||||
|
every { emailReaderService.readEmails("INBOX", 3) } returns emails
|
||||||
|
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
|
||||||
|
every { aiService.generate(prompt) } returns aiResponse
|
||||||
|
every { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) } just runs
|
||||||
|
every { dispatchLogRepository.save(any()) } answers { firstArg() }
|
||||||
|
|
||||||
|
scheduler.runPipeline(entity)
|
||||||
|
|
||||||
|
verify(exactly = 1) { emailReaderService.readEmails("INBOX", 3) }
|
||||||
|
verify(exactly = 1) { promptBuilderService.buildPrompt(entity, emails) }
|
||||||
|
verify(exactly = 1) { aiService.generate(prompt) }
|
||||||
|
verify(exactly = 1) { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_saveDispatchLogWithStatusSent_when_pipelineSucceeds() {
|
||||||
|
val emails = emptyList<EmailContext>()
|
||||||
|
val prompt = "prompt"
|
||||||
|
val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body")
|
||||||
|
val savedLog = slot<DispatchLog>()
|
||||||
|
|
||||||
|
every { emailReaderService.readEmails("INBOX", 3) } returns emails
|
||||||
|
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
|
||||||
|
every { aiService.generate(prompt) } returns aiResponse
|
||||||
|
every { emailSenderService.send(any(), any(), any(), any()) } just runs
|
||||||
|
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
|
||||||
|
|
||||||
|
scheduler.runPipeline(entity)
|
||||||
|
|
||||||
|
assertThat(savedLog.isCaptured).isTrue()
|
||||||
|
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.SENT)
|
||||||
|
assertThat(savedLog.captured.emailSubject).isEqualTo("Subject")
|
||||||
|
assertThat(savedLog.captured.emailBody).isEqualTo("Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_saveDispatchLogWithStatusFailed_when_aiServiceThrows() {
|
||||||
|
val emails = emptyList<EmailContext>()
|
||||||
|
val prompt = "prompt"
|
||||||
|
val savedLog = slot<DispatchLog>()
|
||||||
|
|
||||||
|
every { emailReaderService.readEmails("INBOX", 3) } returns emails
|
||||||
|
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
|
||||||
|
every { aiService.generate(prompt) } throws AiServiceException("API error")
|
||||||
|
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
|
||||||
|
|
||||||
|
scheduler.runPipeline(entity)
|
||||||
|
|
||||||
|
assertThat(savedLog.isCaptured).isTrue()
|
||||||
|
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED)
|
||||||
|
assertThat(savedLog.captured.errorMessage).contains("API error")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_saveDispatchLogWithStatusFailed_when_emailSenderThrows() {
|
||||||
|
val emails = emptyList<EmailContext>()
|
||||||
|
val prompt = "prompt"
|
||||||
|
val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body")
|
||||||
|
val savedLog = slot<DispatchLog>()
|
||||||
|
|
||||||
|
every { emailReaderService.readEmails("INBOX", 3) } returns emails
|
||||||
|
every { promptBuilderService.buildPrompt(entity, emails) } returns prompt
|
||||||
|
every { aiService.generate(prompt) } returns aiResponse
|
||||||
|
every { emailSenderService.send(any(), any(), any(), any()) } throws RuntimeException("SMTP error")
|
||||||
|
every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() }
|
||||||
|
|
||||||
|
scheduler.runPipeline(entity)
|
||||||
|
|
||||||
|
assertThat(savedLog.isCaptured).isTrue()
|
||||||
|
assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED)
|
||||||
|
assertThat(savedLog.captured.errorMessage).contains("SMTP error")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_notTrigger_when_entityIsInactive() {
|
||||||
|
scheduler.runPipeline(inactiveEntity)
|
||||||
|
|
||||||
|
verify { emailReaderService wasNot called }
|
||||||
|
verify { promptBuilderService wasNot called }
|
||||||
|
verify { aiService wasNot called }
|
||||||
|
verify { emailSenderService wasNot called }
|
||||||
|
verify { dispatchLogRepository wasNot called }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.ParsedAiResponse
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
|
||||||
|
class AiServiceTest {
|
||||||
|
|
||||||
|
private val mockRestClient: RestClient = mockk()
|
||||||
|
private val mockRequestBodyUriSpec: RestClient.RequestBodyUriSpec = mockk()
|
||||||
|
private val mockRequestBodySpec: RestClient.RequestBodySpec = mockk()
|
||||||
|
private val mockResponseSpec: RestClient.ResponseSpec = mockk()
|
||||||
|
|
||||||
|
private val service = AiService(
|
||||||
|
restClient = mockRestClient,
|
||||||
|
apiKey = "test-key",
|
||||||
|
model = "gpt-4o"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnAiResponseText_when_apiCallSucceeds() {
|
||||||
|
val rawResponse = "SUBJECT: Test Subject\nBODY:\nTest body content"
|
||||||
|
stubRestClient(rawResponse.replace("\n", "\\n"))
|
||||||
|
|
||||||
|
val result = service.generate("My test prompt")
|
||||||
|
|
||||||
|
assertThat(result.subject).isEqualTo("Test Subject")
|
||||||
|
assertThat(result.body).isEqualTo("Test body content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_throwAiServiceException_when_apiReturnsError() {
|
||||||
|
every { mockRestClient.post() } throws RuntimeException("API unavailable")
|
||||||
|
|
||||||
|
assertThrows<AiServiceException> {
|
||||||
|
service.generate("My prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_extractSubjectAndBody_when_responseIsWellFormatted() {
|
||||||
|
val raw = "SUBJECT: Weekly Update\nBODY:\nDear colleagues,\n\nPlease note the snacks."
|
||||||
|
val result = service.parseResponse(raw)
|
||||||
|
|
||||||
|
assertThat(result.subject).isEqualTo("Weekly Update")
|
||||||
|
assertThat(result.body).isEqualTo("Dear colleagues,\n\nPlease note the snacks.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_throwParseException_when_responseIsMissingSubjectLine() {
|
||||||
|
val raw = "BODY:\nSome body without a subject"
|
||||||
|
|
||||||
|
assertThrows<AiParseException> {
|
||||||
|
service.parseResponse(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_throwParseException_when_responseIsMissingBodySection() {
|
||||||
|
val raw = "SUBJECT: Some Subject\nNo body section here"
|
||||||
|
|
||||||
|
assertThrows<AiParseException> {
|
||||||
|
service.parseResponse(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun stubRestClient(responseText: String) {
|
||||||
|
every { mockRestClient.post() } returns mockRequestBodyUriSpec
|
||||||
|
every { mockRequestBodyUriSpec.uri(any<String>()) } returns mockRequestBodySpec
|
||||||
|
every { mockRequestBodySpec.header(any(), any()) } returns mockRequestBodySpec
|
||||||
|
every { mockRequestBodySpec.body(any<Any>()) } returns mockRequestBodySpec
|
||||||
|
every { mockRequestBodySpec.retrieve() } returns mockResponseSpec
|
||||||
|
every { mockResponseSpec.body(String::class.java) } returns """
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{ "message": { "content": "$responseText" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
|
||||||
|
class AuthServiceTest {
|
||||||
|
|
||||||
|
private val jwtService: JwtService = mockk()
|
||||||
|
private lateinit var authService: AuthService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
authService = AuthService(
|
||||||
|
jwtService = jwtService,
|
||||||
|
appPassword = "testpassword"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnJwtToken_when_correctPasswordProvided() {
|
||||||
|
every { jwtService.generateToken() } returns "jwt-token"
|
||||||
|
|
||||||
|
val token = authService.login("testpassword")
|
||||||
|
|
||||||
|
assertThat(token).isEqualTo("jwt-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_throwUnauthorizedException_when_wrongPasswordProvided() {
|
||||||
|
assertThrows<UnauthorizedException> {
|
||||||
|
authService.login("wrongpassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnValidClaims_when_jwtTokenParsed() {
|
||||||
|
val realJwtService = JwtService(
|
||||||
|
secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters",
|
||||||
|
expirationMs = 86400000L
|
||||||
|
)
|
||||||
|
val token = realJwtService.generateToken()
|
||||||
|
|
||||||
|
assertThat(realJwtService.validateToken(token)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnFalse_when_expiredTokenValidated() {
|
||||||
|
val realJwtService = JwtService(
|
||||||
|
secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters",
|
||||||
|
expirationMs = 1L
|
||||||
|
)
|
||||||
|
val token = realJwtService.generateToken()
|
||||||
|
|
||||||
|
Thread.sleep(10) // wait for expiration
|
||||||
|
|
||||||
|
assertThat(realJwtService.validateToken(token)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import jakarta.mail.Folder
|
||||||
|
import jakarta.mail.Message
|
||||||
|
import jakarta.mail.Session
|
||||||
|
import jakarta.mail.Store
|
||||||
|
import jakarta.mail.internet.InternetAddress
|
||||||
|
import jakarta.mail.internet.MimeMessage
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class EmailReaderServiceTest {
|
||||||
|
|
||||||
|
private val mockStore: Store = mockk(relaxed = true)
|
||||||
|
private val service = EmailReaderService(storeFactory = { mockStore })
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnEmailsSortedChronologically_when_multipleEmailsFetched() {
|
||||||
|
val folder: Folder = mockk(relaxed = true)
|
||||||
|
val session: Session = mockk()
|
||||||
|
|
||||||
|
val newer = buildMimeMessage(session, "Newer email", "bob@x.com", Date())
|
||||||
|
val older = buildMimeMessage(session, "Older email", "alice@x.com", Date(System.currentTimeMillis() - 100_000))
|
||||||
|
|
||||||
|
every { mockStore.getFolder(any<String>()) } returns folder
|
||||||
|
every { folder.messageCount } returns 2
|
||||||
|
every { folder.getMessages() } returns arrayOf(newer, older)
|
||||||
|
|
||||||
|
val result = service.readEmails("INBOX", contextWindowDays = 10)
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
assertThat(result[0].subject).isEqualTo("Older email")
|
||||||
|
assertThat(result[1].subject).isEqualTo("Newer email")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnEmptyList_when_imapConnectionFails() {
|
||||||
|
every { mockStore.getFolder(any<String>()) } throws RuntimeException("Connection refused")
|
||||||
|
|
||||||
|
val result = service.readEmails("INBOX", contextWindowDays = 3)
|
||||||
|
|
||||||
|
assertThat(result).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_filterEmailsOlderThanContextWindow_when_windowIs3Days() {
|
||||||
|
val folder: Folder = mockk(relaxed = true)
|
||||||
|
val session: Session = mockk()
|
||||||
|
|
||||||
|
val recentDate = Date()
|
||||||
|
val oldDate = Date(System.currentTimeMillis() - 10L * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
val recent = buildMimeMessage(session, "Recent", "a@x.com", recentDate)
|
||||||
|
val old = buildMimeMessage(session, "Old", "b@x.com", oldDate)
|
||||||
|
|
||||||
|
every { mockStore.getFolder(any<String>()) } returns folder
|
||||||
|
every { folder.messageCount } returns 2
|
||||||
|
every { folder.getMessages() } returns arrayOf(recent, old)
|
||||||
|
|
||||||
|
val result = service.readEmails("INBOX", contextWindowDays = 3)
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat(result[0].subject).isEqualTo("Recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_stripHtml_when_emailBodyContainsHtmlTags() {
|
||||||
|
val folder: Folder = mockk(relaxed = true)
|
||||||
|
val session: Session = mockk()
|
||||||
|
|
||||||
|
val msg = buildMimeMessage(
|
||||||
|
session,
|
||||||
|
subject = "HTML email",
|
||||||
|
from = "dev@x.com",
|
||||||
|
date = Date(),
|
||||||
|
body = "<p>Hello <b>world</b></p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
every { mockStore.getFolder(any<String>()) } returns folder
|
||||||
|
every { folder.messageCount } returns 1
|
||||||
|
every { folder.getMessages() } returns arrayOf(msg)
|
||||||
|
|
||||||
|
val result = service.readEmails("INBOX", contextWindowDays = 10)
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1)
|
||||||
|
assertThat(result[0].body).doesNotContain("<p>", "<b>", "</b>", "</p>")
|
||||||
|
assertThat(result[0].body).contains("Hello")
|
||||||
|
assertThat(result[0].body).contains("world")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildMimeMessage(
|
||||||
|
session: Session,
|
||||||
|
subject: String,
|
||||||
|
from: String,
|
||||||
|
date: Date,
|
||||||
|
body: String = "Plain text body"
|
||||||
|
): MimeMessage {
|
||||||
|
val msg: MimeMessage = mockk(relaxed = true)
|
||||||
|
every { msg.subject } returns subject
|
||||||
|
every { msg.from } returns arrayOf(InternetAddress(from))
|
||||||
|
every { msg.sentDate } returns date
|
||||||
|
every { msg.receivedDate } returns date
|
||||||
|
every { msg.contentType } returns "text/plain"
|
||||||
|
every { msg.content } returns body
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import jakarta.mail.internet.MimeMessage
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender
|
||||||
|
|
||||||
|
class EmailSenderServiceTest {
|
||||||
|
|
||||||
|
private val mailSender: JavaMailSender = mockk()
|
||||||
|
private val mimeMessage: MimeMessage = mockk(relaxed = true)
|
||||||
|
private val service = EmailSenderService(mailSender)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled() {
|
||||||
|
every { mailSender.createMimeMessage() } returns mimeMessage
|
||||||
|
every { mailSender.send(mimeMessage) } returns Unit
|
||||||
|
|
||||||
|
service.send(
|
||||||
|
from = "joao@condado.com",
|
||||||
|
to = listOf("friend@example.com"),
|
||||||
|
subject = "RE: Meeting on Tuesday",
|
||||||
|
body = "Dear colleagues, I have prepared the agenda."
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(exactly = 1) { mailSender.send(mimeMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_sendToAllRecipients_when_multipleRecipientsConfigured() {
|
||||||
|
every { mailSender.createMimeMessage() } returns mimeMessage
|
||||||
|
every { mailSender.send(mimeMessage) } returns Unit
|
||||||
|
|
||||||
|
service.send(
|
||||||
|
from = "joao@condado.com",
|
||||||
|
to = listOf("friend1@example.com", "friend2@example.com", "friend3@example.com"),
|
||||||
|
subject = "Subject",
|
||||||
|
body = "Body"
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(exactly = 1) { mailSender.send(mimeMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_sendMultipartMessage_when_sendCalled() {
|
||||||
|
every { mailSender.createMimeMessage() } returns mimeMessage
|
||||||
|
every { mailSender.send(mimeMessage) } returns Unit
|
||||||
|
|
||||||
|
// The send method should complete without throwing — multipart assembly is verified indirectly
|
||||||
|
service.send(
|
||||||
|
from = "joao@condado.com",
|
||||||
|
to = listOf("friend@example.com"),
|
||||||
|
subject = "Formal Memo",
|
||||||
|
body = "<p>I wish to formally announce that I am out of coffee.</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(exactly = 1) { mailSender.send(mimeMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_logSendAttempt_when_sendCalled() {
|
||||||
|
every { mailSender.createMimeMessage() } returns mimeMessage
|
||||||
|
every { mailSender.send(mimeMessage) } returns Unit
|
||||||
|
|
||||||
|
// No exception should be thrown — logging is captured via SLF4J internally
|
||||||
|
service.send(
|
||||||
|
from = "joao@condado.com",
|
||||||
|
to = listOf("friend@example.com"),
|
||||||
|
subject = "Log test",
|
||||||
|
body = "Testing log output"
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(exactly = 1) { mailSender.send(mimeMessage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.EmailContext
|
||||||
|
import com.condado.newsletter.model.VirtualEntity
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class PromptBuilderServiceTest {
|
||||||
|
|
||||||
|
private val service = PromptBuilderService()
|
||||||
|
|
||||||
|
private val entity = VirtualEntity(
|
||||||
|
name = "Rodrigo Alves",
|
||||||
|
email = "rodrigo@condado.com",
|
||||||
|
jobTitle = "Head of Casual Affairs",
|
||||||
|
personality = "Extremely pedantic but only talks about cats",
|
||||||
|
scheduleCron = "0 9 * * 1",
|
||||||
|
contextWindowDays = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
private val emailContext = listOf(
|
||||||
|
EmailContext(
|
||||||
|
from = "Maria Santos <maria@condado.com>",
|
||||||
|
subject = "Re: The weekly snack situation",
|
||||||
|
body = "I think we need more chips in the pantry.",
|
||||||
|
receivedAt = LocalDateTime.now().minusHours(2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containEntityName_when_buildPromptCalled() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("Rodrigo Alves")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containEntityJobTitle_when_buildPromptCalled() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("Head of Casual Affairs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containEntityPersonality_when_buildPromptCalled() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("Extremely pedantic but only talks about cats")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containContextWindowDays_when_buildPromptCalled() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containEachEmailSenderAndSubject_when_emailContextProvided() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("Maria Santos <maria@condado.com>")
|
||||||
|
assertThat(prompt).contains("Re: The weekly snack situation")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_containFormatInstruction_when_buildPromptCalled() {
|
||||||
|
val prompt = service.buildPrompt(entity, emailContext)
|
||||||
|
assertThat(prompt).contains("SUBJECT:")
|
||||||
|
assertThat(prompt).contains("BODY:")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun should_returnPromptWithNoEmails_when_emailContextIsEmpty() {
|
||||||
|
val prompt = service.buildPrompt(entity, emptyList())
|
||||||
|
assertThat(prompt).contains("Rodrigo Alves")
|
||||||
|
assertThat(prompt).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/test/resources/application.yml
Normal file
40
backend/src/test/resources/application.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.H2Dialect
|
||||||
|
mail:
|
||||||
|
host: localhost
|
||||||
|
port: 25
|
||||||
|
username: test
|
||||||
|
password: test
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: false
|
||||||
|
starttls:
|
||||||
|
enable: false
|
||||||
|
|
||||||
|
app:
|
||||||
|
password: testpassword
|
||||||
|
recipients: test@test.com
|
||||||
|
jwt:
|
||||||
|
secret: test-secret-key-for-testing-only-must-be-at-least-32-characters
|
||||||
|
expiration-ms: 86400000
|
||||||
|
|
||||||
|
imap:
|
||||||
|
host: localhost
|
||||||
|
port: 993
|
||||||
|
inbox-folder: INBOX
|
||||||
|
|
||||||
|
openai:
|
||||||
|
api-key: test-api-key
|
||||||
|
model: gpt-4o
|
||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
- condado-net
|
- condado-net
|
||||||
|
|
||||||
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
||||||
frontend:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -57,7 +57,7 @@ services:
|
|||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "6969:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
- condado-net
|
- condado-net
|
||||||
|
|
||||||
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
||||||
frontend:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -57,7 +57,7 @@ services:
|
|||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6969:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -19,5 +19,10 @@ fi
|
|||||||
# ── Ensure supervisor log directory exists ────────────────────────────────────
|
# ── Ensure supervisor log directory exists ────────────────────────────────────
|
||||||
mkdir -p /var/log/supervisor
|
mkdir -p /var/log/supervisor
|
||||||
|
|
||||||
|
# ── Defaults for all-in-one local PostgreSQL ─────────────────────────────────
|
||||||
|
export SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL:-jdbc:postgresql://localhost:5432/condado}
|
||||||
|
export SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME:-condado}
|
||||||
|
export SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD:-condado}
|
||||||
|
|
||||||
# ── Start all services via supervisord ───────────────────────────────────────
|
# ── Start all services via supervisord ───────────────────────────────────────
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ stderr_logfile=/var/log/supervisor/postgres.err.log
|
|||||||
|
|
||||||
[program:backend]
|
[program:backend]
|
||||||
command=java -jar /app/app.jar
|
command=java -jar /app/app.jar
|
||||||
environment=SPRING_DATASOURCE_URL="jdbc:postgresql://localhost:5432/condado",SPRING_DATASOURCE_USERNAME="condado",SPRING_DATASOURCE_PASSWORD="condado"
|
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startsecs=15
|
startsecs=15
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "condado-newsletter-frontend",
|
"name": "condado-newsletter-frontend",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "condado-newsletter-frontend",
|
"name": "condado-newsletter-frontend",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "condado-newsletter-frontend",
|
"name": "condado-newsletter-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
40
frontend/src/__tests__/api/authApi.test.ts
Normal file
40
frontend/src/__tests__/api/authApi.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import apiClient from '@/api/apiClient'
|
||||||
|
import { login, logout, getMe } from '@/api/authApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/apiClient', () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedApiClient = apiClient as unknown as {
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authApi', () => {
|
||||||
|
it('should_callLoginEndpoint_when_loginCalled', async () => {
|
||||||
|
mockedApiClient.post.mockResolvedValue({ data: {} })
|
||||||
|
await login({ password: 'secret' })
|
||||||
|
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', { password: 'secret' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callLogoutEndpoint_when_logoutCalled', async () => {
|
||||||
|
mockedApiClient.post.mockResolvedValue({ data: {} })
|
||||||
|
await logout()
|
||||||
|
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callMeEndpoint_when_getMeCalled', async () => {
|
||||||
|
mockedApiClient.get.mockResolvedValue({ data: { message: 'Authenticated' } })
|
||||||
|
const result = await getMe()
|
||||||
|
expect(mockedApiClient.get).toHaveBeenCalledWith('/auth/me')
|
||||||
|
expect(result).toEqual({ message: 'Authenticated' })
|
||||||
|
})
|
||||||
|
})
|
||||||
62
frontend/src/__tests__/api/entitiesApi.test.ts
Normal file
62
frontend/src/__tests__/api/entitiesApi.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import apiClient from '@/api/apiClient'
|
||||||
|
import {
|
||||||
|
getEntities,
|
||||||
|
createEntity,
|
||||||
|
updateEntity,
|
||||||
|
deleteEntity,
|
||||||
|
triggerEntity,
|
||||||
|
} from '@/api/entitiesApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/apiClient', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedApiClient = apiClient as unknown as {
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
put: ReturnType<typeof vi.fn>
|
||||||
|
delete: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('entitiesApi', () => {
|
||||||
|
it('should_callGetEndpoint_when_getAllEntitiesCalled', async () => {
|
||||||
|
mockedApiClient.get.mockResolvedValue({ data: [] })
|
||||||
|
await getEntities()
|
||||||
|
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/virtual-entities')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callPostEndpoint_when_createEntityCalled', async () => {
|
||||||
|
const dto = { name: 'Test', email: 'test@test.com', jobTitle: 'Tester', personality: '', scheduleCron: '', contextWindowDays: 3 }
|
||||||
|
mockedApiClient.post.mockResolvedValue({ data: dto })
|
||||||
|
await createEntity(dto)
|
||||||
|
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities', dto)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callPutEndpoint_when_updateEntityCalled', async () => {
|
||||||
|
mockedApiClient.put.mockResolvedValue({ data: {} })
|
||||||
|
await updateEntity('123', { name: 'Updated' })
|
||||||
|
expect(mockedApiClient.put).toHaveBeenCalledWith('/v1/virtual-entities/123', { name: 'Updated' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callDeleteEndpoint_when_deleteEntityCalled', async () => {
|
||||||
|
mockedApiClient.delete.mockResolvedValue({ data: {} })
|
||||||
|
await deleteEntity('123')
|
||||||
|
expect(mockedApiClient.delete).toHaveBeenCalledWith('/v1/virtual-entities/123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callTriggerEndpoint_when_triggerEntityCalled', async () => {
|
||||||
|
mockedApiClient.post.mockResolvedValue({ data: {} })
|
||||||
|
await triggerEntity('123')
|
||||||
|
expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities/123/trigger')
|
||||||
|
})
|
||||||
|
})
|
||||||
31
frontend/src/__tests__/api/logsApi.test.ts
Normal file
31
frontend/src/__tests__/api/logsApi.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import apiClient from '@/api/apiClient'
|
||||||
|
import { getLogs, getLogsByEntity } from '@/api/logsApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/apiClient', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedApiClient = apiClient as unknown as {
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logsApi', () => {
|
||||||
|
it('should_callGetAllLogsEndpoint_when_getAllLogsCalled', async () => {
|
||||||
|
mockedApiClient.get.mockResolvedValue({ data: [] })
|
||||||
|
await getLogs()
|
||||||
|
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callGetByEntityEndpoint_when_getLogsByEntityCalled', async () => {
|
||||||
|
mockedApiClient.get.mockResolvedValue({ data: [] })
|
||||||
|
await getLogsByEntity('entity-123')
|
||||||
|
expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs/entity/entity-123')
|
||||||
|
})
|
||||||
|
})
|
||||||
43
frontend/src/__tests__/components/ProtectedRoute.test.tsx
Normal file
43
frontend/src/__tests__/components/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
|
import * as authApi from '@/api/authApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/authApi')
|
||||||
|
|
||||||
|
const makeWrapper = (initialPath = '/') =>
|
||||||
|
({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
it('should_renderChildren_when_sessionIsValid', async () => {
|
||||||
|
vi.mocked(authApi.getMe).mockResolvedValue({ message: 'Authenticated' })
|
||||||
|
const wrapper = makeWrapper()
|
||||||
|
render(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{ wrapper }
|
||||||
|
)
|
||||||
|
await screen.findByText('Protected Content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_redirectToLogin_when_sessionIsInvalid', async () => {
|
||||||
|
vi.mocked(authApi.getMe).mockRejectedValue(new Error('Unauthorized'))
|
||||||
|
const wrapper = makeWrapper()
|
||||||
|
render(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{ wrapper }
|
||||||
|
)
|
||||||
|
// Should navigate away, protected content should not be visible
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,47 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import DashboardPage from '@/pages/DashboardPage'
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
|
import * as entitiesApi from '@/api/entitiesApi'
|
||||||
|
import * as logsApi from '@/api/logsApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/entitiesApi')
|
||||||
|
vi.mock('@/api/logsApi')
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
describe('DashboardPage', () => {
|
describe('DashboardPage', () => {
|
||||||
it('should_display_app_version_when_rendered', () => {
|
it('should_display_app_version_when_rendered', () => {
|
||||||
render(<DashboardPage />)
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||||
|
vi.mocked(logsApi.getLogs).mockResolvedValue([])
|
||||||
|
render(<DashboardPage />, { wrapper })
|
||||||
|
expect(screen.getByText(/version \d+\.\d+\.\d+/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
expect(screen.getByText(/version 0.1.0/i)).toBeInTheDocument()
|
it('should_renderEntityCount_when_pageLoads', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([
|
||||||
|
{ id: '1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job A', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' },
|
||||||
|
])
|
||||||
|
vi.mocked(logsApi.getLogs).mockResolvedValue([])
|
||||||
|
render(<DashboardPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/1 active entity|1 entidade/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_renderRecentLogs_when_pageLoads', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||||
|
vi.mocked(logsApi.getLogs).mockResolvedValue([
|
||||||
|
{ id: '1', entityId: 'e1', entityName: 'Entity A', promptSent: '', aiResponse: '', emailSubject: 'Memo', emailBody: '', status: 'SENT', errorMessage: null, dispatchedAt: '2024-01-01T10:00:00' },
|
||||||
|
])
|
||||||
|
render(<DashboardPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Memo/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
60
frontend/src/__tests__/pages/EntitiesPage.test.tsx
Normal file
60
frontend/src/__tests__/pages/EntitiesPage.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import EntitiesPage from '@/pages/EntitiesPage'
|
||||||
|
import * as entitiesApi from '@/api/entitiesApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/entitiesApi')
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockEntity = {
|
||||||
|
id: 'entity-1',
|
||||||
|
name: 'Test Entity',
|
||||||
|
email: 'test@condado.com',
|
||||||
|
jobTitle: 'Tester',
|
||||||
|
personality: 'Formal',
|
||||||
|
scheduleCron: '0 9 * * *',
|
||||||
|
contextWindowDays: 3,
|
||||||
|
active: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EntitiesPage', () => {
|
||||||
|
it('should_renderEntityList_when_entitiesLoaded', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity])
|
||||||
|
render(<EntitiesPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Entity')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_openCreateDialog_when_addButtonClicked', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||||
|
render(<EntitiesPage />, { wrapper })
|
||||||
|
const addButton = screen.getByRole('button', { name: /add|create|new/i })
|
||||||
|
fireEvent.click(addButton)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callDeleteApi_when_deleteConfirmed', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity])
|
||||||
|
vi.mocked(entitiesApi.deleteEntity).mockResolvedValue(undefined)
|
||||||
|
render(<EntitiesPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Entity')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const deleteButton = screen.getByRole('button', { name: /delete|deactivate/i })
|
||||||
|
fireEvent.click(deleteButton)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(entitiesApi.deleteEntity).toHaveBeenCalledWith('entity-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
52
frontend/src/__tests__/pages/LoginPage.test.tsx
Normal file
52
frontend/src/__tests__/pages/LoginPage.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import LoginPage from '@/pages/LoginPage'
|
||||||
|
import * as authApi from '@/api/authApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/authApi')
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
it('should_renderLoginForm_when_pageLoads', () => {
|
||||||
|
render(<LoginPage />, { wrapper })
|
||||||
|
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_callLoginApi_when_formSubmitted', async () => {
|
||||||
|
vi.mocked(authApi.login).mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage />, { wrapper })
|
||||||
|
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApi.login).toHaveBeenCalledWith({ password: 'secret' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_showErrorMessage_when_loginFails', async () => {
|
||||||
|
vi.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'))
|
||||||
|
render(<LoginPage />, { wrapper })
|
||||||
|
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/invalid password|unauthorized|error/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_redirectToDashboard_when_loginSucceeds', async () => {
|
||||||
|
vi.mocked(authApi.login).mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage />, { wrapper })
|
||||||
|
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'correct' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /login/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authApi.login).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
52
frontend/src/__tests__/pages/LogsPage.test.tsx
Normal file
52
frontend/src/__tests__/pages/LogsPage.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import LogsPage from '@/pages/LogsPage'
|
||||||
|
import * as logsApi from '@/api/logsApi'
|
||||||
|
import * as entitiesApi from '@/api/entitiesApi'
|
||||||
|
|
||||||
|
vi.mock('@/api/logsApi')
|
||||||
|
vi.mock('@/api/entitiesApi')
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockLog = {
|
||||||
|
id: 'log-1',
|
||||||
|
entityId: 'e1',
|
||||||
|
entityName: 'Entity A',
|
||||||
|
promptSent: 'prompt',
|
||||||
|
aiResponse: 'response',
|
||||||
|
emailSubject: 'Weekly Memo',
|
||||||
|
emailBody: 'Dear colleagues',
|
||||||
|
status: 'SENT' as const,
|
||||||
|
errorMessage: null,
|
||||||
|
dispatchedAt: '2024-01-01T10:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LogsPage', () => {
|
||||||
|
it('should_renderLogTable_when_logsLoaded', async () => {
|
||||||
|
vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog])
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||||
|
render(<LogsPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Weekly Memo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_filterLogsByEntity_when_filterSelected', async () => {
|
||||||
|
vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog])
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([
|
||||||
|
{ id: 'e1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' },
|
||||||
|
])
|
||||||
|
render(<LogsPage />, { wrapper })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Weekly Memo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('combobox', { hidden: true })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ export type DispatchStatus = 'PENDING' | 'SENT' | 'FAILED'
|
|||||||
export interface DispatchLogResponse {
|
export interface DispatchLogResponse {
|
||||||
id: string
|
id: string
|
||||||
entityId: string
|
entityId: string
|
||||||
|
entityName: string
|
||||||
promptSent: string
|
promptSent: string
|
||||||
aiResponse: string
|
aiResponse: string
|
||||||
emailSubject: string
|
emailSubject: string
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getEntities } from '../api/entitiesApi'
|
||||||
|
import { getLogs } from '../api/logsApi'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const appVersion = __APP_VERSION__
|
const appVersion = __APP_VERSION__
|
||||||
|
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
||||||
|
const { data: logs = [] } = useQuery({ queryKey: ['logs'], queryFn: getLogs })
|
||||||
|
|
||||||
|
const activeCount = entities.filter((e) => e.active).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<p className="mt-2 text-sm text-gray-500">Dashboard — coming in Step 11.</p>
|
|
||||||
<p className="mt-2 text-xs text-gray-400">Version {appVersion}</p>
|
<p className="mt-2 text-xs text-gray-400">Version {appVersion}</p>
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-sm text-gray-500">Active Entities</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{activeCount} active {activeCount === 1 ? 'entity' : 'entities'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">Recent Dispatches</h2>
|
||||||
|
<ul className="mt-2 divide-y divide-gray-100 rounded-lg border bg-white shadow-sm">
|
||||||
|
{logs.slice(0, 10).map((log) => (
|
||||||
|
<li key={log.id} className="px-4 py-3 text-sm">
|
||||||
|
<span className="font-medium">{log.emailSubject}</span>
|
||||||
|
<span className="ml-2 text-gray-400">{log.entityName}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-sm text-gray-400">No dispatches yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
150
frontend/src/pages/EntitiesPage.tsx
Normal file
150
frontend/src/pages/EntitiesPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
getEntities,
|
||||||
|
createEntity,
|
||||||
|
deleteEntity,
|
||||||
|
VirtualEntityCreateDto,
|
||||||
|
} from '../api/entitiesApi'
|
||||||
|
|
||||||
|
export default function EntitiesPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<VirtualEntityCreateDto>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
jobTitle: '',
|
||||||
|
personality: '',
|
||||||
|
scheduleCron: '',
|
||||||
|
contextWindowDays: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createEntity,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['entities'] })
|
||||||
|
setDialogOpen(false)
|
||||||
|
setForm({ name: '', email: '', jobTitle: '', personality: '', scheduleCron: '', contextWindowDays: 3 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteEntity(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['entities'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Virtual Entities</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
New Entity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-6 divide-y divide-gray-100 rounded-lg border bg-white shadow-sm">
|
||||||
|
{entities.map((entity) => (
|
||||||
|
<li key={entity.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{entity.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{entity.jobTitle} — {entity.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(entity.id)}
|
||||||
|
className="ml-4 rounded border border-red-300 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{entities.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-sm text-gray-400">No entities yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{dialogOpen && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Create Entity"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-lg">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">New Entity</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
createMutation.mutate(form)
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Job Title"
|
||||||
|
value={form.jobTitle}
|
||||||
|
onChange={(e) => setForm({ ...form, jobTitle: e.target.value })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Personality"
|
||||||
|
value={form.personality}
|
||||||
|
onChange={(e) => setForm({ ...form, personality: e.target.value })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Schedule Cron (e.g. 0 9 * * 1)"
|
||||||
|
value={form.scheduleCron}
|
||||||
|
onChange={(e) => setForm({ ...form, scheduleCron: e.target.value })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Context Window Days"
|
||||||
|
value={form.contextWindowDays}
|
||||||
|
onChange={(e) => setForm({ ...form, contextWindowDays: Number(e.target.value) })}
|
||||||
|
className="block w-full rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
className="rounded border border-gray-300 px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,52 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { login } from '../api/authApi'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login({ password })
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch {
|
||||||
|
setError('Invalid password')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow">
|
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-gray-900">
|
<h1 className="mb-6 text-2xl font-bold text-gray-900">
|
||||||
Condado Abaixo da Média SA
|
Condado Abaixo da Média SA
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">Login page — coming in Step 11.</p>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
78
frontend/src/pages/LogsPage.tsx
Normal file
78
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getLogs, getLogsByEntity } from '../api/logsApi'
|
||||||
|
import { getEntities } from '../api/entitiesApi'
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState<string>('')
|
||||||
|
|
||||||
|
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
||||||
|
const { data: logs = [] } = useQuery({
|
||||||
|
queryKey: ['logs', selectedEntityId],
|
||||||
|
queryFn: () => selectedEntityId ? getLogsByEntity(selectedEntityId) : getLogs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dispatch Logs</h1>
|
||||||
|
<select
|
||||||
|
value={selectedEntityId}
|
||||||
|
onChange={(e) => setSelectedEntityId(e.target.value)}
|
||||||
|
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Entities</option>
|
||||||
|
{entities.map((entity) => (
|
||||||
|
<option key={entity.id} value={entity.id}>
|
||||||
|
{entity.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Subject</th>
|
||||||
|
<th className="px-4 py-3">Entity</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Dispatched At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id}>
|
||||||
|
<td className="px-4 py-3 font-medium">{log.emailSubject}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{log.entityName}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
log.status === 'SENT'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: log.status === 'FAILED'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">
|
||||||
|
{new Date(log.dispatchedAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No logs found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { createBrowserRouter } from 'react-router-dom'
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense, ReactNode } from 'react'
|
||||||
|
import ProtectedRoute from '../components/ProtectedRoute'
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('../pages/LoginPage'))
|
const LoginPage = lazy(() => import('../pages/LoginPage'))
|
||||||
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
|
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
|
||||||
|
const EntitiesPage = lazy(() => import('../pages/EntitiesPage'))
|
||||||
|
const LogsPage = lazy(() => import('../pages/LogsPage'))
|
||||||
|
|
||||||
|
function Protected({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ProtectedRoute>{children}</ProtectedRoute>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -16,9 +27,25 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Protected>
|
||||||
<DashboardPage />
|
<DashboardPage />
|
||||||
</Suspense>
|
</Protected>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/entities',
|
||||||
|
element: (
|
||||||
|
<Protected>
|
||||||
|
<EntitiesPage />
|
||||||
|
</Protected>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
element: (
|
||||||
|
<Protected>
|
||||||
|
<LogsPage />
|
||||||
|
</Protected>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ http {
|
|||||||
text/xml application/xml application/xml+rss text/javascript;
|
text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 6969;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
Reference in New Issue
Block a user