add get and update for Type

- add UpdateType end-to-end
- add updateType to TypeController
- add updateType to TypeService
- add sql 004-create_type_description_data.sql
- update TypesEndpoint to use Types.Response
- update type end-to-end test
  - update TypeEndpoint with CreateType
  - add reset test table
  - add test data
- add getType to TypeController
- add getType to TypeService
- add findTypeWithDescription to TypeRepository
- update type end-to-end test
- update TypeController for changes for adding type
- update type mapping for latest changes in Mapping.kt
- update addType to use TypeDescriptionRepository and return TypeWithDescription in TypeService
- add TypeWithDescription
- add TypeDescriptionRepository
- add TypeDescriptionEntity
- add missing device mapping test
- add type_descriptions sql script for database changes
- update TypesEndpoint
  - update TypesController to use Types.Response
  - add TypeEntity.toTypesResponse to Mapping.kt
This commit is contained in:
2025-03-11 07:51:40 +01:00
parent 53db4408e2
commit d90a716df7
21 changed files with 947 additions and 84 deletions

View File

@@ -1,7 +1,20 @@
### add type by name
### add type
POST {{hostname}}/type
Content-Type: application/json
{
"name": "Test C"
"name": "Test Device 001",
"description": "Description of test device."
}
### get type by id
GET {{hostname}}/type-00000000-0000-0000-0000-000000000000
### update type by id
PUT {{hostname}}/type-00000000-0000-0000-0000-000000000000
Content-Type: application/json
{
"name": "Test Device 001",
"description": "Description of test device."
}

View File

@@ -0,0 +1,24 @@
-- Table: public.type_descriptions
-- DROP TABLE IF EXISTS public.type_descriptions;
CREATE TABLE IF NOT EXISTS public.type_descriptions
(
type_id uuid NOT NULL,
description character varying(1000) COLLATE pg_catalog."default" NOT NULL DEFAULT ''::character varying,
CONSTRAINT pk_type_descriptions PRIMARY KEY (type_id),
CONSTRAINT fk_type_descriptions_types FOREIGN KEY (type_id)
REFERENCES public.types (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
);
ALTER TABLE IF EXISTS public.type_descriptions
OWNER to role_administrator;
-- Revoke all permissions from existing roles
REVOKE ALL ON TABLE public.type_descriptions FROM role_administrator, role_maintainer, role_support, role_service;
-- Grant appropriate permissions
GRANT ALL ON TABLE public.type_descriptions TO role_administrator;
GRANT SELECT, INSERT, UPDATE ON TABLE public.type_descriptions TO role_maintainer, role_service;
GRANT SELECT ON TABLE public.type_descriptions TO role_support;

View File

@@ -0,0 +1,5 @@
-- make type description for existing types
INSERT INTO public.type_descriptions (type_id)
SELECT id
FROM public.types
ON CONFLICT (type_id) DO NOTHING;

View File

@@ -0,0 +1,24 @@
-- Table: public.type_descriptions
-- DROP TABLE IF EXISTS public.type_descriptions;
CREATE TABLE IF NOT EXISTS public.type_descriptions
(
type_id uuid NOT NULL,
description character varying(1000) COLLATE pg_catalog."default" NOT NULL DEFAULT ''::character varying,
CONSTRAINT pk_type_descriptions PRIMARY KEY (type_id),
CONSTRAINT fk_type_descriptions_types FOREIGN KEY (type_id)
REFERENCES public.types (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
);
ALTER TABLE IF EXISTS public.type_descriptions
OWNER to role_administrator;
-- Revoke all permissions from existing roles
REVOKE ALL ON TABLE public.type_descriptions FROM role_administrator, role_maintainer, role_support, role_service;
-- Grant appropriate permissions
GRANT ALL ON TABLE public.type_descriptions TO role_administrator;
GRANT SELECT, INSERT, UPDATE ON TABLE public.type_descriptions TO role_maintainer, role_service;
GRANT SELECT ON TABLE public.type_descriptions TO role_support;

View File

@@ -2,6 +2,7 @@ package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
@@ -9,10 +10,14 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
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.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@@ -40,9 +45,11 @@ class TypeEndpoint {
@Test
fun `added type - success`() {
// given
val name = "Thing 5"
val name = "Thing 5 v1"
val description = "Thing 5 description"
val request = Type.Request(
name = name,
description = description,
)
// when
@@ -50,11 +57,12 @@ class TypeEndpoint {
// then
result.expectStatus()
.isOk
.isCreated
.expectBody<Type.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id?.version()).isEqualTo(7)
softly.assertThat(it.responseBody?.name).isEqualTo(name)
softly.assertThat(it.responseBody?.description).isEqualTo(description)
}
}
@@ -63,6 +71,7 @@ class TypeEndpoint {
// given
val request = Type.Request(
name = "Thing 1 v1",
description = "Thing 1 description",
)
// when
@@ -71,5 +80,212 @@ class TypeEndpoint {
// then
result.expectStatus().isEqualTo(CONFLICT)
}
@ParameterizedTest
@CsvSource(
value = [
"{}",
"{'name': 'Thing 0 v1'}",
"{'description': 'Thing 0 description'}",
],
)
fun `added type - fail bad request`(jsonRequest: String) {
// when
val result = webClient.post()
.uri("/type")
.contentType(APPLICATION_JSON) // Set Content-Type header
.bodyValue(jsonRequest) // Send raw JSON string
.exchange()
// then
result.expectStatus().isBadRequest
}
}
@Nested
inner class GetType {
@Test
fun `added type - success`() {
// when
val result = webClient.get().uri("/type-00000000-0000-0000-0001-000000000001").exchange()
// then
result.expectStatus()
.isOk
.expectBody<Type.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualToUuid("00000000-0000-0000-0001-000000000001")
softly.assertThat(it.responseBody?.name).isEqualTo("Thing 1 v1")
softly.assertThat(it.responseBody?.description).isEqualTo("Thing 1 description")
}
}
@Test
fun `get type - fail not found`() {
// when
val result = webClient.get().uri("/type-00000000-0000-0000-0000-000000000000").exchange()
// then
result.expectStatus().isNotFound
}
@ParameterizedTest
@CsvSource(
value = [
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"00000000000000000000000000000000",
"0",
],
)
fun `get type - fail bad request`(uuid: String) {
// when
val result = webClient.get().uri("/type-$uuid").exchange()
// then
result.expectStatus().isBadRequest
}
}
@Nested
inner class UpdateType {
@ParameterizedTest
@CsvSource(
value = [
"Thing 4 v1,Thing 4 description update",
"Thing 4 v1 update,Thing 4 description update",
"Thing 4 v1,Thing 4 description",
],
)
fun `update type - success`(name: String, description: String) {
// given
val request = Type.Request(
name = name,
description = description,
)
// when
val result = webClient.put()
.uri("/type-00000000-0000-0000-0001-000000000004")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus()
.isOk
.expectBody<Type.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualToUuid("00000000-0000-0000-0001-000000000004")
softly.assertThat(it.responseBody?.name).isEqualTo(name)
softly.assertThat(it.responseBody?.description).isEqualTo(description)
}
}
@Test
fun `update type - success no change`() {
// given
val request = Type.Request(
name = "Thing 1 v1",
description = "Thing 1 description",
)
// when
val result = webClient.put()
.uri("/type-00000000-0000-0000-0001-000000000001")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(ACCEPTED)
}
@Test
fun `update type - fail invalid id`() {
// given
val request = Type.Request(
name = "Thing 0 v1",
description = "Thing 0 description",
)
// when
val result = webClient.put()
.uri("/type-00000000-0000-0000-0001-000000000000")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `update type - fail name take`() {
// given
val request = Type.Request(
name = "Thing 2 v1",
description = "Thing 2 description",
)
// when
val result = webClient.put()
.uri("/type-00000000-0000-0000-0001-000000000001")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isEqualTo(CONFLICT)
}
@ParameterizedTest
@CsvSource(
value = [
"{}",
"{'name': 'Thing 0 v1'}",
"{'description': 'Thing 0 description'}",
],
)
fun `update type - fail bad data request`(jsonRequest: String) {
// when
val result = webClient.put()
.uri("/type-00000000-0000-0000-0001-000000000001")
.contentType(APPLICATION_JSON)
.bodyValue(jsonRequest)
.exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"00000000000000000000000000000000",
"0",
],
)
fun `update type - fail bad id request`(uuid: String) {
// given
val request = Type.Request(
name = "Thing 0 v1",
description = "Thing 0 description",
)
// when
val result = webClient.put()
.uri("/type-$uuid")
.contentType(APPLICATION_JSON)
.bodyValue(request)
.exchange()
// then
result.expectStatus().isBadRequest
}
}
}

View File

@@ -1,6 +1,6 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
@@ -36,7 +36,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(4)
}
@@ -55,7 +55,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
@@ -84,7 +84,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
@@ -120,7 +120,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
@@ -144,7 +144,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
@@ -185,7 +185,7 @@ class TypesEndpoint {
// then
result.expectStatus().isOk()
.expectBody<List<Type.Response>>()
.expectBody<List<Types.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}

View File

@@ -5,6 +5,12 @@ VALUES ('00000000-0000-0000-0001-000000000001'::uuid, '2000-01-01 00:00:00.00000
('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.type_descriptions (type_id, description)
VALUES ('00000000-0000-0000-0001-000000000001'::uuid, 'Thing 1 description'),
('00000000-0000-0000-0001-000000000002'::uuid, 'Thing 2 description'),
('00000000-0000-0000-0001-000000000003'::uuid, 'Thing 3 description'),
('00000000-0000-0000-0001-000000000004'::uuid, 'Thing 4 description');
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),
('00000000-0000-0000-0002-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', '00000000-0000-0000-0001-000000000001'::uuid),

View File

@@ -3,6 +3,7 @@
-- Truncate tables
TRUNCATE TABLE nodes CASCADE;
TRUNCATE TABLE devices CASCADE;
TRUNCATE TABLE type_descriptions CASCADE;
TRUNCATE TABLE types CASCADE;
-- Enable triggers on the account table

View File

@@ -31,6 +31,20 @@ CREATE TABLE IF NOT EXISTS public.types
CONSTRAINT pk_contact_types PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS types_name_key
ON types (name ASC);
CREATE TABLE IF NOT EXISTS public.type_descriptions
(
type_id uuid NOT NULL,
description character varying(1000) COLLATE pg_catalog."default" NOT NULL DEFAULT ''::character varying,
CONSTRAINT pk_type_descriptions PRIMARY KEY (type_id),
CONSTRAINT fk_type_descriptions_types FOREIGN KEY (type_id)
REFERENCES public.types (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
);
-- Table: public.devices
CREATE TABLE IF NOT EXISTS public.devices

View File

@@ -1,11 +1,16 @@
package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeEntity
import ltd.hlaeja.util.toTypeResponse
import org.springframework.http.HttpStatus.CREATED
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -14,7 +19,19 @@ class TypeController(
) {
@PostMapping("/type")
@ResponseStatus(CREATED)
suspend fun addType(
@RequestBody register: Type.Request,
): Type.Response = service.addType(register.toTypeEntity()).toTypeResponse()
@RequestBody request: Type.Request,
): Type.Response = service.addType(request.name, request.description).toTypeResponse()
@GetMapping("/type-{type}")
suspend fun getType(
@PathVariable type: UUID,
): Type.Response = service.getType(type).toTypeResponse()
@PutMapping("/type-{type}")
suspend fun updateType(
@PathVariable type: UUID,
@RequestBody request: Type.Request,
): Type.Response = service.updateType(type, request.name, request.description).toTypeResponse()
}

View File

@@ -3,9 +3,9 @@ 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.library.deviceRegistry.Types
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeResponse
import ltd.hlaeja.util.toTypesResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@@ -31,6 +31,6 @@ class TypesController(
@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() }
): Flow<Types.Response> = service.getTypes((page - 1) * show, show, filter)
.map { it.toTypesResponse() }
}

View File

@@ -0,0 +1,11 @@
package ltd.hlaeja.dto
import java.time.ZonedDateTime
import java.util.UUID
data class TypeWithDescription(
val id: UUID,
val timestamp: ZonedDateTime,
val name: String,
val description: String?,
)

View File

@@ -0,0 +1,12 @@
package ltd.hlaeja.entity
import java.util.UUID
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
@Table("type_descriptions")
data class TypeDescriptionEntity(
@Id
val typeId: UUID,
val description: String,
)

View File

@@ -0,0 +1,24 @@
package ltd.hlaeja.repository
import java.util.UUID
import ltd.hlaeja.entity.TypeDescriptionEntity
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 TypeDescriptionRepository : CoroutineCrudRepository<TypeDescriptionEntity, UUID> {
@Query(
"""
INSERT INTO type_descriptions (type_id, description) VALUES (:type_id, :description)
ON CONFLICT (type_id)
DO UPDATE SET description = :description
RETURNING *
""",
)
suspend fun upsert(
@Param("type_id") typeId: UUID,
@Param("description") description: String,
): TypeDescriptionEntity
}

View File

@@ -2,8 +2,8 @@ package ltd.hlaeja.repository
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.dto.TypeWithDescription
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
@@ -24,4 +24,16 @@ interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID> {
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<TypeEntity>
@Query(
"""
SELECT t.id, t.timestamp, t.name, td.description
FROM types t
LEFT JOIN type_descriptions td ON t.id = td.type_id
WHERE t.id = :id
""",
)
suspend fun findTypeWithDescription(
@Param("id") id: UUID,
): TypeWithDescription?
}

View File

@@ -1,12 +1,21 @@
package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.repository.TypeDescriptionRepository
import ltd.hlaeja.repository.TypeRepository
import org.springframework.dao.DuplicateKeyException
import org.springframework.http.HttpStatus
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@@ -14,6 +23,7 @@ private val log = KotlinLogging.logger {}
@Service
class TypeService(
private val typeRepository: TypeRepository,
private val typeDescriptionRepository: TypeDescriptionRepository,
) {
fun getTypes(
@@ -25,13 +35,87 @@ class TypeService(
else -> typeRepository.findAll(page, show)
}
@Transactional
suspend fun addType(
entity: TypeEntity,
): TypeEntity = try {
typeRepository.save(entity)
.also { log.debug { "Added new type: $it.id" } }
} catch (e: DuplicateKeyException) {
log.warn { e.localizedMessage }
throw ResponseStatusException(HttpStatus.CONFLICT)
name: String,
description: String,
): TypeWithDescription = try {
val savedType = typeRepository.save(
TypeEntity(timestamp = ZonedDateTime.now(), name = name),
).also { log.debug { "Added new type: ${it.id}" } }
val savedDescription = typeDescriptionRepository.upsert(
savedType.id ?: throw ResponseStatusException(EXPECTATION_FAILED),
description,
).also { log.debug { "Added description for type: ${it.typeId}" } }
TypeWithDescription(
id = savedType.id,
timestamp = savedType.timestamp,
name = savedType.name,
description = savedDescription.description,
)
} catch (e: DataIntegrityViolationException) {
log.warn { "Failed to add type with name '$name': ${e.localizedMessage}" }
throw ResponseStatusException(CONFLICT, "Type with name '$name' already exists")
}
suspend fun getType(
id: UUID,
): TypeWithDescription = typeRepository.findTypeWithDescription(id)
?.also { log.debug { "Retrieved type with description: ${it.id}" } }
?: throw ResponseStatusException(NOT_FOUND, "Type with id '$id' not found")
@Transactional
suspend fun updateType(
id: UUID,
name: String,
description: String,
): TypeWithDescription {
var hasChanges = false
val updatedType = updateType(id, name) { hasChanges = true }
val updatedTypeDescription = updateTypeDescription(id, description) { hasChanges = true }
if (!hasChanges) {
throw ResponseStatusException(ACCEPTED, "No changes for type with id '$id'")
}
return TypeWithDescription(
id = updatedType.id!!,
timestamp = updatedType.timestamp,
name = updatedType.name,
description = updatedTypeDescription.description,
)
}
private suspend fun updateTypeDescription(
id: UUID,
description: String,
onChange: () -> Unit,
): TypeDescriptionEntity {
val existingDescription = typeDescriptionRepository.findById(id)
?: throw ResponseStatusException(NOT_FOUND, "Type description with id '$id' not found")
return if (existingDescription.description == description) {
existingDescription
} else {
onChange()
typeDescriptionRepository.save(existingDescription.copy(description = description))
}
}
private suspend fun updateType(
id: UUID,
name: String,
onChange: () -> Unit,
): TypeEntity {
val existingType = typeRepository.findById(id)
?: throw ResponseStatusException(NOT_FOUND, "Type with id '$id' not found")
return if (existingType.name == name) {
existingType
} else {
onChange()
try {
typeRepository.save(existingType.copy(name = name))
} catch (e: DataIntegrityViolationException) {
log.warn { "Failed to update type with name '$name': ${e.localizedMessage}" }
throw ResponseStatusException(CONFLICT, "Type with name '$name' already exists")
}
}
}
}

View File

@@ -1,22 +1,43 @@
package ltd.hlaeja.util
import java.time.ZonedDateTime
import java.util.UUID
import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.jwt.service.PrivateJwtService
import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.web.server.ResponseStatusException
fun Type.Request.toTypeEntity(): TypeEntity = TypeEntity(null, ZonedDateTime.now(), name)
fun Type.Request.toTypeEntity(id: UUID): TypeEntity = TypeEntity(
id = id,
timestamp = ZonedDateTime.now(),
name = name,
)
fun TypeEntity.toTypeResponse(): Type.Response = Type.Response(
id ?: throw ResponseStatusException(EXPECTATION_FAILED),
name,
fun Type.Request.toTypeDescriptionEntity(id: UUID): TypeDescriptionEntity = TypeDescriptionEntity(
typeId = id,
description = description,
)
fun TypeWithDescription.toTypeResponse(): Type.Response = Type.Response(
id = id,
timestamp = timestamp,
name = name,
description = description ?: "",
)
fun TypeEntity.toTypesResponse(): Types.Response = Types.Response(
id = id!!,
name = name,
timestamp = timestamp,
)
fun Node.Request.toEntity(): NodeEntity = NodeEntity(

View File

@@ -8,7 +8,7 @@ import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.test.isEqualToUuid
@@ -34,16 +34,59 @@ class TypeControllerTest {
@Test
fun `add type`() = runTest {
// given
val request = Type.Request("name")
coEvery { service.addType(any()) } returns TypeEntity(id, timestamp, "name")
val request = Type.Request("name", "description")
coEvery { service.addType(any(), any()) } returns TypeWithDescription(id, timestamp, "name", "description")
// when
val response = controller.addType(request)
// then
coVerify(exactly = 1) { service.addType(any()) }
coVerify(exactly = 1) { service.addType(any(), any()) }
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
assertThat(response.description).isEqualTo("description")
}
@Test
fun `get type`() = runTest {
// given
coEvery { service.getType(any()) } returns TypeWithDescription(id, timestamp, "name", "description")
// when
val response = controller.getType(id)
// then
coVerify(exactly = 1) { service.getType(any()) }
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
assertThat(response.description).isEqualTo("description")
}
@Test
fun `update type`() = runTest {
// given
val request = Type.Request("name", "description")
coEvery { service.updateType(any(), any(), any()) } answers { call ->
TypeWithDescription(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = call.invocation.args[1] as String,
description = call.invocation.args[2] as String,
)
}
// when
val response = controller.updateType(id, request)
// then
coVerify(exactly = 1) { service.updateType(any(), any(), any()) }
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.timestamp).isEqualTo(timestamp)
assertThat(response.name).isEqualTo("name")
assertThat(response.description).isEqualTo("description")
}
}

View File

@@ -19,8 +19,10 @@ 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"))
const val NAME: String = "name"
const val NIL_UUID: String = "00000000-0000-0000-0000-000000000000"
val id: UUID = UUID.fromString(NIL_UUID)
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC"))
}
val service: TypeService = mockk()
@@ -35,7 +37,9 @@ class TypesControllerTest {
@Test
fun `get all types`() = runTest {
// given
every { service.getTypes(any(), any(), any()) } returns flowOf(TypeEntity(id, timestamp, "name"))
every {
service.getTypes(any(), any(), any())
} returns flowOf(TypeEntity(id, timestamp, NAME))
// when
val response = controller.getTypes().single()
@@ -43,7 +47,8 @@ class TypesControllerTest {
// then
verify(exactly = 1) { service.getTypes(0, 25, null) }
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.name).isEqualTo("name")
assertThat(response.id).isEqualToUuid(NIL_UUID)
assertThat(response.name).isEqualTo(NAME)
assertThat(response.timestamp).isEqualTo(timestamp)
}
}

View File

@@ -4,90 +4,321 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlin.test.assertFailsWith
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.repository.TypeDescriptionRepository
import ltd.hlaeja.repository.TypeRepository
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.dao.DuplicateKeyException
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.web.server.ResponseStatusException
class TypeServiceTest {
companion object {
val timestamp = ZonedDateTime.ofInstant(Instant.parse("2000-01-01T00:00:00.001Z"), ZoneId.of("UTC"))
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC"))
val uuid = UUID.fromString("00000000-0000-0000-0000-000000000000")
}
val repository: TypeRepository = mockk()
val typeRepository: TypeRepository = mockk()
val typeDescriptionRepository: TypeDescriptionRepository = mockk()
lateinit var service: TypeService
@BeforeEach
fun setUp() {
service = TypeService(repository)
service = TypeService(typeRepository, typeDescriptionRepository)
mockkStatic(ZonedDateTime::class)
every { ZonedDateTime.now() } returns timestamp
}
@AfterEach
fun tearDown() {
unmockkStatic(ZonedDateTime::class)
}
@Test
fun `get all types`() {
// given
every { repository.findAll(any(), any()) } returns flowOf(mockk<TypeEntity>())
every { typeRepository.findAll(any(), any()) } returns flowOf(mockk<TypeEntity>())
// when
service.getTypes(1, 10, null)
// then
verify(exactly = 1) { repository.findAll(1, 10) }
verify(exactly = 0) { repository.findAllContaining(any(), any(), any()) }
verify(exactly = 1) { typeRepository.findAll(1, 10) }
verify(exactly = 0) { typeRepository.findAllContaining(any(), any(), any()) }
}
@Test
fun `get all types with filter`() {
// given
every { repository.findAllContaining(any(), any(), any()) } returns flowOf(mockk<TypeEntity>())
every { typeRepository.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()) }
verify(exactly = 1) { typeRepository.findAllContaining("%abc%", 1, 10) }
verify(exactly = 0) { typeRepository.findAll(any(), any()) }
}
@Test
fun `add new type success`() = runTest {
// given
val entity = TypeEntity(
null,
timestamp,
"name",
)
coEvery { repository.save(any()) } answers { call -> (call.invocation.args[0] as TypeEntity).copy(id = uuid) }
coEvery { typeRepository.save(any()) } answers { call ->
(call.invocation.args[0] as TypeEntity).copy(id = uuid)
}
coEvery { typeDescriptionRepository.upsert(any(), any()) } answers { call ->
TypeDescriptionEntity(
typeId = call.invocation.args[0] as UUID,
description = call.invocation.args[1] as String,
)
}
// when
service.addType(entity)
val result = service.addType("name", "description")
// then
coVerify(exactly = 1) { repository.save(any()) }
coVerify(exactly = 1) { typeRepository.save(any()) }
coVerify(exactly = 1) { typeDescriptionRepository.upsert(any(), any()) }
assertThat(result.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(result.timestamp).isEqualTo(timestamp)
assertThat(result.name).isEqualTo("name")
assertThat(result.description).isEqualTo("description")
}
@Test
fun `add new type exception`() = runTest {
fun `add new type - fail this should never happen save not updating id`() = runTest {
// given
val entity: TypeEntity = mockk()
coEvery { typeRepository.save(any()) } answers { call -> call.invocation.args[0] as TypeEntity }
coEvery { repository.save(any()) } throws DuplicateKeyException("duplicate key")
// then exception
assertFailsWith<ResponseStatusException> {
service.addType(entity)
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.addType("name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.save(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.upsert(any(), any()) }
assertThat(response.statusCode).isEqualTo(EXPECTATION_FAILED)
}
@Test
fun `add new type - fail duplicate key`() = runTest {
// given
coEvery { typeRepository.save(any()) } throws DuplicateKeyException("duplicate key")
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.addType("name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.save(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.upsert(any(), any()) }
assertThat(response.statusCode).isEqualTo(CONFLICT)
}
@Test
fun `get type - success`() = runTest {
// given
coEvery { typeRepository.findTypeWithDescription(any()) } answers { call ->
TypeWithDescription(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = "name",
description = "description",
)
}
// when
val result = service.getType(uuid)
// then
coVerify(exactly = 1) { typeRepository.findTypeWithDescription(any()) }
assertThat(result.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(result.timestamp).isEqualTo(timestamp)
assertThat(result.name).isEqualTo("name")
assertThat(result.description).isEqualTo("description")
}
@Test
fun `get type - fail`() = runTest {
// given
coEvery { typeRepository.findTypeWithDescription(any()) } returns null
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.getType(uuid)
}
// then
coVerify(exactly = 1) { typeRepository.findTypeWithDescription(any()) }
assertThat(response.statusCode).isEqualTo(NOT_FOUND)
}
@Test
fun `update type - success`() = runTest {
// given
coEvery { typeRepository.findById(any()) } answers { call ->
TypeEntity(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = "name",
)
}
coEvery { typeRepository.save(any()) } answers { call ->
call.invocation.args[0] as TypeEntity
}
coEvery { typeDescriptionRepository.findById(any()) } answers { call ->
TypeDescriptionEntity(
typeId = call.invocation.args[0] as UUID,
description = "description",
)
}
coEvery { typeDescriptionRepository.save(any()) } answers { call ->
call.invocation.args[0] as TypeDescriptionEntity
}
// when
val result = service.updateType(uuid, "new-name", "new-description")
// then
coVerify(exactly = 1) { typeRepository.findById(any()) }
coVerify(exactly = 1) { typeRepository.save(any()) }
coVerify(exactly = 1) { typeDescriptionRepository.findById(any()) }
coVerify(exactly = 1) { typeDescriptionRepository.save(any()) }
assertThat(result.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(result.timestamp).isEqualTo(timestamp)
assertThat(result.name).isEqualTo("new-name")
assertThat(result.description).isEqualTo("new-description")
}
@Test
fun `update type - success no change`() = runTest {
// given
coEvery { typeRepository.findById(any()) } answers { call ->
TypeEntity(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = "name",
)
}
coEvery { typeDescriptionRepository.findById(any()) } answers { call ->
TypeDescriptionEntity(
typeId = call.invocation.args[0] as UUID,
description = "description",
)
}
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.updateType(uuid, "name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.findById(any()) }
coVerify(exactly = 0) { typeRepository.save(any()) }
coVerify(exactly = 1) { typeDescriptionRepository.findById(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.save(any()) }
assertThat(response.statusCode).isEqualTo(ACCEPTED)
}
@Test
fun `update type - fail type dont exist`() = runTest {
// given
coEvery { typeRepository.findById(any()) } returns null
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.updateType(uuid, "name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.findById(any()) }
coVerify(exactly = 0) { typeRepository.save(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.findById(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.save(any()) }
assertThat(response.statusCode).isEqualTo(NOT_FOUND)
}
@Test
fun `update type - fail type description dont exist`() = runTest {
// given
coEvery { typeRepository.findById(any()) } answers { call ->
TypeEntity(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = "name",
)
}
coEvery { typeDescriptionRepository.findById(any()) } returns null
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.updateType(uuid, "name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.findById(any()) }
coVerify(exactly = 0) { typeRepository.save(any()) }
coVerify(exactly = 1) { typeDescriptionRepository.findById(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.save(any()) }
assertThat(response.statusCode).isEqualTo(NOT_FOUND)
}
@Test
fun `update type - fail name already exists`() = runTest {
// given
coEvery { typeRepository.findById(any()) } answers { call ->
TypeEntity(
id = call.invocation.args[0] as UUID,
timestamp = timestamp,
name = "name",
)
}
coEvery { typeRepository.save(any()) } throws DuplicateKeyException("duplicate key")
// when exception
val response: ResponseStatusException = assertFailsWith<ResponseStatusException> {
service.updateType(uuid, "taken-name", "description")
}
// then
coVerify(exactly = 1) { typeRepository.findById(any()) }
coVerify(exactly = 1) { typeRepository.save(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.findById(any()) }
coVerify(exactly = 0) { typeDescriptionRepository.save(any()) }
assertThat(response.statusCode).isEqualTo(CONFLICT)
}
}

View File

@@ -1,6 +1,7 @@
package ltd.hlaeja.util
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import java.time.LocalDateTime
@@ -8,8 +9,11 @@ import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlin.test.Test
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.jwt.service.PrivateJwtService
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.isEqualToUuid
@@ -40,23 +44,82 @@ class MappingKtTest {
inner class TypeMapping {
@Test
fun `request to entity successful`() {
fun `request to type entity successful`() {
// given
val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
val request = Type.Request(
"test",
"name",
"description",
)
// when
val result = request.toTypeEntity()
val entity = request.toTypeEntity(id)
// then
assertThat(result.id).isNull()
assertThat(result.timestamp.toString()).isEqualTo("2000-01-01T00:00:00.000000001Z[UTC]")
assertThat(result.name).isEqualTo("test")
assertThat(entity.id).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(entity.timestamp).isEqualTo(timestamp)
assertThat(entity.name).isEqualTo("name")
}
@Test
fun `entity to response successful`() {
fun `request to type description entity successful`() {
// given
val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
val request = Type.Request(
"name",
"description",
)
// when
val entity = request.toTypeDescriptionEntity(id)
// then
assertThat(entity.typeId).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(entity.description).isEqualTo("description")
}
@Test
fun `type with description to response successful`() {
// given
val typeWithDescription = TypeWithDescription(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
timestamp,
"name",
"description",
)
// when
val response = typeWithDescription.toTypeResponse()
// then
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(response.timestamp).isEqualTo(timestamp)
assertThat(response.name).isEqualTo("name")
assertThat(response.description).isEqualTo("description")
}
@Test
fun `type with description to response, description null successful`() {
// given
val typeWithDescription = TypeWithDescription(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
timestamp,
"name",
null,
)
// when
val response = typeWithDescription.toTypeResponse()
// then
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(response.timestamp).isEqualTo(timestamp)
assertThat(response.name).isEqualTo("name")
assertThat(response.description).isEmpty()
}
@Test
fun `type entity to response successful`() {
// given
val entity = TypeEntity(
UUID.fromString("00000000-0000-0000-0000-000000000000"),
@@ -65,20 +128,13 @@ class MappingKtTest {
)
// when
val response = entity.toTypeResponse()
val response = entity.toTypesResponse()
// then
assertThat(response.id).isEqualToUuid("00000000-0000-0000-0000-000000000000")
assertThat(response.timestamp).isEqualTo(timestamp)
assertThat(response.name).isEqualTo("name")
}
@Test
fun `entity to response exception`() {
// then exception
assertThrows(ResponseStatusException::class.java) {
TypeEntity(null, timestamp, "name").toTypeResponse()
}
}
}
@Nested
@@ -98,7 +154,7 @@ class MappingKtTest {
// then
assertThat(result.id).isNull()
assertThat(result.timestamp.toString()).isEqualTo("2000-01-01T00:00:00.000000001Z[UTC]")
assertThat(result.timestamp).isEqualTo(timestamp)
assertThat(result.client.toString()).isEqualTo("00000000-0000-0000-0000-000000000001")
assertThat(result.device.toString()).isEqualTo("00000000-0000-0000-0000-000000000002")
assertThat(result.name).isEqualTo("test")
@@ -189,4 +245,48 @@ class MappingKtTest {
assertThat(exception.message).isEqualTo("417 EXPECTATION_FAILED")
}
}
@Nested
inner class DeviceMapping {
val jwtService: PrivateJwtService = mockk()
@Test
fun `entity to identity response successful`() {
// given
val entity = DeviceEntity(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
timestamp,
UUID.fromString("00000000-0000-0000-0000-000000000002"),
)
every { jwtService.sign(any()) } returns "header.payload.signature"
// when
val result = entity.toDeviceResponse(jwtService)
// then
assertThat(result.id).isEqualToUuid("00000000-0000-0000-0000-000000000001")
assertThat(result.type).isEqualToUuid("00000000-0000-0000-0000-000000000002")
assertThat(result.identity).isEqualTo("header.payload.signature")
}
@Test
fun `entity to identity response exception`() {
// given
val entity = DeviceEntity(
null,
timestamp,
UUID.fromString("00000000-0000-0000-0000-000000000002"),
)
// then exception
val exception = assertThrows(ResponseStatusException::class.java) {
entity.toDeviceResponse(jwtService)
}
// then
assertThat(exception.message).isEqualTo("417 EXPECTATION_FAILED")
}
}
}