Skip to content

Commit

Permalink
refs #127: create endpoint to update a product
Browse files Browse the repository at this point in the history
  • Loading branch information
mauriciogeneroso committed Nov 4, 2023
1 parent 02efef5 commit 35cc984
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import org.springframework.security.web.SecurityFilterChain
@Configuration
class SecurityConfig @Autowired constructor(private val securityEntryPoint: SecurityEntryPoint) {

private val roleClient: String = UserRole.CLIENT.role
private val roleSales: String = UserRole.SALES.role

companion object {
const val PRODUCTS_PATTERN = "/v1/products"
const val PRODUCTS_PATTERN_WITH_IDS = "/v1/products/**"
}

@Bean
@Throws(Exception::class)
fun filterChain(http: HttpSecurity): SecurityFilterChain? {
Expand All @@ -26,11 +30,11 @@ class SecurityConfig @Autowired constructor(private val securityEntryPoint: Secu
http.authorizeHttpRequests { authorizeExchange ->
//@formatter:off
authorizeExchange
.requestMatchers(HttpMethod.GET, "/v1/products").permitAll()
.requestMatchers(HttpMethod.GET, "/v1/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/v1/products").hasAnyRole(roleSales)
.requestMatchers(HttpMethod.PUT, "/v1/products/**").hasAnyRole(roleSales)
.requestMatchers(HttpMethod.DELETE, "/v1/products/**").hasAnyRole(roleSales)
.requestMatchers(HttpMethod.GET, PRODUCTS_PATTERN).permitAll()
.requestMatchers(HttpMethod.GET, PRODUCTS_PATTERN_WITH_IDS).permitAll()
.requestMatchers(HttpMethod.POST, PRODUCTS_PATTERN).hasAnyRole(roleSales)
.requestMatchers(HttpMethod.PUT, PRODUCTS_PATTERN_WITH_IDS).hasAnyRole(roleSales)
.requestMatchers(HttpMethod.DELETE, PRODUCTS_PATTERN_WITH_IDS).hasAnyRole(roleSales)
.requestMatchers("/private/**").permitAll()
.anyRequest().authenticated()
//@formatter:on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.generoso.salescatalog.controller
import com.generoso.salescatalog.auth.UserInfo
import com.generoso.salescatalog.converter.ProductV1Converter
import com.generoso.salescatalog.dto.ProductV1Dto
import com.generoso.salescatalog.entity.Product
import com.generoso.salescatalog.exception.NoResourceFoundException
import com.generoso.salescatalog.service.ProductService
import io.swagger.v3.oas.annotations.Operation
Expand All @@ -27,76 +28,74 @@ import java.util.*
@RestController
@RequestMapping("/v1/products")
class ProductV1Controller @Autowired constructor(
private val service: ProductService,
private val converter: ProductV1Converter,
private val userInfo: UserInfo
private val service: ProductService, private val converter: ProductV1Converter, private val userInfo: UserInfo
) {

@Operation(description = "Retrieve a list of products")
@GetMapping
@ApiResponses(
ApiResponse(
responseCode = "200", description = "Successfully retrieved the list of products",
content = [Content(
mediaType = "application/json",
schema = Schema(implementation = Page::class)
responseCode = "200", description = "Successfully retrieved the list of products", content = [Content(
mediaType = "application/json", schema = Schema(implementation = Page::class)
)]
)
)
fun getAll(@Parameter(description = "Pageable options") pageable: Pageable): Page<ProductV1Dto> {
val products = service.findAll(pageable)
return products.map {
if (userInfo.isSalesUser())
converter.convertToDto(it)
else
converter.convertToPublicViewDto(it)
if (userInfo.isSalesUser()) converter.convertToDto(it)
else converter.convertToPublicViewDto(it)
}
}

@Operation(description = "Retrieve a product by id")
@GetMapping("/{productId}")
@ApiResponses(
value =
[
ApiResponse(
responseCode = "200", description = "Successfully found the product by id",
content = [Content(
mediaType = "application/json",
schema = Schema(implementation = ProductV1Dto::class)
)]
),
ApiResponse(
responseCode = "404", description = "Product not found"
)
]
value = [ApiResponse(
responseCode = "200", description = "Successfully found the product by id", content = [Content(
mediaType = "application/json", schema = Schema(implementation = ProductV1Dto::class)
)]
), ApiResponse(
responseCode = "404", description = "Product not found"
)]
)
fun getProductById(@Parameter(description = "Product id") @PathVariable productId: UUID): ProductV1Dto {
val product = service.findById(productId)
return if (userInfo.isSalesUser())
converter.convertToDto(product)
else
converter.convertToPublicViewDto(product)
return if (userInfo.isSalesUser()) converter.convertToDto(product)
else converter.convertToPublicViewDto(product)
}

@Operation(description = "Register a new product")
@PostMapping
@ApiResponses(
ApiResponse(
responseCode = "201", description = "Successfully saved new product",
content = [Content(
mediaType = "application/json",
schema = Schema(implementation = ProductV1Dto::class)
responseCode = "201", description = "Successfully saved new product", content = [Content(
mediaType = "application/json", schema = Schema(implementation = ProductV1Dto::class)
)]
)
)
fun createProduct(@Parameter(description = "Product dto to save") @RequestBody @Valid dto: ProductV1Dto): ResponseEntity<ProductV1Dto> {
val entity = converter.convertToEntity(dto)
val entity = Product()
converter.convertToEntity(dto, entity)
val savedEntity = service.save(entity)
return ResponseEntity.status(HttpStatus.CREATED)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON.toString())
.body(converter.convertToDto(savedEntity))
}

@Operation(description = "Update a product")
@PutMapping("/{productId}")
@ApiResponses(ApiResponse(responseCode = "200", description = "Successfully updated the product"))
fun updateProduct(
@Parameter(description = "Product id") @PathVariable productId: UUID,
@Parameter(description = "Product dto to update") @RequestBody @Valid dto: ProductV1Dto
): ResponseEntity<Unit> {
val existingProduct = service.findById(productId)
converter.convertToEntity(dto, existingProduct)
service.save(existingProduct)
return ResponseEntity.ok().build()
}

@Operation(description = "Delete new product")
@ApiResponses(ApiResponse(responseCode = "204", description = "Successfully deleted product"))
@DeleteMapping("/{productId}")
Expand All @@ -105,7 +104,7 @@ class ProductV1Controller @Autowired constructor(
val product = service.findById(productId)
service.delete(product)
} catch (_: NoResourceFoundException) {
// it doesn't matter if the product does not exist
// it doesn't matter if the product does not exist, return success
}
return ResponseEntity.noContent().build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import org.springframework.stereotype.Component
@Component
class ProductV1Converter {

fun convertToEntity(dto: ProductV1Dto): Product {
val product = Product()
product.name = dto.name
product.description = dto.description
product.price = dto.price
product.quantity = dto.quantity!!
return product
fun convertToEntity(dto: ProductV1Dto, entity: Product) {
entity.name = dto.name
entity.description = dto.description
entity.price = dto.price
entity.quantity = dto.quantity!!
}

fun convertToDto(entity: Product): ProductV1Dto {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.generoso.salescatalog.controller.security.SecurityControllerSetup
import com.generoso.salescatalog.converter.ProductV1Converter
import com.generoso.salescatalog.dto.ProductV1Dto
import com.generoso.salescatalog.entity.Product
import com.generoso.salescatalog.exception.GlobalExceptionHandler
import com.generoso.salescatalog.exception.NoResourceFoundException
import com.generoso.salescatalog.service.ProductService
import org.junit.jupiter.api.Test
Expand All @@ -21,7 +22,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.math.BigDecimal
import java.util.*

@Import(ProductV1Controller::class)
@Import(value = [ProductV1Controller::class, GlobalExceptionHandler::class])
@WebMvcTest(ProductV1Controller::class)
class ProductV1ControllerTest : SecurityControllerSetup() {

Expand Down Expand Up @@ -169,8 +170,7 @@ class ProductV1ControllerTest : SecurityControllerSetup() {
productDto.quantity = 50
val product = mock(Product::class.java)

`when`(converter.convertToEntity(anyOrNull())).thenReturn(product)
`when`(service.save(product)).thenReturn(product)
`when`(service.save(anyOrNull())).thenReturn(product)
`when`(converter.convertToDto(product)).thenReturn(productDto)

// Act & Assert
Expand All @@ -184,11 +184,95 @@ class ProductV1ControllerTest : SecurityControllerSetup() {
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
//@formatter:on

verify(converter).convertToEntity(anyOrNull())
verify(service).save(product)
verify(converter).convertToEntity(anyOrNull(), anyOrNull())
verify(service).save(anyOrNull())
verify(converter).convertToDto(product)
}

// Update product
@Test
fun `when call to update a product without logged in user, returns 401`() {
// Arrange
val productId = UUID.randomUUID()
val product = Product(productId = productId)

`when`(service.findById(product.productId!!)).thenReturn(product)

// Act & Assert
mockMvc.perform(put("/v1/products/{productId}", productId))
.andExpect(status().isUnauthorized)
}

@Test
fun `when call to update a product with client user, returns 403`() {
// Arrange
val productId = UUID.randomUUID()
val product = Product(productId = productId)

`when`(service.findById(product.productId!!)).thenReturn(product)

// Act & Assert
mockMvc.perform(
put("/v1/products/{productId}", productId)
.header("Authorization", clientUserToken())
).andExpect(status().isForbidden)
}

@Test
fun `when call to update a product and product does not exist should return 404`() {
// Arrange
val productId = UUID.randomUUID()
val productDto = ProductV1Dto().apply {
name = "name-test"
description = "test-description"
price = BigDecimal.valueOf(10)
quantity = 2
}
`when`(service.findById(productId)).thenThrow(NoResourceFoundException("not found"))

// Act & Assert
mockMvc.perform(
put("/v1/products/{productId}", productId)
.header("Authorization", salesUserToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(productDto))
).andExpect(status().isNotFound)
}

@Test
fun `when call to update product should update it`() {
// Arrange
val productDto = ProductV1Dto().apply {
name = "New Name"
description = "Description"
price = BigDecimal.valueOf(25)
quantity = 50
}

val productId = UUID.randomUUID()
val existingProduct = Product(
productId = productId,
name = "Old Name",
description = "Description",
price = BigDecimal.valueOf(25),
quantity = 50
)

`when`(service.findById(productId)).thenReturn(existingProduct)

// Act & Assert
mockMvc.perform(
put("/v1/products/{productId}", productId)
.header("Authorization", salesUserToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(productDto))
).andExpect(status().isOk)

verify(service).findById(productId)
verify(converter).convertToEntity(anyOrNull(), anyOrNull())
verify(service).save(existingProduct)
}

// Delete product
@Test
fun `when call to delete a product without logged in user, returns 401`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ import java.time.temporal.ChronoUnit
import java.util.*

@ExtendWith(SpringExtension::class)
@ContextConfiguration(classes = [SecurityConfig::class, SecurityEntryPoint::class, UserInfo::class])
@ContextConfiguration(
classes = [
SecurityConfig::class,
SecurityEntryPoint::class,
UserInfo::class
]
)
@AutoConfigureWireMock(port = 0)
@ActiveProfiles("unit-tests")
open class SecurityControllerSetup {
Expand Down Expand Up @@ -62,9 +68,7 @@ open class SecurityControllerSetup {

fun salesUserToken(): String = format("Bearer %s", generateJWT("sales", listOf(UserRole.SALES.role)))

fun generateJWT(roles: List<String>): String = generateJWT("default name", roles)

fun generateJWT(name: String, roles: List<String>): String {
private fun generateJWT(name: String, roles: List<String>): String {
// Create the Claims, which will be the content of the JWT
val claims = JwtClaims()
claims.jwtId = UUID.randomUUID().toString() // a unique identifier for the token
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ class ProductV1ConverterTest {
dto.reserved = true
dto.sold = true

val entity = Product()

// Act
val entity = converter.convertToEntity(dto)
converter.convertToEntity(dto, entity)

// Assert
assertEquals(dto.name, entity.name)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.generoso.ft.salescatalog.client

import com.generoso.ft.salescatalog.client.model.Endpoint
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

@Component
@Qualifier("service-request")
class ProductUpdateTemplate @Autowired constructor(
@Value("\${service.host}") host: String?,
@Value("\${service.context-path:}") contextPath: String?
) : RequestTemplate(host!!, contextPath!!) {

override val endpoint: Endpoint
get() = Endpoint.PRODUCT_UPDATE
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ enum class Endpoint(val path: String, val method: String) {
PRIVATE_SWAGGER_UI("/sales-catalog/private/swagger-ui/index.html", "GET"),
PRODUCT_POST("/sales-catalog/v1/products", "POST"),
PRODUCT_GET("/sales-catalog/v1/products", "GET"),
PRODUCT_UPDATE("/sales-catalog/v1/products", "PUT"),
PRODUCT_DELETE("/sales-catalog/v1/products", "DELETE")
}
Loading

0 comments on commit 35cc984

Please sign in to comment.