diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt new file mode 100644 index 00000000..11e50363 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt @@ -0,0 +1,131 @@ +package com.sprintstart.sprintstartbackend.user.controller + +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.service.DashboardLayoutService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +/** + * How the signed-in user has arranged their dashboard. + * + * Self-serve only: the user is always resolved from the token and no endpoint takes a user id, so + * there is no "read somebody else's layout" to get the authorisation wrong on. + * + * The per-board arrangement (areas, folds, sizes, pins on `/board`) is a separate document per + * board — see `BoardStructureController`. This one is the user-level half: one dashboard per person. + */ +@RestController +@RequestMapping("/api/v1/users") +@Tag( + name = "Current User - Dashboard layout", + description = "Which widgets you placed on your dashboard, in which order and at which size", +) +class DashboardLayoutController( + private val dashboardLayoutService: DashboardLayoutService, +) { + /** + * Returns the caller's dashboard arrangement for the given client layout version. + * + * No arrangement, or one written under another version, answers 200 with no items and a null + * `updatedAt`, which tells the client to show its default. + * + * @param jwt The authenticated JWT containing the caller subject. + * @param version The layout version the client understands. + * @return The stored arrangement, or the empty one. + */ + @Operation( + summary = "How I have arranged my dashboard", + description = "Returns the widgets placed on the caller's dashboard, in order, with their sizes.\n\n" + + "`version` is the layout version the client understands. A layout stored under any " + + "other version, or no layout at all, answers with no items and a null `updatedAt` " + + "rather than a 404: it means \"show the default\".", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Arrangement returned, possibly empty"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @GetMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun getMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + @RequestParam version: Int, + ): DashboardLayoutResponse = dashboardLayoutService.read(jwt.subject, version) + + /** + * Replaces the caller's dashboard arrangement. + * + * @param jwt The authenticated JWT containing the caller subject. + * @param request The whole arrangement and the version it was written under. + * @return The stored arrangement with its new `updatedAt`. + */ + @Operation( + summary = "Arrange my dashboard", + description = "Replaces the whole arrangement. Send all of it rather than the part that " + + "changed. Widget ids and sizes are the client's vocabulary and are stored as given; " + + "the client drops what it no longer knows when it reads them back.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Arrangement stored"), + ApiResponse(responseCode = "400", description = "Invalid arrangement"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @PutMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun saveMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + @RequestBody @Valid request: SaveDashboardLayoutRequest, + ): DashboardLayoutResponse = dashboardLayoutService.write(jwt.subject, request) + + /** + * Forgets the caller's dashboard arrangement, so the next read answers with the default. + * + * @param jwt The authenticated JWT containing the caller subject. + */ + @Operation( + summary = "Reset my dashboard", + description = "Removes the stored arrangement. Succeeds whether or not one was stored.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Arrangement removed"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun resetMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + ) { + dashboardLayoutService.clear(jwt.subject) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt new file mode 100644 index 00000000..353d9985 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt @@ -0,0 +1,45 @@ +package com.sprintstart.sprintstartbackend.user.model.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant +import java.util.UUID + +/** + * How one user has arranged their dashboard. + * + * One row per user, created on first write: a user who has not arranged anything gets the default + * dashboard, and an empty row saying so would tell nobody anything. + * + * Keyed by user and not by project, because the dashboard is one per person — a hire on two + * projects has two board arrangements (see `board_structures`) and one dashboard. The widgets + * themselves decide per project what they show. + * + * The arrangement is JSON in [payload], read and written whole, the same bargain the board's + * arrangement makes. [version] is the client's layout version it was written under; see + * `DashboardLayoutService` for what happens when it no longer matches. + */ +@Entity +@Table( + name = "dashboard_layouts", + uniqueConstraints = [ + UniqueConstraint(name = "uq_dashboard_layouts_user", columnNames = ["user_id"]), + ], +) +class DashboardLayout( + @Id + val id: UUID = UUID.randomUUID(), + @Column(name = "user_id", nullable = false) + val userId: UUID, + @Column(nullable = false) + var version: Int, + @Column(columnDefinition = "TEXT", nullable = false) + var payload: String, + @Column(name = "created_at", nullable = false) + val createdAt: Instant = Instant.now(), + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now(), +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt new file mode 100644 index 00000000..1bc0fd44 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt @@ -0,0 +1,36 @@ +package com.sprintstart.sprintstartbackend.user.model.request.dashboard + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import kotlinx.serialization.Serializable + +/** + * How much of a dashboard arrangement the server will take. + * + * A dashboard holds around a dozen widgets; the numbers are far above anything a person arranges + * by hand and low enough that one client cannot decide how large a stored row gets. + */ +object DashboardLayoutLimits { + /** Placed widgets. The catalog has about a dozen. */ + const val ITEMS = 100 + + /** A widget id or size name: short strings the client defines. */ + const val SHORT_TEXT = 100 +} + +/** + * One placed widget: which one, and how large. Order in the list is the reading order on screen. + * + * Both fields are strings the client owns. The catalog of widgets and the size vocabulary live in + * the frontend and change there; checking them here would mean every new widget needs a backend + * release. The client already drops ids and sizes it does not know when it reads a layout back. + */ +@Serializable +data class DashboardLayoutItemPayload( + @field:NotBlank + @field:Size(max = DashboardLayoutLimits.SHORT_TEXT) + val id: String, + @field:NotBlank + @field:Size(max = DashboardLayoutLimits.SHORT_TEXT) + val size: String, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt new file mode 100644 index 00000000..9481dfc3 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt @@ -0,0 +1,20 @@ +package com.sprintstart.sprintstartbackend.user.model.request.dashboard + +import jakarta.validation.Valid +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size + +/** + * The whole dashboard arrangement, replacing whatever was stored. + * + * Sent whole rather than as a patch: the arrangement is small and the client holds all of it. + * [version] is the client's layout version (`LAYOUT_VERSION` in the frontend) the items were + * written under. + */ +data class SaveDashboardLayoutRequest( + @field:Positive + val version: Int, + @field:Size(max = DashboardLayoutLimits.ITEMS) + @field:Valid + val items: List, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt new file mode 100644 index 00000000..933d39e5 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt @@ -0,0 +1,17 @@ +package com.sprintstart.sprintstartbackend.user.model.response.dashboard + +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload +import java.time.Instant + +/** + * How this user has arranged their dashboard. + * + * [updatedAt] is null when there is no arrangement to use — none stored, or one stored under a + * different version — which means "show the default". An empty [items] with a non-null + * [updatedAt] is a different fact: somebody who took every widget off on purpose. + */ +data class DashboardLayoutResponse( + val version: Int, + val items: List, + val updatedAt: Instant?, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt new file mode 100644 index 00000000..476ef630 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt @@ -0,0 +1,41 @@ +package com.sprintstart.sprintstartbackend.user.repository + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant +import java.util.UUID + +interface DashboardLayoutRepository : JpaRepository { + fun findByUserId(userId: UUID): DashboardLayout? + + @Modifying + fun deleteByUserId(userId: UUID) + + /** + * Writes a user's dashboard arrangement, whether or not they already have one. + * + * One statement rather than "look, then insert or update", for the same reason as the board's + * arrangement: two tabs saving a first arrangement must resolve as last-write-wins, not as a + * constraint violation. [id] is only used when this inserts. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + value = """ + INSERT INTO dashboard_layouts (id, user_id, version, payload, created_at, updated_at) + VALUES (:id, :userId, :version, :payload, :now, :now) + ON CONFLICT (user_id) DO UPDATE + SET version = EXCLUDED.version, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at + """, + nativeQuery = true, + ) + fun upsert( + @Param("id") id: UUID, + @Param("userId") userId: UUID, + @Param("version") version: Int, + @Param("payload") payload: String, + @Param("now") now: Instant, + ) +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt new file mode 100644 index 00000000..15354702 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt @@ -0,0 +1,117 @@ +package com.sprintstart.sprintstartbackend.user.service + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository +import com.sprintstart.sprintstartbackend.user.repository.UserRepository +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.UUID + +/** + * Reads, writes and forgets how a user has arranged their dashboard. + * + * Every method takes the caller's auth id and nothing else that names a user, so there is no way + * to ask for somebody else's arrangement — a layout is a personal preference, and whether a PM + * should ever see a hire's is a product question this deliberately does not answer. + * + * **Versioned like the client does it.** The client stamps a layout with its `LAYOUT_VERSION` and + * throws a layout of another version away rather than half-migrating it. The server does the + * same: a read names the version the client understands, and a stored layout of any other version + * answers exactly as "nothing stored" does, so the client falls back to its default. The version is + * the client's rather than a constant here because only the client knows what its widgets and + * sizes mean — a server-side number would have to be bumped in lockstep with every frontend change + * to the vocabulary. + */ +@Service +class DashboardLayoutService( + private val dashboardLayoutRepository: DashboardLayoutRepository, + private val userRepository: UserRepository, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * The caller's arrangement, or an empty one with a null `updatedAt` when there is none to use. + * + * "None to use" covers no row, a row written under a different [version], and a row that can + * no longer be parsed. All three mean the same thing to the client — show the default — and a + * 404 for the most ordinary state of a new user would make the client treat real errors the + * same way. + */ + @Transactional(readOnly = true) + fun read(authId: String, version: Int): DashboardLayoutResponse { + val userId = resolveUserId(authId) + val stored = dashboardLayoutRepository.findByUserId(userId) + ?: return defaultLayout(version) + + if (stored.version != version) return defaultLayout(version) + + val items = decode(stored) ?: return defaultLayout(version) + + return DashboardLayoutResponse(stored.version, items, stored.updatedAt) + } + + /** + * Replaces the caller's arrangement. + * + * Last write wins: two tabs are the realistic conflict, and an arrangement is a statement about + * the whole dashboard that cannot be merged with another one. + */ + @Transactional + fun write(authId: String, request: SaveDashboardLayoutRequest): DashboardLayoutResponse { + val userId = resolveUserId(authId) + val now = Instant.now() + + dashboardLayoutRepository.upsert( + id = UUID.randomUUID(), + userId = userId, + version = request.version, + payload = json.encodeToString(itemsSerializer, request.items), + now = now, + ) + + return DashboardLayoutResponse(request.version, request.items, now) + } + + /** Forgets the caller's arrangement, so the next read answers with the default. */ + @Transactional + fun clear(authId: String) { + dashboardLayoutRepository.deleteByUserId(resolveUserId(authId)) + } + + private fun decode(stored: DashboardLayout): List? = + try { + json.decodeFromString(itemsSerializer, stored.payload) + } catch (e: SerializationException) { + logger.warn( + "Dashboard layout {} of user {} could not be read and was answered as the default; " + + "the next write replaces it", + stored.id, + stored.userId, + e, + ) + null + } + + private fun resolveUserId(authId: String): UUID = + userRepository.findIdByAuthId(authId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "User with authId: $authId not found") + } + + private fun defaultLayout(version: Int): DashboardLayoutResponse = + DashboardLayoutResponse(version, emptyList(), null) + + private companion object { + val json = Json { ignoreUnknownKeys = true } + val itemsSerializer = ListSerializer(DashboardLayoutItemPayload.serializer()) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt index 0caf702e..4abaf9eb 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt @@ -14,6 +14,7 @@ import com.sprintstart.sprintstartbackend.user.model.request.user.UpdateUserEnab import com.sprintstart.sprintstartbackend.user.model.response.project.MyProjectResponse import com.sprintstart.sprintstartbackend.user.model.response.user.DeleteUserResponse import com.sprintstart.sprintstartbackend.user.model.response.user.GetUserResponse +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository import org.springframework.context.ApplicationEventPublisher @@ -38,6 +39,7 @@ class UserService( private val keycloakAdminClient: KeycloakAdminClient, private val githubLoginService: GithubLoginService, private val jiraDisplayNameService: JiraDisplayNameService, + private val dashboardLayoutRepository: DashboardLayoutRepository, ) { /** * Returns all persisted users. @@ -224,6 +226,7 @@ class UserService( keycloakAdminClient.deleteUser(authId) projectRepository.clearManagerForUser(id) + dashboardLayoutRepository.deleteByUserId(id) userRepository.deleteRolesByUserId(id) userRepository.deleteProjectionById(id) // What other modules recorded about this person is theirs to erase; only they know what diff --git a/src/main/resources/db/migration/V21__add_dashboard_layouts.sql b/src/main/resources/db/migration/V21__add_dashboard_layouts.sql new file mode 100644 index 00000000..08da259c --- /dev/null +++ b/src/main/resources/db/migration/V21__add_dashboard_layouts.sql @@ -0,0 +1,27 @@ +-- Records dashboard_layouts, the one row per user that holds how they arranged their dashboard: +-- which widgets are placed, in which order, at which size. The arrangement is JSON in a single +-- TEXT column because it is read and written whole and nothing ever queries inside it; `version` +-- is the client's layout version it was written under. +-- +-- As with the rest of the schema, Hibernate's ddl-auto already emits this table; this migration is +-- idempotent so it stays a no-op against such a schema and exists so the change is recorded in the +-- migration history. +-- +-- The unique constraint on user_id backs the upsert (ON CONFLICT (user_id) DO UPDATE), which makes +-- two tabs saving a first arrangement resolve as last-write-wins. + +CREATE TABLE IF NOT EXISTS dashboard_layouts ( + id UUID NOT NULL, + user_id UUID NOT NULL, + version INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE IF EXISTS dashboard_layouts + DROP CONSTRAINT IF EXISTS uq_dashboard_layouts_user; + +ALTER TABLE IF EXISTS dashboard_layouts + ADD CONSTRAINT uq_dashboard_layouts_user UNIQUE (user_id); diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt index 5d7c2de7..b2ae2db0 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt @@ -9,6 +9,7 @@ import com.sprintstart.sprintstartbackend.user.model.entity.User import com.sprintstart.sprintstartbackend.user.model.request.user.PatchMeRequest import com.sprintstart.sprintstartbackend.user.model.request.user.PatchUserRequest import com.sprintstart.sprintstartbackend.user.model.request.user.UpdateUserEnabledRequest +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository import com.sprintstart.sprintstartbackend.user.service.GithubLoginService @@ -37,6 +38,7 @@ class UserServiceTest { private val projectRepository: ProjectRepository = mockk() private val eventPublisher: ApplicationEventPublisher = mockk() private val keycloakAdminClient: KeycloakAdminClient = mockk() + private val dashboardLayoutRepository: DashboardLayoutRepository = mockk(relaxed = true) private val githubLoginService = GithubLoginService(userRepository) private val jiraDisplayNameService = JiraDisplayNameService(userRepository) private val userService = UserService( @@ -46,6 +48,7 @@ class UserServiceTest { keycloakAdminClient, githubLoginService, jiraDisplayNameService, + dashboardLayoutRepository, ) @Test @@ -202,6 +205,7 @@ class UserServiceTest { verify(exactly = 1) { keycloakAdminClient.deleteUser("auth-1") } verify(exactly = 1) { userRepository.deleteRolesByUserId(user.id) } verify(exactly = 1) { userRepository.deleteProjectionById(user.id) } + verify(exactly = 1) { dashboardLayoutRepository.deleteByUserId(user.id) } // Other modules hold data about this person that only they can reach. verify(exactly = 1) { eventPublisher.publishEvent(UserDeletedEvent(user.id)) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt new file mode 100644 index 00000000..ac2f5538 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt @@ -0,0 +1,157 @@ +package com.sprintstart.sprintstartbackend.user.controller + +import com.ninjasquad.springmockk.MockkBean +import com.sprintstart.sprintstartbackend.config.SecurityConfig +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.service.DashboardLayoutService +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Instant +import kotlin.test.assertEquals + +@WebMvcTest(DashboardLayoutController::class) +@Import(SecurityConfig::class) +@AutoConfigureMockMvc +class DashboardLayoutControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @MockkBean + private lateinit var dashboardLayoutService: DashboardLayoutService + + @MockkBean + private lateinit var jwtDecoder: JwtDecoder + + private val path = "/api/v1/users/me/dashboard/layout" + private val authId = "auth-alice" + + private fun jwtWithSubject(subject: String, vararg roles: String): JwtRequestPostProcessor = + jwt() + .jwt { jwt -> + jwt.subject(subject) + jwt.claim("realm_access", mapOf("roles" to roles.toList())) + }.authorities(roles.map { SimpleGrantedAuthority("ROLE_$it") }) + + private val userJwt = jwtWithSubject(authId, "USER") + + @Test + fun `reads the caller's layout, resolved from the token`() { + every { dashboardLayoutService.read(authId, 2) } returns DashboardLayoutResponse( + version = 2, + items = listOf(DashboardLayoutItemPayload("greeting", "wide")), + updatedAt = Instant.parse("2026-09-30T10:00:00Z"), + ) + + mockMvc + .perform(get(path).param("version", "2").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.version").value(2)) + .andExpect(jsonPath("$.items[0].id").value("greeting")) + .andExpect(jsonPath("$.items[0].size").value("wide")) + .andExpect(jsonPath("$.updatedAt").exists()) + + verify(exactly = 1) { dashboardLayoutService.read(authId, 2) } + } + + @Test + fun `no stored layout is a 200 with no items, not a 404`() { + every { dashboardLayoutService.read(authId, 2) } returns DashboardLayoutResponse(2, emptyList(), null) + + mockMvc + .perform(get(path).param("version", "2").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isEmpty) + .andExpect(jsonPath("$.updatedAt").doesNotExist()) + } + + @Test + fun `a PM without the user role can arrange their own dashboard too`() { + every { dashboardLayoutService.read("auth-pm", 2) } returns DashboardLayoutResponse(2, emptyList(), null) + + mockMvc + .perform(get(path).param("version", "2").with(jwtWithSubject("auth-pm", "PM"))) + .andExpect(status().isOk) + } + + @Test + fun `writes the whole arrangement for the caller`() { + val twoWidgets = """{"version":2,"items":[{"id":"skills","size":"small"},""" + + """{"id":"greeting","size":"wide"}]}""" + val request = slot() + every { dashboardLayoutService.write(authId, capture(request)) } answers { + DashboardLayoutResponse(request.captured.version, request.captured.items, Instant.now()) + } + + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(twoWidgets) + .with(userJwt), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.items[1].id").value("greeting")) + + assertEquals(2, request.captured.version) + assertEquals(listOf("skills", "greeting"), request.captured.items.map { it.id }) + } + + @Test + fun `an item without an id is refused`() { + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"version":2,"items":[{"id":"","size":"small"}]}""") + .with(userJwt), + ).andExpect(status().isBadRequest) + } + + @Test + fun `a layout without a version is refused`() { + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"version":0,"items":[]}""") + .with(userJwt), + ).andExpect(status().isBadRequest) + } + + @Test + fun `reset forgets the caller's layout`() { + every { dashboardLayoutService.clear(authId) } just runs + + mockMvc + .perform(delete(path).with(userJwt)) + .andExpect(status().isNoContent) + + verify(exactly = 1) { dashboardLayoutService.clear(authId) } + } + + @Test + fun `nobody can read a layout without signing in`() { + mockMvc + .perform(get(path).param("version", "2")) + .andExpect(status().isUnauthorized) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt new file mode 100644 index 00000000..63682074 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt @@ -0,0 +1,156 @@ +package com.sprintstart.sprintstartbackend.user.service + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository +import com.sprintstart.sprintstartbackend.user.repository.UserRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class DashboardLayoutServiceTest { + private val dashboardLayoutRepository: DashboardLayoutRepository = mockk(relaxed = true) + private val userRepository: UserRepository = mockk() + + private lateinit var service: DashboardLayoutService + + private val authId = "auth-alice" + private val userId: UUID = UUID.randomUUID() + private val otherAuthId = "auth-bob" + private val otherUserId: UUID = UUID.randomUUID() + + private val items = listOf( + DashboardLayoutItemPayload(id = "greeting", size = "wide"), + DashboardLayoutItemPayload(id = "onboarding", size = "medium"), + DashboardLayoutItemPayload(id = "skills", size = "small"), + ) + + @BeforeEach + fun setUp() { + service = DashboardLayoutService(dashboardLayoutRepository, userRepository) + every { userRepository.findIdByAuthId(authId) } returns Optional.of(userId) + every { userRepository.findIdByAuthId(otherAuthId) } returns Optional.of(otherUserId) + } + + @Test + fun `a user who never arranged anything gets the default rather than an error`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns null + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + assertEquals(2, read.version) + } + + @Test + fun `an arrangement survives the round trip in order and with its sizes`() { + val payload = slot() + every { + dashboardLayoutRepository.upsert(any(), userId, 2, capture(payload), any()) + } returns Unit + + val written = service.write(authId, SaveDashboardLayoutRequest(version = 2, items = items)) + assertNotNull(written.updatedAt) + + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, payload.captured) + + val read = service.read(authId, 2) + + assertEquals(items, read.items) + assertEquals(2, read.version) + assertNotNull(read.updatedAt) + } + + @Test + fun `an arrangement emptied on purpose reads back as empty but not as the default`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, "[]") + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNotNull(read.updatedAt) + } + + @Test + fun `a layout stored under another version falls back to the default`() { + val payload = """[{"id":"greeting","size":"large"}]""" + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 1, payload) + + val read = service.read(authId, 2) + + // Discarded rather than half-migrated, the same trade the client makes. + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + assertEquals(2, read.version) + } + + @Test + fun `a layout that can no longer be parsed answers as the default instead of failing`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, "not json") + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + } + + @Test + fun `a user only ever reads their own layout, never somebody else's`() { + every { dashboardLayoutRepository.findByUserId(otherUserId) } returns + stored(otherUserId, 2, """[{"id":"team-overview","size":"wide"}]""") + every { dashboardLayoutRepository.findByUserId(userId) } returns null + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + verify(exactly = 1) { dashboardLayoutRepository.findByUserId(userId) } + verify(exactly = 0) { dashboardLayoutRepository.findByUserId(otherUserId) } + } + + @Test + fun `a write lands on the caller's row and nobody else's`() { + service.write(authId, SaveDashboardLayoutRequest(version = 2, items = items)) + + verify(exactly = 1) { dashboardLayoutRepository.upsert(any(), userId, 2, any(), any()) } + verify(exactly = 0) { dashboardLayoutRepository.upsert(any(), otherUserId, any(), any(), any()) } + } + + @Test + fun `a reset forgets only the caller's layout`() { + service.clear(authId) + + verify(exactly = 1) { dashboardLayoutRepository.deleteByUserId(userId) } + verify(exactly = 0) { dashboardLayoutRepository.deleteByUserId(otherUserId) } + } + + @Test + fun `an unknown caller is a 404`() { + every { userRepository.findIdByAuthId("nobody") } returns Optional.empty() + + val error = assertThrows { service.read("nobody", 2) } + + assertEquals(HttpStatus.NOT_FOUND, error.statusCode) + } + + private fun stored(owner: UUID, version: Int, payload: String) = DashboardLayout( + userId = owner, + version = version, + payload = payload, + updatedAt = Instant.parse("2026-09-30T10:00:00Z"), + ) +}