diff --git a/README.md b/README.md index 2cb207a..3dc9c11 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Implemented: - Service-layer overlap checks for clear API errors - PostgreSQL exclusion constraints for scheduled shifts and approved leave - Concurrent PostgreSQL integration test: two overlapping inserts yield exactly one commit and one `23P01` exclusion violation +- Audit events for shift creation, updates, cancellation, leave approval and leave cancellation +- Before/after state snapshots stored as JSONB, with actor and timestamp +- Audit writes participate in the same transaction as the business mutation - Validation and API error handling - Unit and integration tests - GitHub Actions CI @@ -34,11 +37,15 @@ Touching shifts are valid. Overlapping windows are rejected. ## API -Create an employee with POST /api/v1/employees. +Create an employee with `POST /api/v1/employees`. -Schedule a shift with POST /api/v1/shifts. +Schedule a shift with `POST /api/v1/shifts`. Update and cancel shifts with `PUT /api/v1/shifts/{id}` and `DELETE /api/v1/shifts/{id}`. -A scheduling conflict returns HTTP 409 with code SHIFT_CONFLICT. +Create or cancel leave with `POST /api/v1/leave` and `DELETE /api/v1/leave/{id}`. + +Mutation requests must include an `X-Actor-Id` header. The value is recorded for audit attribution. **It is not authentication**: until authentication and role-based access control are implemented, a caller can supply an arbitrary actor value. Do not expose mutation endpoints to untrusted clients based on this header alone. + +A scheduling conflict returns HTTP 409 with code `SHIFT_CONFLICT`; invalid time windows return HTTP 400. ## Run locally @@ -58,6 +65,8 @@ The API starts on port 8080. The service checks provide a useful conflict response, but they are not the concurrency boundary. PostgreSQL enforces the invariant with GiST exclusion constraints using `tstzrange(..., '[)')`; the half-open range allows adjacent shifts while rejecting overlaps. Migration `V3__schedule_consistency.sql` applies these constraints to scheduled shifts and approved leave. `ConcurrentShiftConflictIntegrationTest` synchronizes two transactions and asserts exactly one insert commits. +Migration `V4__audit_events.sql` adds the audit event table. Audit writes use the same transaction as each shift or leave mutation, so a failed mutation or failed audit write rolls back the whole operation. Integration tests verify that behaviour. + ## Roadmap ### Milestone 2 — Scheduling Engine @@ -74,7 +83,6 @@ The service checks provide a useful conflict response, but they are not the conc - approval workflows - overtime tracking - workload calculations -- audit log ### Milestone 4 — Distributed Processing - Redis diff --git a/build.gradle.kts b/build.gradle.kts index 668f9b6..67726af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.flywaydb:flyway-core") + runtimeOnly("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") diff --git a/src/main/kotlin/dev/whrite/opsgrid/OpsGridApplication.kt b/src/main/kotlin/dev/whrite/opsgrid/OpsGridApplication.kt new file mode 100644 index 0000000..04221c8 --- /dev/null +++ b/src/main/kotlin/dev/whrite/opsgrid/OpsGridApplication.kt @@ -0,0 +1,11 @@ +package dev.whrite.opsgrid + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class OpsGridApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/src/main/kotlin/dev/whrite/opsgrid/audit/AuditLogService.kt b/src/main/kotlin/dev/whrite/opsgrid/audit/AuditLogService.kt new file mode 100644 index 0000000..cf4d435 --- /dev/null +++ b/src/main/kotlin/dev/whrite/opsgrid/audit/AuditLogService.kt @@ -0,0 +1,50 @@ +package dev.whrite.opsgrid.audit + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class AuditLogService( + private val jdbcTemplate: JdbcTemplate, + private val objectMapper: ObjectMapper +) { + @Transactional(propagation = Propagation.MANDATORY) + fun record( + actorId: String, + action: String, + entityType: String, + entityId: UUID, + beforeState: Any?, + afterState: Any? + ) { + val actor = actorId.trim() + require(actor.isNotEmpty()) { "X-Actor-Id must not be blank" } + require(actor.length <= MAX_ACTOR_ID_LENGTH) { + "X-Actor-Id must be at most $MAX_ACTOR_ID_LENGTH characters" + } + + jdbcTemplate.update( + """ + INSERT INTO audit_events ( + id, actor_id, action, entity_type, entity_id, + occurred_at, before_state, after_state + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?::jsonb, ?::jsonb) + """.trimIndent(), + UUID.randomUUID(), + actor, + action, + entityType, + entityId, + beforeState?.let(objectMapper::writeValueAsString), + afterState?.let(objectMapper::writeValueAsString) + ) + } + + private companion object { + const val MAX_ACTOR_ID_LENGTH = 120 + } +} diff --git a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt index 22b24f4..1d7cc16 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveController.kt @@ -8,16 +8,44 @@ import org.springframework.web.bind.annotation.* import java.time.OffsetDateTime import java.util.UUID -data class CreateLeaveRequest(@field:NotNull val employeeId: UUID?, @field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?, @field:NotBlank val reason: String) -data class LeaveResponse(val id: UUID, val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val reason: String, val status: LeaveStatus) +data class CreateLeaveRequest( + @field:NotNull val employeeId: UUID?, + @field:NotNull val startsAt: OffsetDateTime?, + @field:NotNull val endsAt: OffsetDateTime?, + @field:NotBlank val reason: String +) + +data class LeaveResponse( + val id: UUID, + val employeeId: UUID, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime, + val reason: String, + val status: LeaveStatus +) @RestController @RequestMapping("/api/v1/leave") class EmployeeLeaveController(private val service: EmployeeLeaveService) { - @PostMapping @ResponseStatus(HttpStatus.CREATED) - fun create(@Valid @RequestBody request: CreateLeaveRequest) = service.approve(CreateLeaveCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!, request.reason)).toResponse() - @GetMapping("/{id}") fun get(@PathVariable id: UUID) = service.find(id).toResponse() - @DeleteMapping("/{id}") fun cancel(@PathVariable id: UUID) = service.cancel(id).toResponse() + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create( + @RequestHeader("X-Actor-Id") actorId: String, + @Valid @RequestBody request: CreateLeaveRequest + ) = service.approve( + CreateLeaveCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!, request.reason), + actorId + ).toResponse() + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID) = service.find(id).toResponse() + + @DeleteMapping("/{id}") + fun cancel( + @PathVariable id: UUID, + @RequestHeader("X-Actor-Id") actorId: String + ) = service.cancel(id, actorId).toResponse() } -private fun EmployeeLeave.toResponse() = LeaveResponse(id, employee.id, startsAt, endsAt, reason, status) +private fun EmployeeLeave.toResponse() = + LeaveResponse(id, employee.id, startsAt, endsAt, reason, status) diff --git a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveService.kt b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveService.kt index 8324ea5..27d9743 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveService.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveService.kt @@ -1,33 +1,75 @@ package dev.whrite.opsgrid.leave +import dev.whrite.opsgrid.audit.AuditLogService import dev.whrite.opsgrid.employee.EmployeeRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.OffsetDateTime import java.util.UUID -data class CreateLeaveCommand(val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val reason: String) +data class CreateLeaveCommand( + val employeeId: UUID, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime, + val reason: String +) class LeaveConflictException(message: String) : RuntimeException(message) class LeaveNotFoundException(message: String) : RuntimeException(message) @Service -class EmployeeLeaveService(private val employees: EmployeeRepository, private val leaves: EmployeeLeaveRepository) { +class EmployeeLeaveService( + private val employees: EmployeeRepository, + private val leaves: EmployeeLeaveRepository, + private val auditLog: AuditLogService +) { @Transactional - fun approve(command: CreateLeaveCommand): EmployeeLeave { + fun approve(command: CreateLeaveCommand, actorId: String): EmployeeLeave { validateWindow(command.startsAt, command.endsAt) require(command.reason.isNotBlank()) { "Leave reason is required" } - val employee = employees.findById(command.employeeId).orElseThrow { RuntimeException("Employee "+command.employeeId+" was not found") } + val employee = employees.findById(command.employeeId) + .orElseThrow { RuntimeException("Employee ${command.employeeId} was not found") } check(employee.active) { "Inactive employees cannot receive leave" } - if (leaves.hasConflict(employee.id, command.startsAt, command.endsAt)) throw LeaveConflictException("Employee already has approved leave overlapping this time window") - return leaves.save(EmployeeLeave(UUID.randomUUID(), employee, command.startsAt, command.endsAt, command.reason.trim())) + if (leaves.hasConflict(employee.id, command.startsAt, command.endsAt)) { + throw LeaveConflictException("Employee already has approved leave overlapping this time window") + } + + val leave = leaves.save( + EmployeeLeave( + UUID.randomUUID(), + employee, + command.startsAt, + command.endsAt, + command.reason.trim() + ) + ) + auditLog.record(actorId, "LEAVE_APPROVED", "EMPLOYEE_LEAVE", leave.id, null, snapshot(leave)) + return leave } + @Transactional(readOnly = true) - fun find(id: UUID) = leaves.findById(id).orElseThrow { LeaveNotFoundException("Leave "+id+" was not found") } + fun find(id: UUID) = leaves.findById(id) + .orElseThrow { LeaveNotFoundException("Leave $id was not found") } + @Transactional - fun cancel(id: UUID): EmployeeLeave { - val leave = find(id) + fun cancel(id: UUID, actorId: String): EmployeeLeave { + val leave = leaves.findById(id) + .orElseThrow { LeaveNotFoundException("Leave $id was not found") } + val before = snapshot(leave) leave.status = LeaveStatus.CANCELLED - return leaves.save(leave) + val cancelled = leaves.save(leave) + auditLog.record(actorId, "LEAVE_CANCELLED", "EMPLOYEE_LEAVE", cancelled.id, before, snapshot(cancelled)) + return cancelled + } + + private fun snapshot(leave: EmployeeLeave) = mapOf( + "employeeId" to leave.employee.id, + "startsAt" to leave.startsAt, + "endsAt" to leave.endsAt, + "reason" to leave.reason, + "status" to leave.status.name + ) + + private fun validateWindow(startsAt: OffsetDateTime, endsAt: OffsetDateTime) { + require(endsAt.isAfter(startsAt)) { "Leave end time must be after start time" } } - private fun validateWindow(startsAt: OffsetDateTime, endsAt: OffsetDateTime) { require(endsAt.isAfter(startsAt)) { "Leave end time must be after start time" } } } diff --git a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt index 9aa8a0f..8bd59c4 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt @@ -7,20 +7,64 @@ import org.springframework.web.bind.annotation.* import java.time.OffsetDateTime import java.util.UUID -data class CreateShiftRequest(@field:NotNull val employeeId: UUID?, @field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?) -data class UpdateShiftRequest(@field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?) -data class ShiftResponse(val id: UUID, val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val status: ShiftStatus) +data class CreateShiftRequest( + @field:NotNull val employeeId: UUID?, + @field:NotNull val startsAt: OffsetDateTime?, + @field:NotNull val endsAt: OffsetDateTime? +) + +data class UpdateShiftRequest( + @field:NotNull val startsAt: OffsetDateTime?, + @field:NotNull val endsAt: OffsetDateTime? +) + +data class ShiftResponse( + val id: UUID, + val employeeId: UUID, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime, + val status: ShiftStatus +) @RestController @RequestMapping("/api/v1/shifts") class ShiftController(private val service: ShiftService) { - @PostMapping @ResponseStatus(HttpStatus.CREATED) - fun create(@Valid @RequestBody request: CreateShiftRequest) = service.schedule(CreateShiftCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!)).toResponse() - @GetMapping("/{id}") fun get(@PathVariable id: UUID) = service.find(id).toResponse() - @PutMapping("/{id}") fun update(@PathVariable id: UUID, @Valid @RequestBody request: UpdateShiftRequest) = service.update(id, UpdateShiftCommand(request.startsAt!!, request.endsAt!!)).toResponse() - @DeleteMapping("/{id}") fun cancel(@PathVariable id: UUID) = service.cancel(id).toResponse() + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create( + @RequestHeader("X-Actor-Id") actorId: String, + @Valid @RequestBody request: CreateShiftRequest + ) = service.schedule( + CreateShiftCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!), + actorId + ).toResponse() + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID) = service.find(id).toResponse() + + @PutMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestHeader("X-Actor-Id") actorId: String, + @Valid @RequestBody request: UpdateShiftRequest + ) = service.update( + id, + UpdateShiftCommand(request.startsAt!!, request.endsAt!!), + actorId + ).toResponse() + + @DeleteMapping("/{id}") + fun cancel( + @PathVariable id: UUID, + @RequestHeader("X-Actor-Id") actorId: String + ) = service.cancel(id, actorId).toResponse() + @GetMapping("/employee/{employeeId}") - fun schedule(@PathVariable employeeId: UUID, @RequestParam startsAt: OffsetDateTime, @RequestParam endsAt: OffsetDateTime) = service.findEmployeeSchedule(employeeId, startsAt, endsAt).map { it.toResponse() } + fun schedule( + @PathVariable employeeId: UUID, + @RequestParam startsAt: OffsetDateTime, + @RequestParam endsAt: OffsetDateTime + ) = service.findEmployeeSchedule(employeeId, startsAt, endsAt).map { it.toResponse() } } private fun Shift.toResponse() = ShiftResponse(id, employee.id, startsAt, endsAt, status) diff --git a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftService.kt b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftService.kt index 7e11789..88fec67 100644 --- a/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftService.kt +++ b/src/main/kotlin/dev/whrite/opsgrid/shift/ShiftService.kt @@ -1,5 +1,6 @@ package dev.whrite.opsgrid.shift +import dev.whrite.opsgrid.audit.AuditLogService import dev.whrite.opsgrid.employee.EmployeeRepository import dev.whrite.opsgrid.leave.EmployeeLeaveRepository import org.springframework.stereotype.Service @@ -18,10 +19,11 @@ class ShiftNotFoundException(message: String) : RuntimeException(message) class ShiftService( private val employees: EmployeeRepository, private val shifts: ShiftRepository, - private val leaves: EmployeeLeaveRepository + private val leaves: EmployeeLeaveRepository, + private val auditLog: AuditLogService ) { @Transactional - fun schedule(command: CreateShiftCommand): Shift { + fun schedule(command: CreateShiftCommand, actorId: String): Shift { validateWindow(command.startsAt, command.endsAt) val employee = employees.findById(command.employeeId) .orElseThrow { EmployeeNotFoundException("Employee ${command.employeeId} was not found") } @@ -35,11 +37,14 @@ class ShiftService( "Employee ${employee.employeeNumber} has a scheduling conflict in this time window" ) } - return shifts.save(Shift(UUID.randomUUID(), employee, command.startsAt, command.endsAt)) + + val shift = shifts.save(Shift(UUID.randomUUID(), employee, command.startsAt, command.endsAt)) + auditLog.record(actorId, "SHIFT_CREATED", "SHIFT", shift.id, null, snapshot(shift)) + return shift } @Transactional - fun update(id: UUID, command: UpdateShiftCommand): Shift { + fun update(id: UUID, command: UpdateShiftCommand, actorId: String): Shift { validateWindow(command.startsAt, command.endsAt) val shift = shifts.findById(id) .orElseThrow { ShiftNotFoundException("Shift $id was not found") } @@ -62,17 +67,23 @@ class ShiftService( ) } + val before = snapshot(shift) shift.startsAt = command.startsAt shift.endsAt = command.endsAt - return shifts.save(shift) + val updated = shifts.save(shift) + auditLog.record(actorId, "SHIFT_UPDATED", "SHIFT", updated.id, before, snapshot(updated)) + return updated } @Transactional - fun cancel(id: UUID): Shift { + fun cancel(id: UUID, actorId: String): Shift { val shift = shifts.findById(id) .orElseThrow { ShiftNotFoundException("Shift $id was not found") } + val before = snapshot(shift) shift.status = ShiftStatus.CANCELLED - return shifts.save(shift) + val cancelled = shifts.save(shift) + auditLog.record(actorId, "SHIFT_CANCELLED", "SHIFT", cancelled.id, before, snapshot(cancelled)) + return cancelled } @Transactional(readOnly = true) @@ -96,6 +107,13 @@ class ShiftService( ) } + private fun snapshot(shift: Shift) = mapOf( + "employeeId" to shift.employee.id, + "startsAt" to shift.startsAt, + "endsAt" to shift.endsAt, + "status" to shift.status.name + ) + private fun validateWindow(startsAt: OffsetDateTime, endsAt: OffsetDateTime) { require(endsAt.isAfter(startsAt)) { "Shift end time must be after start time" } } diff --git a/src/main/resources/db/migration/V4__audit_events.sql b/src/main/resources/db/migration/V4__audit_events.sql new file mode 100644 index 0000000..f38387a --- /dev/null +++ b/src/main/resources/db/migration/V4__audit_events.sql @@ -0,0 +1,16 @@ +CREATE TABLE audit_events ( + id UUID PRIMARY KEY, + actor_id VARCHAR(120) NOT NULL CHECK (length(trim(actor_id)) > 0), + action VARCHAR(60) NOT NULL, + entity_type VARCHAR(60) NOT NULL, + entity_id UUID NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + before_state JSONB, + after_state JSONB +); + +CREATE INDEX idx_audit_events_entity_time + ON audit_events(entity_type, entity_id, occurred_at DESC); + +CREATE INDEX idx_audit_events_actor_time + ON audit_events(actor_id, occurred_at DESC); diff --git a/src/test/kotlin/dev/whrite/opsgrid/audit/AuditLogIntegrationTest.kt b/src/test/kotlin/dev/whrite/opsgrid/audit/AuditLogIntegrationTest.kt new file mode 100644 index 0000000..fcce562 --- /dev/null +++ b/src/test/kotlin/dev/whrite/opsgrid/audit/AuditLogIntegrationTest.kt @@ -0,0 +1,104 @@ +package dev.whrite.opsgrid.audit + +import dev.whrite.opsgrid.employee.Employee +import dev.whrite.opsgrid.employee.EmployeeRepository +import dev.whrite.opsgrid.shift.CreateShiftCommand +import dev.whrite.opsgrid.shift.ShiftService +import dev.whrite.opsgrid.shift.ShiftRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import java.time.OffsetDateTime +import java.util.UUID + +@SpringBootTest +class AuditLogIntegrationTest { + @Autowired + private lateinit var employees: EmployeeRepository + + @Autowired + private lateinit var shifts: ShiftRepository + + @Autowired + private lateinit var shiftService: ShiftService + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Test + fun successfulShiftMutationPersistsItsAuditEvent() { + val employee = createEmployee() + try { + val shift = shiftService.schedule( + CreateShiftCommand( + employee.id, + OffsetDateTime.parse("2026-10-20T08:00:00Z"), + OffsetDateTime.parse("2026-10-20T16:00:00Z") + ), + "integration-test" + ) + + val count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_events WHERE entity_id = ? AND actor_id = ?", + Long::class.javaObjectType, + shift.id, + "integration-test" + ) + val status = jdbcTemplate.queryForObject( + "SELECT after_state ->> 'status' FROM audit_events WHERE entity_id = ?", + String::class.java, + shift.id + ) + + assertEquals(1L, count) + assertEquals("SCHEDULED", status) + } finally { + shifts.deleteAll(shifts.findAll().filter { it.employee.id == employee.id }) + employees.deleteById(employee.id) + } + } + + @Test + fun failedAuditWriteRollsBackTheShiftMutation() { + val employee = createEmployee() + val startsAt = OffsetDateTime.parse("2026-10-21T08:00:00Z") + val endsAt = OffsetDateTime.parse("2026-10-21T16:00:00Z") + try { + assertThrows(IllegalArgumentException::class.java) { + shiftService.schedule( + CreateShiftCommand(employee.id, startsAt, endsAt), + "x".repeat(121) + ) + } + + val shiftCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM shifts WHERE employee_id = ?", + Long::class.javaObjectType, + employee.id + ) + val auditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_events WHERE actor_id = ?", + Long::class.javaObjectType, + "x".repeat(121) + ) + + assertEquals(0L, shiftCount) + assertEquals(0L, auditCount) + } finally { + employees.deleteById(employee.id) + } + } + + private fun createEmployee(): Employee = employees.save( + Employee( + UUID.randomUUID(), + "AUD-" + UUID.randomUUID().toString().take(8), + "Audit", + "Test", + "Operations" + ) + ) +} diff --git a/src/test/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveServiceTest.kt b/src/test/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveServiceTest.kt new file mode 100644 index 0000000..007a1e0 --- /dev/null +++ b/src/test/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveServiceTest.kt @@ -0,0 +1,106 @@ +package dev.whrite.opsgrid.leave + +import dev.whrite.opsgrid.audit.AuditLogService +import dev.whrite.opsgrid.employee.Employee +import dev.whrite.opsgrid.employee.EmployeeRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import java.time.OffsetDateTime +import java.util.Optional +import java.util.UUID + +class EmployeeLeaveServiceTest { + private val employees = mock() + private val leaves = mock() + private val auditLog = mock() + private val service = EmployeeLeaveService(employees, leaves, auditLog) + + private val startsAt = OffsetDateTime.parse("2026-11-02T08:00:00Z") + private val endsAt = OffsetDateTime.parse("2026-11-02T16:00:00Z") + + @Test + fun approvalWritesAnAuditEvent() { + val employeeId = UUID.randomUUID() + val employee = employee(employeeId) + whenever(employees.findById(employeeId)).thenReturn(Optional.of(employee)) + whenever(leaves.hasConflict(employeeId, startsAt, endsAt)).thenReturn(false) + whenever(leaves.save(any())).thenAnswer { it.arguments[0] as EmployeeLeave } + + val leave = service.approve( + CreateLeaveCommand(employeeId, startsAt, endsAt, " Annual leave "), + "operator-2" + ) + + assertEquals("Annual leave", leave.reason) + verify(auditLog).record( + "operator-2", + "LEAVE_APPROVED", + "EMPLOYEE_LEAVE", + leave.id, + null, + mapOf( + "employeeId" to employeeId, + "startsAt" to startsAt, + "endsAt" to endsAt, + "reason" to "Annual leave", + "status" to "APPROVED" + ) + ) + } + + @Test + fun rejectedLeaveDoesNotWriteAnAuditEvent() { + val employeeId = UUID.randomUUID() + whenever(employees.findById(employeeId)).thenReturn(Optional.of(employee(employeeId))) + whenever(leaves.hasConflict(employeeId, startsAt, endsAt)).thenReturn(true) + + assertThrows(LeaveConflictException::class.java) { + service.approve( + CreateLeaveCommand(employeeId, startsAt, endsAt, "Annual leave"), + "operator-2" + ) + } + + verify(leaves, never()).save(any()) + verifyNoInteractions(auditLog) + } + + @Test + fun cancellationAuditsBeforeAndAfterState() { + val employeeId = UUID.randomUUID() + val leaveId = UUID.randomUUID() + val employee = employee(employeeId) + val leave = EmployeeLeave(leaveId, employee, startsAt, endsAt, "Annual leave") + whenever(leaves.findById(leaveId)).thenReturn(Optional.of(leave)) + whenever(leaves.save(any())).thenAnswer { it.arguments[0] as EmployeeLeave } + + val cancelled = service.cancel(leaveId, "operator-2") + + assertEquals(LeaveStatus.CANCELLED, cancelled.status) + verify(auditLog).record( + "operator-2", + "LEAVE_CANCELLED", + "EMPLOYEE_LEAVE", + leaveId, + mapOf( + "employeeId" to employeeId, + "startsAt" to startsAt, + "endsAt" to endsAt, + "reason" to "Annual leave", + "status" to "APPROVED" + ), + mapOf( + "employeeId" to employeeId, + "startsAt" to startsAt, + "endsAt" to endsAt, + "reason" to "Annual leave", + "status" to "CANCELLED" + ) + ) + } + + private fun employee(id: UUID) = + Employee(id, "EMP-" + id.toString().take(8), "Test", "Employee", "Operations") +} diff --git a/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftServiceTest.kt b/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftServiceTest.kt index 17a63ad..048b8c3 100644 --- a/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftServiceTest.kt +++ b/src/test/kotlin/dev/whrite/opsgrid/shift/ShiftServiceTest.kt @@ -1,5 +1,6 @@ package dev.whrite.opsgrid.shift +import dev.whrite.opsgrid.audit.AuditLogService import dev.whrite.opsgrid.employee.Employee import dev.whrite.opsgrid.employee.EmployeeRepository import dev.whrite.opsgrid.leave.EmployeeLeaveRepository @@ -15,7 +16,8 @@ class ShiftServiceTest { private val employees = mock() private val shifts = mock() private val leaves = mock() - private val service = ShiftService(employees, shifts, leaves) + private val auditLog = mock() + private val service = ShiftService(employees, shifts, leaves, auditLog) private val morning = OffsetDateTime.parse("2026-10-06T08:00:00+03:00") private val afternoon = OffsetDateTime.parse("2026-10-06T16:00:00+03:00") @@ -29,12 +31,13 @@ class ShiftServiceTest { whenever(shifts.hasConflict(any(), any(), any())).thenReturn(true) val ex = assertThrows(ShiftConflictException::class.java) { - service.schedule(CreateShiftCommand(employeeId, morning, afternoon)) + service.schedule(CreateShiftCommand(employeeId, morning, afternoon), "operator-1") } assertEquals("Employee EMP-001 has a scheduling conflict in this time window", ex.message) verify(shifts, never()).save(any()) verify(leaves, never()).hasConflict(any(), any(), any()) + verifyNoInteractions(auditLog) } @Test @@ -45,10 +48,11 @@ class ShiftServiceTest { whenever(leaves.hasConflict(any(), any(), any())).thenReturn(true) assertThrows(ShiftConflictException::class.java) { - service.schedule(CreateShiftCommand(employeeId, morning, afternoon)) + service.schedule(CreateShiftCommand(employeeId, morning, afternoon), "operator-1") } verify(shifts, never()).save(any()) + verifyNoInteractions(auditLog) } @Test @@ -62,10 +66,11 @@ class ShiftServiceTest { whenever(leaves.hasConflict(employeeId, morning, evening)).thenReturn(true) assertThrows(ShiftConflictException::class.java) { - service.update(shiftId, UpdateShiftCommand(morning, evening)) + service.update(shiftId, UpdateShiftCommand(morning, evening), "operator-1") } verify(shifts, never()).save(any()) + verifyNoInteractions(auditLog) } @Test @@ -76,12 +81,13 @@ class ShiftServiceTest { ) assertThrows(EmployeeInactiveException::class.java) { - service.schedule(CreateShiftCommand(employeeId, morning, afternoon)) + service.schedule(CreateShiftCommand(employeeId, morning, afternoon), "operator-1") } verify(shifts, never()).save(any()) verify(shifts, never()).hasConflict(any(), any(), any()) verify(leaves, never()).hasConflict(any(), any(), any()) + verifyNoInteractions(auditLog) } @Test @@ -89,10 +95,10 @@ class ShiftServiceTest { val employeeId = UUID.randomUUID() assertThrows(IllegalArgumentException::class.java) { - service.schedule(CreateShiftCommand(employeeId, afternoon, morning)) + service.schedule(CreateShiftCommand(employeeId, afternoon, morning), "operator-1") } - verifyNoInteractions(employees, shifts, leaves) + verifyNoInteractions(employees, shifts, leaves, auditLog) } @Test @@ -103,7 +109,10 @@ class ShiftServiceTest { whenever(leaves.hasConflict(employeeId, afternoon, evening)).thenReturn(false) whenever(shifts.save(any())).thenAnswer { it.arguments[0] as Shift } - val shift = service.schedule(CreateShiftCommand(employeeId, afternoon, evening)) + val shift = service.schedule( + CreateShiftCommand(employeeId, afternoon, evening), + "operator-1" + ) assertEquals(afternoon, shift.startsAt) assertEquals(evening, shift.endsAt) @@ -118,24 +127,43 @@ class ShiftServiceTest { whenever(leaves.hasConflict(employeeId, morning, afternoon)).thenReturn(false) whenever(shifts.save(any())).thenAnswer { it.arguments[0] as Shift } - val shift = service.schedule(CreateShiftCommand(employeeId, morning, afternoon)) + val shift = service.schedule( + CreateShiftCommand(employeeId, morning, afternoon), + "operator-1" + ) assertEquals(employeeId, shift.employee.id) verify(shifts).save(any()) } @Test - fun createsShiftWhenThereIsNoConflict() { + fun createsShiftAndWritesAuditEvent() { val employeeId = UUID.randomUUID() whenever(employees.findById(employeeId)).thenReturn(Optional.of(employee(employeeId, "EMP-007"))) whenever(shifts.hasConflict(any(), any(), any())).thenReturn(false) whenever(leaves.hasConflict(any(), any(), any())).thenReturn(false) whenever(shifts.save(any())).thenAnswer { it.arguments[0] as Shift } - val shift = service.schedule(CreateShiftCommand(employeeId, morning, afternoon)) + val shift = service.schedule( + CreateShiftCommand(employeeId, morning, afternoon), + "operator-1" + ) assertEquals(employeeId, shift.employee.id) verify(shifts).save(any()) + verify(auditLog).record( + "operator-1", + "SHIFT_CREATED", + "SHIFT", + shift.id, + null, + mapOf( + "employeeId" to employeeId, + "startsAt" to morning, + "endsAt" to afternoon, + "status" to "SCHEDULED" + ) + ) } private fun employee(