Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/src/main/kotlin/com/seatly/desk/DeskController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ open class DeskController(
val responseBody = BookingResponse.from(created)
return HttpResponse.created(responseBody)
}

@Post("{deskId}/recurrence-bookings")
@Secured(SecurityRule.IS_AUTHENTICATED)
open fun createRecurrenceBooking(
authentication: Authentication,
@PathVariable deskId: Long,
@Body @Valid request: CreateBookingRequest,
): HttpResponse<List<BookingResponse>> {
val bookings =
deskManager.createRecurringBookings(
command =
request.toCommand(
deskId = deskId,
userId = authentication.name.toLong(),

),
)
val responseBody = bookings.map { BookingResponse.from(it) }
return HttpResponse.created(responseBody)
}
}

@Serdeable
Expand Down Expand Up @@ -127,6 +147,8 @@ data class CreateBookingRequest(
val startAt: LocalDateTime,
@field:NotNull
val endAt: LocalDateTime,
val recurrence_type: String? = null,
val duration: Long = 0,
) {
fun toCommand(
deskId: Long,
Expand All @@ -137,6 +159,8 @@ data class CreateBookingRequest(
userId = userId,
startAt = startAt,
endAt = endAt,
recurrence_type = recurrence_type,
duration = duration,
)
}

Expand Down
30 changes: 29 additions & 1 deletion backend/src/main/kotlin/com/seatly/desk/DeskManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class DeskManager(
require(command.startAt.isBefore(command.endAt)) {
"startAt must be before endAt"
}

val normalizedStart = command.startAt.truncatedTo(ChronoUnit.MINUTES)
val normalizedEnd = command.endAt.truncatedTo(ChronoUnit.MINUTES)

Expand All @@ -99,6 +99,32 @@ class DeskManager(

val savedBooking = bookingRepository.save(booking)
return BookingDto.from(savedBooking)

}

public fun createRecurringBookings(command: CreateBookingCommand): List<BookingDto> {
require(command.startAt.isBefore(command.endAt)) {
"startAt must be before endAt"
}
require(command.recurrence_type == "WEEKLY") {
"Only weekly recurrence is supported"
}
val normalizedStart = command.startAt.truncatedTo(ChronoUnit.MINUTES)
val normalizedEnd = command.endAt.truncatedTo(ChronoUnit.MINUTES)

val occurrences = (0 until command.duration).map { weekIndex ->
val occurrenceStart = normalizedStart.plusWeeks(weekIndex.toLong())
val occurrenceEnd = normalizedEnd.plusWeeks(weekIndex.toLong())

if (bookingRepository.existsOverlappingBooking(command.deskId, occurrenceStart, occurrenceEnd)) {
throw IllegalStateException("Conflict for weekly occurrence on ${occurrenceStart.toLocalDate()}")
}

Booking(deskId = command.deskId, userId = command.userId, startAt = occurrenceStart, endAt = occurrenceEnd)
}

val savedBookings = bookingRepository.saveAll(occurrences)
return savedBookings.map { BookingDto.from(it) }
}
}

Expand Down Expand Up @@ -152,6 +178,8 @@ data class CreateBookingCommand(
val userId: Long,
val startAt: LocalDateTime,
val endAt: LocalDateTime,
val duration: Long,
val recurrence_type: String?,
)

data class BookingDto(
Expand Down
234 changes: 234 additions & 0 deletions backend/src/test/kotlin/com/seatly/desk/RecurringBookingServiceTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package com.seatly.desk

import com.seatly.user.CreateUserRequest
import com.seatly.user.LoginRequest
import com.seatly.user.LoginResponse
import com.seatly.user.UserRepository
import com.seatly.user.UserResponse
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit

@MicronautTest(transactional = false)
class RecurringBookingServiceTest {

@Inject
@field:Client("/")
lateinit var client: HttpClient

@Inject
lateinit var deskRepository: DeskRepository

@Inject
lateinit var userRepository: UserRepository

@Inject
lateinit var bookingRepository: BookingRepository

private lateinit var authUser: UserResponse
private lateinit var authToken: String

@BeforeEach
fun setUp() {
bookingRepository.deleteAll()
deskRepository.deleteAll()
userRepository.deleteAll()

val createUserRequest = CreateUserRequest(
email = "[email protected]",
password = "password123",
fullName = "Test User"
)
authUser = client.toBlocking().retrieve(
HttpRequest.POST("/users", createUserRequest),
Argument.of(UserResponse::class.java)
)

val loginRequest = LoginRequest(
email = "[email protected]",
password = "password123"
)
val loginResponse = client.toBlocking().retrieve(
HttpRequest.POST("/users/login", loginRequest),
LoginResponse::class.java
)

authToken = loginResponse.token
}

@Test
fun `should create recurring bookings successfully without conflicts`() {

val desk: DeskResponse =
createDesk(
client = client,
authToken = authToken,
name = "Booking Desk 1",
location = "Booking Floor 1",
)
val deskId = desk.id!!

val now = LocalDateTime.now().plusHours(1)

val duration = 2L
val createBookingRequest =
CreateBookingRequest(
startAt = now,
endAt = now.plusHours(1),
recurrence_type = "WEEKLY",
duration = duration,
)

val bookingsResponse =
client.toBlocking().exchange(
HttpRequest
.POST("desks/$deskId/recurrence-bookings", createBookingRequest)
.bearerAuth(authToken),
Argument.listOf(BookingResponse::class.java),
)

assertEquals(HttpStatus.CREATED, bookingsResponse.status)
val bookings = bookingsResponse.body()
assertNotNull(bookings)

val booking = bookings[0]
assertNotNull(booking)
assertEquals(deskId, booking!!.deskId)
assertEquals(authUser.id, booking.userId)
assertEquals(createBookingRequest.startAt.truncatedTo(ChronoUnit.MINUTES), booking.startAt)
assertEquals(createBookingRequest.endAt.truncatedTo(ChronoUnit.MINUTES), booking.endAt)
}

@Test
fun `should reject recurring booking with conflicts`() {
val desk: DeskResponse =
createDesk(
client = client,
authToken = authToken,
name = "Booking Desk 1",
location = "Booking Floor 1",
)
val deskId = desk.id!!

val now = LocalDateTime.now().plusHours(1)

val startAt = now
val endAt = now.plusHours(1)

val createBookingRequest =
CreateBookingRequest(
startAt = startAt,
endAt = endAt,
)

client.toBlocking().exchange(
HttpRequest
.POST("desks/$deskId/bookings", createBookingRequest)
.bearerAuth(authToken),
BookingResponse::class.java,
)

val duration = 2L
val createRecurrenceBookingRequest =
CreateBookingRequest(
startAt = startAt,
endAt = endAt,
recurrence_type = "WEEKLY",
duration = duration,
)

val exception = assertThrows(HttpClientResponseException::class.java) {
client.toBlocking().exchange(
HttpRequest
.POST("desks/$deskId/recurrence-bookings", createRecurrenceBookingRequest)
.bearerAuth(authToken),
Argument.listOf(BookingResponse::class.java),
)
}

assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, exception.status)
}


@Test
fun `should reject creating booking without token`() {
val desk: DeskResponse =
createDesk(
client = client,
authToken = authToken,
name = "Booking Desk 1",
location = "Booking Floor 1",
)
val deskId = desk.id!!

val now = LocalDateTime.now().plusHours(1)

val startAt = now
val endAt = now.plusHours(1)
val duration = 2L

val createRecurrenceBookingRequest =
CreateBookingRequest(
startAt = startAt,
endAt = endAt,
recurrence_type = "WEEKLY",
duration = duration,
)

val exception = assertThrows(HttpClientResponseException::class.java) {
client.toBlocking().exchange(
HttpRequest
.POST("desks/$deskId/recurrence-bookings", createRecurrenceBookingRequest),
Argument.listOf(BookingResponse::class.java),
)
}

assertEquals(HttpStatus.UNAUTHORIZED, exception.status)
}

fun `should throw IllegalArgumentException when recurrence_type is not WEEKLY`() {
val desk: DeskResponse =
createDesk(
client = client,
authToken = authToken,
name = "Booking Desk 1",
location = "Booking Floor 1",
)
val deskId = desk.id!!

val now = LocalDateTime.now().plusHours(1)

val startAt = now
val endAt = now.plusHours(1)
val duration = 2L

val invalidRecurrenceType = "DAILY"

val createRecurrenceBookingRequest =
CreateBookingRequest(
startAt = startAt,
endAt = endAt,
recurrence_type = invalidRecurrenceType,
duration = duration,
)
val exception = assertThrows(IllegalArgumentException::class.java) {
client.toBlocking().exchange(
HttpRequest
.POST("desks/$deskId/recurrence-bookings", createRecurrenceBookingRequest),
Argument.listOf(BookingResponse::class.java),
)
}

assertEquals("Only weekly recurrence is supported", exception.message)
}
}
Loading