@@ -30,4 +30,4 @@ OPENAI_MODEL=gpt-4o
|
||||
APP_RECIPIENTS=friend1@example.com,friend2@example.com
|
||||
|
||||
# ── 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.
|
||||
- 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
|
||||
|
||||
| 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
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6969
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -45,7 +45,7 @@ employee is an AI-powered entity that:
|
||||
|------|-----------------------------------------|-------------|
|
||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||
| 1 | Scaffold monorepo structure | ✅ Done |
|
||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
||||
| 2 | Domain model (JPA entities) | ✅ Done |
|
||||
| 3 | Repositories | ⬜ Pending |
|
||||
| 4 | Email Reader Service (IMAP) | ⬜ 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."
|
||||
|
||||
**Done when:**
|
||||
- [ ] `EntityMappingTest.kt` exists with all 5 tests.
|
||||
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||
- [ ] `./gradlew test` is green.
|
||||
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
|
||||
- [x] `EntityMappingTest.kt` exists with all 5 tests.
|
||||
- [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||
- [x] `./gradlew test` is green.
|
||||
- [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
|
||||
|
||||
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
||||
frontend:
|
||||
nginx:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
restart: always
|
||||
ports:
|
||||
- "6969:80"
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
- condado-net
|
||||
|
||||
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
|
||||
frontend:
|
||||
nginx:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6969:80"
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -19,5 +19,10 @@ fi
|
||||
# ── Ensure supervisor log directory exists ────────────────────────────────────
|
||||
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 ───────────────────────────────────────
|
||||
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]
|
||||
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
|
||||
autorestart=true
|
||||
startsecs=15
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "condado-newsletter-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "condado-newsletter-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "condado-newsletter-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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 { describe, expect, it } from 'vitest'
|
||||
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 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', () => {
|
||||
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 {
|
||||
id: string
|
||||
entityId: string
|
||||
entityName: string
|
||||
promptSent: string
|
||||
aiResponse: 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() {
|
||||
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 (
|
||||
<div className="p-8">
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<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">
|
||||
<h1 className="mb-6 text-2xl font-bold text-gray-900">
|
||||
Condado Abaixo da Média SA
|
||||
</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>
|
||||
)
|
||||
|
||||
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 { lazy, Suspense } from 'react'
|
||||
import { lazy, Suspense, ReactNode } from 'react'
|
||||
import ProtectedRoute from '../components/ProtectedRoute'
|
||||
|
||||
const LoginPage = lazy(() => import('../pages/LoginPage'))
|
||||
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([
|
||||
{
|
||||
@@ -16,9 +27,25 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Protected>
|
||||
<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;
|
||||
|
||||
server {
|
||||
listen 6969;
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
Reference in New Issue
Block a user