Skip to content

Commit

Permalink
- Add an endpoint that creates task lists.
Browse files Browse the repository at this point in the history
- Add an endpoint that reads tasks from a task list.
  • Loading branch information
yaskovdev committed Aug 26, 2024
1 parent 868cd65 commit cb6b75b
Show file tree
Hide file tree
Showing 18 changed files with 194 additions and 25 deletions.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
</scm>
<properties>
<java.version>17</java.version>
<kotlin.version>2.0.20-RC2</kotlin.version>
<kotlin.version>2.0.20</kotlin.version>
<swagger.version>2.9.2</swagger.version>
<dbunit.version>2.8.0</dbunit.version>
<spring.test.dbunit.version>1.3.0</spring.test.dbunit.version>
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/org/motivepick/domain/model/TaskList.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.motivepick.domain.model

import org.motivepick.domain.entity.TaskListType

data class TaskList(val id: Long, val type: TaskListType)
5 changes: 5 additions & 0 deletions src/main/kotlin/org/motivepick/domain/view/TaskListView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.motivepick.domain.view

import org.motivepick.domain.entity.TaskListType

data class TaskListView(val id: Long, val type: TaskListType)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.motivepick.exception

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ResponseStatus

@ResponseStatus(HttpStatus.BAD_REQUEST)
open class ClientErrorException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.motivepick.exception

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ResponseStatus

@ResponseStatus(HttpStatus.NOT_FOUND)
class ResourceNotFoundException(message: String) : ClientErrorException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.motivepick.extensions

import org.motivepick.domain.entity.TaskListEntity
import org.motivepick.domain.model.TaskList

internal object TaskListEntityExtensions {

fun TaskListEntity.model(): TaskList = TaskList(this.id, this.type)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.motivepick.extensions

import org.motivepick.domain.model.TaskList
import org.motivepick.domain.view.TaskListView

internal object TaskListExtensions {

fun TaskList.view(): TaskListView = TaskListView(this.id, this.type)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface TaskListRepository : CrudRepository<TaskListEntity, Long>, PagingAndSo

fun findByUserAccountIdAndType(accountId: String, type: TaskListType): TaskListEntity?

fun findByUserAccountIdAndId(accountId: String, id: Long): TaskListEntity?

fun deleteByUserAccountId(accountId: String)

fun deleteByIdIn(ids: List<Long>)
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/org/motivepick/service/TaskListService.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package org.motivepick.service

import org.motivepick.domain.entity.TaskListType
import org.motivepick.domain.model.TaskList
import org.motivepick.domain.view.TaskView
import java.util.*

interface TaskListService {

fun createTaskList(): TaskList

fun moveTask(sourceListType: TaskListType, sourceIndex: Int, destinationListType: TaskListType, destinationIndex: Int)

fun closeTask(taskId: Long): Optional<TaskView>
Expand Down
17 changes: 15 additions & 2 deletions src/main/kotlin/org/motivepick/service/TaskListServiceImpl.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package org.motivepick.service

import org.motivepick.domain.entity.TaskListEntity
import org.motivepick.domain.entity.TaskListType
import org.motivepick.domain.entity.TaskListType.CLOSED
import org.motivepick.domain.entity.TaskListType.INBOX
import org.motivepick.domain.model.TaskList
import org.motivepick.domain.view.TaskView
import org.motivepick.exception.ResourceNotFoundException
import org.motivepick.extensions.ListExtensions.add
import org.motivepick.extensions.TaskEntityExtensions.view
import org.motivepick.extensions.TaskListEntityExtensions.model
import org.motivepick.repository.TaskListRepository
import org.motivepick.repository.TaskRepository
import org.motivepick.repository.UserRepository
import org.motivepick.security.CurrentUser
import org.motivepick.extensions.ListExtensions.add
import org.motivepick.extensions.TaskEntityExtensions.view
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.data.repository.findByIdOrNull
Expand All @@ -21,12 +26,20 @@ import java.util.Optional.empty
@Service
internal class TaskListServiceImpl(
private val user: CurrentUser,
private val userRepository: UserRepository,
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) : TaskListService {

private val logger: Logger = LoggerFactory.getLogger(TaskListServiceImpl::class.java)

@Transactional
override fun createTaskList(): TaskList {
val accountId = user.getAccountId()
val user = userRepository.findByAccountId(accountId) ?: throw ResourceNotFoundException("User does not exist, it was deleted or blocked")
return taskListRepository.save(TaskListEntity(user, TaskListType.CUSTOM, listOf())).model()
}

@Transactional
override fun moveTask(sourceListType: TaskListType, sourceIndex: Int, destinationListType: TaskListType, destinationIndex: Int) {
val accountId = user.getAccountId()
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/org/motivepick/service/TaskService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.motivepick.service

import org.motivepick.domain.entity.TaskListType
import org.motivepick.domain.entity.UserEntity
import org.motivepick.domain.view.CreateTaskRequest
import org.motivepick.domain.view.ScheduleView
Expand All @@ -17,7 +16,7 @@ interface TaskService {

fun softDeleteTaskById(taskId: Long): TaskView?

fun findForCurrentUser(listType: TaskListType, offset: Long, limit: Int): Page<TaskView>
fun findForCurrentUser(listId: String, offset: Long, limit: Int): Page<TaskView>

fun findScheduleForCurrentUser(timeZone: ZoneId): ScheduleView

Expand Down
29 changes: 24 additions & 5 deletions src/main/kotlin/org/motivepick/service/TaskServiceImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package org.motivepick.service
import org.motivepick.domain.entity.TaskEntity
import org.motivepick.domain.entity.TaskListEntity
import org.motivepick.domain.entity.TaskListType
import org.motivepick.domain.entity.TaskListType.CLOSED
import org.motivepick.domain.entity.TaskListType.INBOX
import org.motivepick.domain.entity.TaskListType.*
import org.motivepick.domain.entity.UserEntity
import org.motivepick.domain.model.ScheduledTask
import org.motivepick.domain.view.CreateTaskRequest
import org.motivepick.domain.view.ScheduleView
import org.motivepick.domain.view.TaskView
import org.motivepick.domain.view.UpdateTaskRequest
import org.motivepick.exception.ClientErrorException
import org.motivepick.exception.ResourceNotFoundException
import org.motivepick.extensions.ListExtensions.withPageable
import org.motivepick.extensions.ScheduleExtensions.view
import org.motivepick.extensions.TaskEntityExtensions.view
Expand Down Expand Up @@ -40,6 +41,8 @@ internal class TaskServiceImpl(

private val logger: Logger = LoggerFactory.getLogger(TaskServiceImpl::class.java)

private val predefinedTaskListTypes = listOf(INBOX, CLOSED, SCHEDULE_SECTION, DELETED)

@Transactional
override fun findTaskById(taskId: Long): TaskView? {
val task = taskRepository.findByIdAndVisibleTrue(taskId).getOrNull()
Expand Down Expand Up @@ -84,11 +87,11 @@ internal class TaskServiceImpl(
}

@Transactional
override fun findForCurrentUser(listType: TaskListType, offset: Long, limit: Int): Page<TaskView> {
override fun findForCurrentUser(listId: String, offset: Long, limit: Int): Page<TaskView> {
val pageable = OffsetBasedPageable(offset, limit)
val accountId = currentUser.getAccountId()
val taskList = taskListRepository.findByUserAccountIdAndType(accountId, listType)
val taskIdsPage = taskList!!.orderedIds.withPageable(pageable)
val taskList = findTaskList(accountId, listId) ?: throw ResourceNotFoundException("Task list with ID or type $listId not found for user $accountId")
val taskIdsPage = taskList.orderedIds.withPageable(pageable)
val tasks = taskRepository.findAllByIdInAndVisibleTrue(taskIdsPage.content).map { it.view() }
val taskToId: Map<Long?, TaskView> = tasks.associateBy { it.id }
return PageImpl(taskIdsPage.mapNotNull { taskToId[it] }, pageable, taskIdsPage.totalElements)
Expand Down Expand Up @@ -164,4 +167,20 @@ internal class TaskServiceImpl(
}

private fun currentUserOwns(task: TaskEntity) = task.user.accountId == currentUser.getAccountId()

private fun findTaskList(accountId: String, listId: String): TaskListEntity? {
try {
val listType = TaskListType.valueOf(listId)
if (predefinedTaskListTypes.contains(listType)) {
return taskListRepository.findByUserAccountIdAndType(accountId, listType)
}
throw ClientErrorException("Invalid task list ID: $listId, must be one of $predefinedTaskListTypes")
} catch (e: IllegalArgumentException) {
try {
return taskListRepository.findByUserAccountIdAndId(accountId, listId.toLong())
} catch (e: NumberFormatException) {
throw ClientErrorException("Invalid task list ID: $listId, must be a number or one of ${TaskListType.entries.toTypedArray()}")
}
}
}
}
13 changes: 9 additions & 4 deletions src/main/kotlin/org/motivepick/web/TaskListController.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package org.motivepick.web

import org.motivepick.domain.entity.TaskListType
import org.motivepick.domain.view.MoveTaskRequest
import org.motivepick.domain.view.TaskListView
import org.motivepick.domain.view.TaskView
import org.motivepick.extensions.TaskListExtensions.view
import org.motivepick.service.TaskListService
import org.motivepick.service.TaskService
import org.springframework.data.domain.Page
Expand All @@ -13,9 +14,13 @@ import org.springframework.web.bind.annotation.*
@RestController
internal class TaskListController(private val taskService: TaskService, private val taskListService: TaskListService) {

@GetMapping("/task-lists/{type}")
fun read(@PathVariable("type") listType: TaskListType, @RequestParam("offset") offset: Long, @RequestParam("limit") limit: Int): ResponseEntity<Page<TaskView>> =
ok(taskService.findForCurrentUser(listType, offset, limit))
@PostMapping("/task-lists")
fun create(): ResponseEntity<TaskListView> =
ok(taskListService.createTaskList().view())

@GetMapping("/task-lists/{id}")
fun read(@PathVariable("id") listId: String, @RequestParam("offset") offset: Long, @RequestParam("limit") limit: Int): ResponseEntity<Page<TaskView>> =
ok(taskService.findForCurrentUser(listId, offset, limit))

@PostMapping("/orders")
fun moveTask(@RequestBody request: MoveTaskRequest): ResponseEntity<Void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.motivepick.web

import com.github.springtestdbunit.annotation.DatabaseOperation.DELETE_ALL
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DatabaseTearDown
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import jakarta.servlet.http.Cookie
import org.hamcrest.Matchers.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.motivepick.IntegrationTest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.test.context.junit.jupiter.SpringExtension
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.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@ExtendWith(SpringExtension::class)
@IntegrationTest(1234567890L)
@DatabaseSetup("/dbunit/tasks.xml")
@DatabaseTearDown("/dbunit/tasks.xml", type = DELETE_ALL)
@DbUnitConfiguration(databaseConnection = ["dbUnitDatabaseConnection"])
@AutoConfigureMockMvc
class TaskListControllerIntegrationTest {

@Autowired
private lateinit var mockMvc: MockMvc

@Test
fun `should create a custom task list if the user exists`() {
val token = readTextFromResource("token.aae47dd3-32f1-415d-8bd8-4dc1086a6d10.txt")
mockMvc
.perform(post("/task-lists").cookie(Cookie("Authorization", token)))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("{\"id\":1,\"type\":\"CUSTOM\"}")))
}

@Test
fun `should respond with 404 if the user does not exist`() {
val token = readTextFromResource("token.c03354bb-0c31-4010-90a1-65582f4c35cf.txt")
mockMvc
.perform(post("/task-lists").cookie(Cookie("Authorization", token)))
.andExpect(status().isNotFound())
}

@Test
fun `should read tasks by task list ID`() {
val token = readTextFromResource("token.aae47dd3-32f1-415d-8bd8-4dc1086a6d10.txt")
mockMvc
.perform(get("/task-lists/4").param("offset", "0").param("limit", "1").cookie(Cookie("Authorization", token)))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("{\"content\":[{\"id\":4,\"name\":\"Test task 3\",\"description\":\"\",\"dueDate\":null,\"closed\":false}],\"page\":{\"size\":1,\"number\":0,\"totalElements\":1,\"totalPages\":1}}")))
}

@Test
fun `should read tasks by a predefined task list type`() {
val token = readTextFromResource("token.aae47dd3-32f1-415d-8bd8-4dc1086a6d10.txt")
mockMvc
.perform(get("/task-lists/INBOX").param("offset", "0").param("limit", "1").cookie(Cookie("Authorization", token)))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("{\"content\":[{\"id\":4,\"name\":\"Test task 3\",\"description\":\"\",\"dueDate\":null,\"closed\":false}],\"page\":{\"size\":1,\"number\":0,\"totalElements\":1,\"totalPages\":1}}")))
}

@Test
fun `should return 400 if the task list type is not predefined`() {
val token = readTextFromResource("token.aae47dd3-32f1-415d-8bd8-4dc1086a6d10.txt")
mockMvc
.perform(get("/task-lists/CUSTOM").param("offset", "0").param("limit", "1").cookie(Cookie("Authorization", token)))
.andExpect(status().is4xxClientError())
}

@Test
fun `should return 404 if task list does not exist`() {
val token = readTextFromResource("token.aae47dd3-32f1-415d-8bd8-4dc1086a6d10.txt")
mockMvc
.perform(get("/task-lists/1000000").param("offset", "0").param("limit", "1").cookie(Cookie("Authorization", token)))
.andExpect(status().isNotFound())
}

private fun readTextFromResource(path: String) = this::class.java.classLoader.getResource(path)?.readText() ?: error("Resource not found: $path")
}
4 changes: 2 additions & 2 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ github:
clientSecret: testClientSecret

jwt.token:
issuer: motive-test
signing.key: testSigningKey
issuer: http://localhost:8080
signing.key: someVerySecureKey
18 changes: 9 additions & 9 deletions src/test/resources/dbunit/tasks.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
<dataset>

<USER_ACCOUNT ID="1" ACCOUNT_ID="1234567890" NAME="Firstname Lastname"/>
<USER_ACCOUNT ID="2" ACCOUNT_ID="0000000000" NAME="John Smith"/>
<USER_ACCOUNT ID="2" ACCOUNT_ID="aae47dd3-32f1-415d-8bd8-4dc1086a6d10" NAME="John Smith"/>

<TASK_LIST ID="1" USER_ID="1" TASK_LIST_TYPE="INBOX" ORDERED_TASK_IDS="[2]"/>
<TASK_LIST ID="2" USER_ID="1" TASK_LIST_TYPE="CLOSED" ORDERED_TASK_IDS="[3]"/>
<TASK_LIST ID="3" USER_ID="2" TASK_LIST_TYPE="INBOX" ORDERED_TASK_IDS="[4]"/>
<TASK_LIST ID="2" USER_ID="1" TASK_LIST_TYPE="INBOX" ORDERED_TASK_IDS="[2]"/>
<TASK_LIST ID="3" USER_ID="1" TASK_LIST_TYPE="CLOSED" ORDERED_TASK_IDS="[3]"/>
<TASK_LIST ID="4" USER_ID="2" TASK_LIST_TYPE="INBOX" ORDERED_TASK_IDS="[4]"/>

<!-- TODO: replace IDs of the tasks with 1, 2, 3 and run the tests again, check why fail -->
<!-- TODO: replace IDs of the TASK entities with 1, 2, 3 and run the tests again, check why fail. Same for TASK_LIST. -->
<TASK ID="2" NAME="Test task" CREATED="2018-08-11 19:55:47.900000" DESCRIPTION="Test Description"
CLOSED="FALSE" DUE_DATE="2019-01-02 00:00:00.000000" USER_ID="1" TASK_LIST_ID="1"/>
CLOSED="FALSE" DUE_DATE="2019-01-02 00:00:00.000000" USER_ID="1" TASK_LIST_ID="2"/>

<TASK ID="3" NAME="Test task 2" CREATED="2018-10-11 19:55:47.900000" DESCRIPTION="Test Description 2"
CLOSED="TRUE" DUE_DATE="2019-01-02 00:00:00.000000" USER_ID="1" TASK_LIST_ID="2"/>
CLOSED="TRUE" DUE_DATE="2019-01-02 00:00:00.000000" USER_ID="1" TASK_LIST_ID="3"/>

<TASK ID="4" NAME="Test task 3" CREATED="2018-10-11 19:55:47.900000" USER_ID="2" CLOSED="FALSE" TASK_LIST_ID="3"/>
<TASK ID="4" NAME="Test task 3" CREATED="2018-10-11 19:55:47.900000" USER_ID="2" CLOSED="FALSE" TASK_LIST_ID="4"/>

</dataset>
</dataset>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bearer+eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhYWU0N2RkMy0zMmYxLTQxNWQtOGJkOC00ZGMxMDg2YTZkMTAiLCJzY29wZXMiOlsiUk9MRV9VU0VSIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImlhdCI6MTcyNDY5MjM4M30.-xmE8jv0gkbRTRRKuhTavgq8rFK7ZcgE2TCNw5BUYZory17yoWly6HxQnyi-OBpQkBerFdHVG_5XIQ_aNJ2L0w
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bearer+eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjMDMzNTRiYi0wYzMxLTQwMTAtOTBhMS02NTU4MmY0YzM1Y2YiLCJzY29wZXMiOlsiUk9MRV9VU0VSIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImlhdCI6MTcyNDcxNjA3Mn0.O4_3Bc_-D-wIVC3W17nsJ7bax2F-xJDze3y3dm-nOLNKjKgkPcdpndJjPuVdrh_OoGp8QVy_m71KuLUQtMyWcw

0 comments on commit cb6b75b

Please sign in to comment.