update get type(s) for pagination and filter

- update TypesEndpoint for all new endpoints
- update application to use properties for sql script
- update types controller
  - update types.http for all types of types calls
  - update test mocking for service
  - update getTypes to use filter, page, and show in TypesController
  - add validation dependency
- update unit test for uuid assertion from test library in
  - remove UUIDAssert.kt
  - update IdentityControllerTest
  - update MappingKtTest
  - update NodeControllerTest
  - update TypeControllerTest
  - update TypesControllerTest
  - add test library dependency
- update getTypes to handle filter limit and offset in TypeService
- update TypeRepository
  - add findAllContaining with filter, limit and offset
  - add findAll with limit and offset
- update type database with specific name key
- split Type and Types
This commit is contained in:
2025-03-05 22:17:07 +01:00
parent 10b95057e5
commit 53db4408e2
21 changed files with 407 additions and 97 deletions

View File

@@ -16,12 +16,14 @@ dependencies {
implementation(hlaeja.library.jwt)
implementation(hlaeja.springboot.starter.actuator)
implementation(hlaeja.springboot.starter.r2dbc)
implementation(hlaeja.springboot.starter.validation)
implementation(hlaeja.springboot.starter.webflux)
runtimeOnly(hlaeja.postgresql)
runtimeOnly(hlaeja.postgresql.r2dbc)
testImplementation(hlaeja.assertj.core)
testImplementation(hlaeja.library.test)
testImplementation(hlaeja.mockk)
testImplementation(hlaeja.projectreactor.reactor.test)
testImplementation(hlaeja.kotlin.test.junit5)

View File

@@ -1,7 +1,3 @@
### let all types
GET {{hostname}}/types
### add type by name
POST {{hostname}}/type
Content-Type: application/json

17
http/types.http Normal file
View File

@@ -0,0 +1,17 @@
### get all types
GET {{hostname}}/types
### get all types
GET {{hostname}}/types/page-1
### get all types
GET {{hostname}}/types/page-1/show-2
### get all types
GET {{hostname}}/types/filter-{filter}
### get all types
GET {{hostname}}/types/filter-{filter}/page-1
### get all types
GET {{hostname}}/types/filter-{filter}/page-1/show-2

6
sql/002-types.sql Normal file
View File

@@ -0,0 +1,6 @@
-- make name index unique order by name
DROP INDEX IF EXISTS types_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS types_name_key
ON types (name ASC);

View File

@@ -5,13 +5,18 @@ CREATE TABLE IF NOT EXISTS public.types
(
id UUID DEFAULT gen_uuid_v7(),
timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(50) NOT NULL,
CONSTRAINT pk_contact_types PRIMARY KEY (id)
);
ALTER TABLE IF EXISTS public.types
OWNER to role_administrator;
-- Index: types_name_key
-- DROP INDEX IF EXISTS types_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS types_name_key
ON types (name ASC);
-- Revoke all permissions from existing roles
REVOKE ALL ON TABLE public.types FROM role_administrator, role_maintainer, role_support, role_service;

View File

@@ -2,7 +2,6 @@ package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
@@ -35,23 +34,6 @@ class TypeEndpoint {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Nested
inner class GetTypes {
@Test
fun `get types`() {
// when
val result = webClient.get().uri("/types").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(5)
}
}
}
@Nested
inner class CreateType {
@@ -80,7 +62,7 @@ class TypeEndpoint {
fun `added type - fail name take`() {
// given
val request = Type.Request(
name = "Thing 1",
name = "Thing 1 v1",
)
// when

View File

@@ -0,0 +1,210 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TypesEndpoint {
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Test
fun `get types default - success`() {
// when
val result = webClient.get().uri("/types").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(4)
}
}
@ParameterizedTest
@CsvSource(
value = [
"1,4",
"2,0",
],
)
fun `get types by page - success`(page: Int, expected: Int) {
// when
val result = webClient.get().uri("/types/page-$page").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@Test
fun `get types by pages - fail`() {
// when
val result = webClient.get().uri("/types/page-0").exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"1,2,2",
"2,2,2",
"3,2,0",
],
)
fun `get types by page and show - success`(page: Int, show: Int, expected: Int) {
// when
val result = webClient.get().uri("/types/page-$page/show-$show").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"0,1",
"1,0",
],
)
fun `get types by page and show - fail`(page: Int, show: Int) {
// when
val result = webClient.get().uri("/types/page-$page/show-$show").exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"'',4",
"v1,3",
"v2,1",
"v3,0",
],
)
fun `get types filter - success`(filter: String, expected: Int) {
// when
val result = webClient.get().uri("/types/filter-$filter").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"'',1,4",
"'',2,0",
"v1,1,3",
"v1,2,0",
"v2,1,1",
"v2,2,0",
"v3,1,0",
],
)
fun `get types by filter and page - success`(filter: String, page: Int, expected: Int) {
// when
val result = webClient.get().uri("/types/filter-$filter/page-$page").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"'',0",
"v1,0",
],
)
fun `get types by filter and page - fail`(filter: String, page: Int) {
// when
val result = webClient.get().uri("/types/filter-$filter/page-$page").exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"'',1,2,2",
"'',2,2,2",
"'',3,2,0",
"v1,1,2,2",
"v1,2,2,1",
"v1,3,2,0",
"v2,1,2,1",
"v2,2,2,0",
"v3,1,2,0",
],
)
fun `get types by filter, page and show - success`(filter: String, page: Int, show: Int, expected: Int) {
// when
val result = webClient.get().uri("/types/filter-$filter/page-$page/show-$show").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"'',1,0",
"'',0,1",
"v1,1,0",
"v1,0,1",
],
)
fun `get types by filter, page and show - fail`(filter: String, page: Int, show: Int) {
// when
val result = webClient.get().uri("/types/filter-$filter/page-$page/show-$show").exchange()
// then
result.expectStatus().isBadRequest
}
}

View File

@@ -6,3 +6,10 @@ spring:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder
container:
postgres:
version: postgres:17
init: postgres/schema.sql
before: postgres/data.sql
after: postgres/reset.sql

View File

@@ -1,9 +1,9 @@
-- Test data
INSERT INTO public.types (id, timestamp, name)
VALUES ('00000000-0000-0000-0001-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 1'),
('00000000-0000-0000-0001-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 2'),
('00000000-0000-0000-0001-000000000003'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 3'),
('00000000-0000-0000-0001-000000000004'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 4');
VALUES ('00000000-0000-0000-0001-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 1 v1'),
('00000000-0000-0000-0001-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 2 v1'),
('00000000-0000-0000-0001-000000000003'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 3 v2'),
('00000000-0000-0000-0001-000000000004'::uuid, '2000-01-01 00:00:00.000001 +00:00', 'Thing 4 v1');
INSERT INTO public.devices (id, timestamp, type)
VALUES ('00000000-0000-0000-0002-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', '00000000-0000-0000-0001-000000000001'::uuid),

View File

@@ -1,8 +1,8 @@
-- Disable triggers on the tables
-- Truncate tables
TRUNCATE TABLE types;
TRUNCATE TABLE devices;
TRUNCATE TABLE nodes;
TRUNCATE TABLE nodes CASCADE;
TRUNCATE TABLE devices CASCADE;
TRUNCATE TABLE types CASCADE;
-- Enable triggers on the account table

View File

@@ -1,12 +1,9 @@
package ltd.hlaeja.controller
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeEntity
import ltd.hlaeja.util.toTypeResponse
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.RestController
@@ -16,9 +13,6 @@ class TypeController(
private val service: TypeService,
) {
@GetMapping("/types")
fun getTypes(): Flow<Type.Response> = service.getTypes().map { it.toTypeResponse() }
@PostMapping("/type")
suspend fun addType(
@RequestBody register: Type.Request,

View File

@@ -0,0 +1,36 @@
package ltd.hlaeja.controller
import jakarta.validation.constraints.Min
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@RestController
class TypesController(
private val service: TypeService,
) {
companion object {
const val DEFAULT_PAGE: Int = 1
const val DEFAULT_SIZE: Int = 25
}
@GetMapping(
"/types",
"/types/page-{page}",
"/types/page-{page}/show-{show}",
"/types/filter-{filter}",
"/types/filter-{filter}/page-{page}",
"/types/filter-{filter}/page-{page}/show-{show}",
)
fun getTypes(
@PathVariable(required = false) @Min(1) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE,
@PathVariable(required = false) filter: String? = null,
): Flow<Type.Response> = service.getTypes((page - 1) * show, show, filter)
.map { it.toTypeResponse() }
}

View File

@@ -1,9 +1,27 @@
package ltd.hlaeja.repository
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.entity.TypeEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID>
interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID> {
@Query("SELECT * FROM types ORDER BY name LIMIT :limit OFFSET :offset")
fun findAll(
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<TypeEntity>
@Query("SELECT * FROM types WHERE name ILIKE :filter ORDER BY name LIMIT :limit OFFSET :offset")
fun findAllContaining(
@Param("filter") filter: String,
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<TypeEntity>
}

View File

@@ -16,7 +16,14 @@ class TypeService(
private val typeRepository: TypeRepository,
) {
fun getTypes(): Flow<TypeEntity> = typeRepository.findAll()
fun getTypes(
page: Int,
show: Int,
filter: String?,
): Flow<TypeEntity> = when {
!filter.isNullOrEmpty() -> typeRepository.findAllContaining("%$filter%", page, show)
else -> typeRepository.findAll(page, show)
}
suspend fun addType(
entity: TypeEntity,

View File

@@ -1,15 +0,0 @@
package ltd.hlaeja.assertj
import java.util.UUID
import org.assertj.core.api.AbstractAssert
class UUIDAssert(actual: UUID) : AbstractAssert<UUIDAssert, UUID>(actual, UUIDAssert::class.java) {
fun isUUID(expected: String): UUIDAssert {
objects.assertEqual(this.info, this.actual, UUID.fromString(expected))
return this.myself
}
}
fun assertThat(actual: UUID): UUIDAssert {
return UUIDAssert(actual)
}

View File

@@ -8,9 +8,10 @@ import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.assertj.assertThat
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.service.NodeService
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -48,8 +49,8 @@ class IdentityControllerTest {
// then
coVerify(exactly = 1) { service.getNodeFromDevice(any()) }
assertThat(response.node).isUUID("00000000-0000-0000-0000-000000000001")
assertThat(response.client).isUUID("00000000-0000-0000-0000-000000000002")
assertThat(response.device).isUUID("00000000-0000-0000-0000-000000000003")
assertThat(response.node).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(response.client).isEqualToUuid("00000000-0000-0000-0000-000000000002")
assertThat(response.device).isEqualToUuid("00000000-0000-0000-0000-000000000003")
}
}

View File

@@ -5,10 +5,10 @@ import io.mockk.coVerify
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.assertj.assertThat
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.service.NodeService
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -42,9 +42,9 @@ class NodeControllerTest {
// then
coVerify(exactly = 1) { service.addNode(any()) }
assertThat(response.id).isUUID("00000000-0000-0000-0000-000000000003")
assertThat(response.client).isUUID("00000000-0000-0000-0000-000000000001")
assertThat(response.device).isUUID("00000000-0000-0000-0000-000000000002")
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000003")
assertThat(response.client).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(response.device).isEqualToUuid("00000000-0000-0000-0000-000000000002")
assertThat(response.name).isEqualTo("test")
}
}

View File

@@ -2,20 +2,16 @@ package ltd.hlaeja.controller
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.assertj.assertThat
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -36,22 +32,7 @@ class TypeControllerTest {
}
@Test
fun `get all types`() = runTest {
// given
every { service.getTypes() } returns flowOf(TypeEntity(id, timestamp, "name"))
// when
val response = controller.getTypes().single()
// then
verify(exactly = 1) { service.getTypes() }
assertThat(response.id).isUUID("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
}
@Test
fun `add types`() = runTest {
fun `add type`() = runTest {
// given
val request = Type.Request("name")
coEvery { service.addType(any()) } returns TypeEntity(id, timestamp, "name")
@@ -62,7 +43,7 @@ class TypeControllerTest {
// then
coVerify(exactly = 1) { service.addType(any()) }
assertThat(response.id).isUUID("00000000-0000-0000-0000-000000000000")
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
}
}

View File

@@ -0,0 +1,49 @@
package ltd.hlaeja.controller
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class TypesControllerTest {
companion object {
val id = UUID.fromString("00000000-0000-0000-0000-000000000000")
val timestamp = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC"))
}
val service: TypeService = mockk()
lateinit var controller: TypesController
@BeforeEach
fun setUp() {
controller = TypesController(service)
}
@Test
fun `get all types`() = runTest {
// given
every { service.getTypes(any(), any(), any()) } returns flowOf(TypeEntity(id, timestamp, "name"))
// when
val response = controller.getTypes().single()
// then
verify(exactly = 1) { service.getTypes(0, 25, null) }
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
}
}

View File

@@ -37,13 +37,27 @@ class TypeServiceTest {
@Test
fun `get all types`() {
// given
every { repository.findAll() } returns flowOf(mockk<TypeEntity>())
every { repository.findAll(any(), any()) } returns flowOf(mockk<TypeEntity>())
// when
service.getTypes()
service.getTypes(1, 10, null)
// then
verify(exactly = 1) { repository.findAll() }
verify(exactly = 1) { repository.findAll(1, 10) }
verify(exactly = 0) { repository.findAllContaining(any(), any(), any()) }
}
@Test
fun `get all types with filter`() {
// given
every { repository.findAllContaining(any(), any(), any()) } returns flowOf(mockk<TypeEntity>())
// when
service.getTypes(1, 10, "abc")
// then
verify(exactly = 1) { repository.findAllContaining("%abc%", 1, 10) }
verify(exactly = 0) { repository.findAll(any(), any()) }
}
@Test

View File

@@ -8,11 +8,11 @@ import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlin.test.Test
import ltd.hlaeja.assertj.assertThat
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
@@ -68,7 +68,7 @@ class MappingKtTest {
val response = entity.toTypeResponse()
// then
assertThat(response.id).isUUID("00000000-0000-0000-0000-000000000000")
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
}
@@ -119,9 +119,9 @@ class MappingKtTest {
val result = entity.toNodeResponse()
// then
assertThat(result.id).isUUID("00000000-0000-0000-0000-000000000001")
assertThat(result.client).isUUID("00000000-0000-0000-0000-000000000002")
assertThat(result.device).isUUID("00000000-0000-0000-0000-000000000003")
assertThat(result.id).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(result.client).isEqualToUuid("00000000-0000-0000-0000-000000000002")
assertThat(result.device).isEqualToUuid("00000000-0000-0000-0000-000000000003")
assertThat(result.name).isEqualTo("test")
}
@@ -164,9 +164,9 @@ class MappingKtTest {
val result = entity.toIdentityResponse()
// then
assertThat(result.node).isUUID("00000000-0000-0000-0000-000000000001")
assertThat(result.client).isUUID("00000000-0000-0000-0000-000000000002")
assertThat(result.device).isUUID("00000000-0000-0000-0000-000000000003")
assertThat(result.node).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(result.client).isEqualToUuid("00000000-0000-0000-0000-000000000002")
assertThat(result.device).isEqualToUuid("00000000-0000-0000-0000-000000000003")
}
@Test