diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/Team.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/Team.kt index 3f1cc99d75..8e942255e2 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/Team.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/Team.kt @@ -16,7 +16,11 @@ package com.ritense.valtimo.contract.authentication +import java.util.UUID + interface Team { val key: String val title: String + val adHocCaseDocumentId: UUID? get() = null + val adHoc: Boolean get() = adHocCaseDocumentId != null } \ No newline at end of file diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/TeamManagementService.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/TeamManagementService.kt index 9f78693335..b54c1457ce 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/TeamManagementService.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/authentication/TeamManagementService.kt @@ -18,6 +18,7 @@ package com.ritense.valtimo.contract.authentication import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import java.util.UUID interface TeamManagementService { @@ -44,4 +45,20 @@ interface TeamManagementService { fun addUserToTeam(username: String, teamKey: String): String fun removeUserFromTeam(username: String, teamKey: String) + + fun createAdHocTeam(adHocCaseDocumentId: UUID, title: String? = null): Team { + throw UnsupportedOperationException("Ad hoc teams are not supported by this implementation") + } + + fun findAllByAdHocCaseDocumentId( + adHocCaseDocumentId: UUID, + titleContains: String? = null, + pageable: Pageable = Pageable.unpaged() + ): Page { + throw UnsupportedOperationException("Ad hoc teams are not supported by this implementation") + } + + fun deleteAllByAdHocCaseDocumentId(adHocCaseDocumentId: UUID) { + throw UnsupportedOperationException("Ad hoc teams are not supported by this implementation") + } } \ No newline at end of file diff --git a/backend/core/src/main/resources/config/liquibase/13-27-0/13-27-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-27-0/13-27-0-master.xml index d868d8fc1a..989a7258b0 100644 --- a/backend/core/src/main/resources/config/liquibase/13-27-0/13-27-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-27-0/13-27-0-master.xml @@ -21,5 +21,6 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd"> + diff --git a/backend/core/src/main/resources/config/liquibase/13-27-0/20260430-add-ad-hoc-case-document-id-to-team.xml b/backend/core/src/main/resources/config/liquibase/13-27-0/20260430-add-ad-hoc-case-document-id-to-team.xml new file mode 100644 index 0000000000..5f0817c1b7 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-27-0/20260430-add-ad-hoc-case-document-id-to-team.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/backend/team/src/main/kotlin/com/ritense/team/autoconfigure/TeamAutoConfiguration.kt b/backend/team/src/main/kotlin/com/ritense/team/autoconfigure/TeamAutoConfiguration.kt index ed7d01f3b2..448693e9ab 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/autoconfigure/TeamAutoConfiguration.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/autoconfigure/TeamAutoConfiguration.kt @@ -21,11 +21,13 @@ import com.ritense.authorization.AuthorizationService import com.ritense.team.authorization.TeamSpecificationFactory import com.ritense.team.exporter.TeamExporter import com.ritense.team.importer.TeamImporter +import com.ritense.team.listener.DocumentDeletedTeamCleanupListener import com.ritense.team.repository.TeamRepository import com.ritense.team.repository.TeamUserRepository import com.ritense.team.security.config.TeamHttpSecurityConfigurer import com.ritense.team.service.TeamActionProvider import com.ritense.team.service.TeamManagementServiceImpl +import com.ritense.team.web.rest.AdHocTeamResource import com.ritense.team.web.rest.TeamResource import com.ritense.valtimo.contract.authentication.TeamManagementService import com.ritense.valtimo.contract.authentication.UserManagementService @@ -103,6 +105,22 @@ class TeamAutoConfiguration { return TeamImporter(objectMapper, teamManagementService) } + @Bean + @ConditionalOnMissingBean(AdHocTeamResource::class) + fun adHocTeamResource( + teamManagementService: TeamManagementService, + ): AdHocTeamResource { + return AdHocTeamResource(teamManagementService) + } + + @Bean + @ConditionalOnMissingBean(DocumentDeletedTeamCleanupListener::class) + fun documentDeletedTeamCleanupListener( + teamManagementService: TeamManagementService, + ): DocumentDeletedTeamCleanupListener { + return DocumentDeletedTeamCleanupListener(teamManagementService) + } + @Bean @ConditionalOnMissingBean(TeamSpecificationFactory::class) fun teamSpecificationFactory( diff --git a/backend/team/src/main/kotlin/com/ritense/team/domain/Team.kt b/backend/team/src/main/kotlin/com/ritense/team/domain/Team.kt index 4852ed297f..b28eb5b43a 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/domain/Team.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/domain/Team.kt @@ -24,6 +24,7 @@ import jakarta.persistence.FetchType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.Table +import java.util.UUID import com.ritense.valtimo.contract.authentication.Team as TeamInterface @Entity @@ -39,5 +40,8 @@ data class Team( @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = "team_user", joinColumns = [JoinColumn(name = "team_key")]) @Column(name = "username") - var users: List = emptyList() + var users: List = emptyList(), + + @Column(name = "ad_hoc_case_document_id") + override val adHocCaseDocumentId: UUID? = null ) : TeamInterface diff --git a/backend/team/src/main/kotlin/com/ritense/team/listener/DocumentDeletedTeamCleanupListener.kt b/backend/team/src/main/kotlin/com/ritense/team/listener/DocumentDeletedTeamCleanupListener.kt new file mode 100644 index 0000000000..1950304657 --- /dev/null +++ b/backend/team/src/main/kotlin/com/ritense/team/listener/DocumentDeletedTeamCleanupListener.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.listener + +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.authentication.TeamManagementService +import com.ritense.valtimo.contract.event.DocumentDeletedEvent +import org.springframework.context.event.EventListener + +@SkipComponentScan +class DocumentDeletedTeamCleanupListener( + private val teamManagementService: TeamManagementService, +) { + + @EventListener + fun handle(event: DocumentDeletedEvent) { + teamManagementService.deleteAllByAdHocCaseDocumentId(event.caseDocumentId) + } +} diff --git a/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepository.kt b/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepository.kt index 292d28690c..d16862b4ad 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepository.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepository.kt @@ -21,10 +21,13 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query import java.util.Optional +import java.util.UUID interface TeamRepository : JpaRepository, JpaSpecificationExecutor { fun findByTitleContainingIgnoreCase(title: String): List @Query("SELECT t FROM Team t LEFT JOIN FETCH t.users WHERE t.key = :key") fun findByKeyWithUsers(key: String): Optional + + fun deleteByAdHocCaseDocumentId(adHocCaseDocumentId: UUID) } diff --git a/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepositoryConfigSpecificationHelper.kt b/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepositoryConfigSpecificationHelper.kt index f3f92810f4..a89f2e1737 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepositoryConfigSpecificationHelper.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/repository/TeamRepositoryConfigSpecificationHelper.kt @@ -19,12 +19,14 @@ package com.ritense.team.repository import com.ritense.team.domain.Team import jakarta.persistence.criteria.JoinType import org.springframework.data.jpa.domain.Specification +import java.util.UUID class TeamRepositoryConfigSpecificationHelper { companion object { const val TITLE: String = "title" + const val AD_HOC_CASE_DOCUMENT_ID: String = "adHocCaseDocumentId" @JvmStatic fun byTitleContains(titlePart: String) = Specification { root, _, cb -> @@ -38,5 +40,15 @@ class TeamRepositoryConfigSpecificationHelper { } null } + + @JvmStatic + fun byAdHocCaseDocumentIdIsNull() = Specification { root, _, cb -> + cb.isNull(root.get(AD_HOC_CASE_DOCUMENT_ID)) + } + + @JvmStatic + fun byAdHocCaseDocumentId(adHocCaseDocumentId: UUID) = Specification { root, _, cb -> + cb.equal(root.get(AD_HOC_CASE_DOCUMENT_ID), adHocCaseDocumentId) + } } } diff --git a/backend/team/src/main/kotlin/com/ritense/team/security/config/TeamHttpSecurityConfigurer.kt b/backend/team/src/main/kotlin/com/ritense/team/security/config/TeamHttpSecurityConfigurer.kt index 046ed6d949..11383b5d99 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/security/config/TeamHttpSecurityConfigurer.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/security/config/TeamHttpSecurityConfigurer.kt @@ -41,6 +41,10 @@ class TeamHttpSecurityConfigurer : HttpSecurityConfigurer { .authenticated() .requestMatchers(antMatcher(HttpMethod.GET, "/api/v1/team/{teamKey}/candidate-user")) .authenticated() + .requestMatchers(antMatcher(HttpMethod.GET, "/api/v1/case/{caseId}/team")).authenticated() + .requestMatchers(antMatcher(HttpMethod.POST, "/api/v1/case/{caseId}/team")).authenticated() + .requestMatchers(antMatcher(HttpMethod.DELETE, "/api/v1/case/{caseId}/team/{teamKey}")) + .authenticated() } } catch (e: Exception) { throw HttpConfigurerConfigurationException(e) diff --git a/backend/team/src/main/kotlin/com/ritense/team/service/TeamManagementServiceImpl.kt b/backend/team/src/main/kotlin/com/ritense/team/service/TeamManagementServiceImpl.kt index 901bea887c..120d024269 100644 --- a/backend/team/src/main/kotlin/com/ritense/team/service/TeamManagementServiceImpl.kt +++ b/backend/team/src/main/kotlin/com/ritense/team/service/TeamManagementServiceImpl.kt @@ -36,6 +36,7 @@ import org.springframework.data.domain.Pageable import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID import com.ritense.valtimo.contract.authentication.Team as TeamInterface @Service @@ -60,6 +61,8 @@ class TeamManagementServiceImpl( ) ) + specification = specification.and(TeamRepositoryConfigSpecificationHelper.byAdHocCaseDocumentIdIsNull()) + if (titleContains != null) { specification = specification.and(TeamRepositoryConfigSpecificationHelper.byTitleContains(titleContains)) } @@ -148,6 +151,44 @@ class TeamManagementServiceImpl( return findAll(titleContains = null, pageable = pageable) } + override fun createAdHocTeam(adHocCaseDocumentId: UUID, title: String?): TeamInterface { + val generatedKey = "adhoc-${UUID.randomUUID()}" + val generatedTitle = title ?: "Ad hoc team" + val team = Team( + key = generatedKey, + title = generatedTitle, + adHocCaseDocumentId = adHocCaseDocumentId + ) + requirePermission(team, TeamActionProvider.CREATE) + return teamRepository.save(team) + } + + @Transactional(readOnly = true) + override fun findAllByAdHocCaseDocumentId( + adHocCaseDocumentId: UUID, + titleContains: String?, + pageable: Pageable + ): Page { + var specification: Specification = authorizationService.getAuthorizationSpecification( + EntityAuthorizationRequest( + Team::class.java, + TeamActionProvider.VIEW_LIST + ) + ) + + specification = specification.and(TeamRepositoryConfigSpecificationHelper.byAdHocCaseDocumentId(adHocCaseDocumentId)) + + if (titleContains != null) { + specification = specification.and(TeamRepositoryConfigSpecificationHelper.byTitleContains(titleContains)) + } + specification = specification.and(TeamRepositoryConfigSpecificationHelper.fetchUsers()) + return teamRepository.findAll(specification, pageable).map { it as TeamInterface } + } + + override fun deleteAllByAdHocCaseDocumentId(adHocCaseDocumentId: UUID) { + teamRepository.deleteByAdHocCaseDocumentId(adHocCaseDocumentId) + } + private fun requirePermission(team: Team, action: Action) { authorizationService.requirePermission( EntityAuthorizationRequest( diff --git a/backend/team/src/main/kotlin/com/ritense/team/web/rest/AdHocTeamResource.kt b/backend/team/src/main/kotlin/com/ritense/team/web/rest/AdHocTeamResource.kt new file mode 100644 index 0000000000..b75de7c03a --- /dev/null +++ b/backend/team/src/main/kotlin/com/ritense/team/web/rest/AdHocTeamResource.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.web.rest + +import com.ritense.team.web.rest.dto.AdHocTeamCreateRequestDto +import com.ritense.team.web.rest.dto.AdHocTeamResponseDto +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.authentication.TeamManagementService +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.SortDefault +import org.springframework.data.web.SortDefault.SortDefaults +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +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.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 +import java.util.UUID + +@RestController +@SkipComponentScan +@RequestMapping("/api/v1/case/{caseId}/team") +class AdHocTeamResource( + private val teamManagementService: TeamManagementService, +) { + + @GetMapping + fun getAdHocTeams( + @PathVariable caseId: UUID, + @RequestParam(required = false) titleContains: String?, + @SortDefaults(SortDefault(sort = ["title"])) pageable: Pageable, + ): Page { + return teamManagementService.findAllByAdHocCaseDocumentId(caseId, titleContains, pageable) + .map { AdHocTeamResponseDto.from(it) } + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun createAdHocTeam( + @PathVariable caseId: UUID, + @Valid @RequestBody(required = false) request: AdHocTeamCreateRequestDto?, + ): AdHocTeamResponseDto { + val team = teamManagementService.createAdHocTeam(caseId, request?.title) + return AdHocTeamResponseDto.from(team) + } + + @DeleteMapping("/{teamKey}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteAdHocTeam( + @PathVariable caseId: UUID, + @PathVariable teamKey: String, + ) { + val team = teamManagementService.findByKey(teamKey) + ?: throw IllegalArgumentException("Team not found") + require(team.adHocCaseDocumentId == caseId) { "Team does not belong to this case" } + teamManagementService.delete(teamKey) + } +} diff --git a/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamCreateRequestDto.kt b/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamCreateRequestDto.kt new file mode 100644 index 0000000000..03cdd6b5a7 --- /dev/null +++ b/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamCreateRequestDto.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.web.rest.dto + +import jakarta.validation.constraints.Size + +data class AdHocTeamCreateRequestDto( + @field:Size(max = 255) + val title: String? = null +) diff --git a/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamResponseDto.kt b/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamResponseDto.kt new file mode 100644 index 0000000000..17b39dceff --- /dev/null +++ b/backend/team/src/main/kotlin/com/ritense/team/web/rest/dto/AdHocTeamResponseDto.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.web.rest.dto + +import com.ritense.team.domain.Team +import java.util.UUID +import com.ritense.valtimo.contract.authentication.Team as TeamInterface + +data class AdHocTeamResponseDto( + val key: String, + val title: String, + val adHocCaseDocumentId: UUID?, + val userCount: Int, +) { + companion object { + fun from(team: TeamInterface) = AdHocTeamResponseDto( + key = team.key, + title = team.title, + adHocCaseDocumentId = team.adHocCaseDocumentId, + userCount = if (team is Team) team.users.size else 0 + ) + } +} diff --git a/backend/team/src/test/kotlin/com/ritense/team/service/AdHocTeamServiceIntTest.kt b/backend/team/src/test/kotlin/com/ritense/team/service/AdHocTeamServiceIntTest.kt new file mode 100644 index 0000000000..86ee2cb13d --- /dev/null +++ b/backend/team/src/test/kotlin/com/ritense/team/service/AdHocTeamServiceIntTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.team.BaseIntegrationTest +import com.ritense.team.domain.Team +import com.ritense.valtimo.contract.event.DocumentDeletedEvent +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.test.context.support.WithMockUser +import java.util.UUID + +class AdHocTeamServiceIntTest : BaseIntegrationTest() { + + @Autowired + lateinit var teamManagementService: TeamManagementServiceImpl + + @Autowired + lateinit var eventPublisher: ApplicationEventPublisher + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should create ad hoc team with generated title`() { + val caseId = UUID.randomUUID() + + val team = teamManagementService.createAdHocTeam(caseId) + + assertThat(team.key).startsWith("adhoc-") + assertThat(team.title).isEqualTo("Ad hoc team") + assertThat(team.adHocCaseDocumentId).isEqualTo(caseId) + assertThat(team.adHoc).isTrue() + } + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should create ad hoc team with custom title`() { + val caseId = UUID.randomUUID() + + val team = teamManagementService.createAdHocTeam(caseId, "Custom Team") + + assertThat(team.title).isEqualTo("Custom Team") + assertThat(team.adHocCaseDocumentId).isEqualTo(caseId) + } + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should exclude ad hoc teams from findAll`() { + val caseId = UUID.randomUUID() + + teamManagementService.create(Team(key = "regular-team-adhoc-test", title = "Regular")) + teamManagementService.createAdHocTeam(caseId) + + val allTeams = teamManagementService.findAll() + + assertThat(allTeams.content).noneMatch { it.adHoc } + assertThat(allTeams.content).anyMatch { it.key == "regular-team-adhoc-test" } + } + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should find ad hoc teams by case document id`() { + val caseId1 = UUID.randomUUID() + val caseId2 = UUID.randomUUID() + + teamManagementService.createAdHocTeam(caseId1, "Team A") + teamManagementService.createAdHocTeam(caseId1, "Team B") + teamManagementService.createAdHocTeam(caseId2, "Team C") + + val teams = teamManagementService.findAllByAdHocCaseDocumentId(caseId1) + + assertThat(teams.content).hasSize(2) + assertThat(teams.content.map { it.title }).containsExactlyInAnyOrder("Team A", "Team B") + } + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should delete all ad hoc teams by case document id`() { + val caseId = UUID.randomUUID() + + teamManagementService.createAdHocTeam(caseId, "To Delete 1") + teamManagementService.createAdHocTeam(caseId, "To Delete 2") + + teamManagementService.deleteAllByAdHocCaseDocumentId(caseId) + + val teams = teamManagementService.findAllByAdHocCaseDocumentId(caseId) + assertThat(teams.content).isEmpty() + } + + @Test + @WithMockUser(username = ADMIN_USER_NAME, authorities = [ADMIN]) + fun `should cleanup ad hoc teams on DocumentDeletedEvent`() { + val caseId = UUID.randomUUID() + + teamManagementService.createAdHocTeam(caseId, "Event Cleanup") + + eventPublisher.publishEvent(DocumentDeletedEvent(caseId)) + + val teams = runWithoutAuthorization { + teamManagementService.findAllByAdHocCaseDocumentId(caseId) + } + assertThat(teams.content).isEmpty() + } +} diff --git a/backend/team/src/test/kotlin/com/ritense/team/web/rest/AdHocTeamResourceIntTest.kt b/backend/team/src/test/kotlin/com/ritense/team/web/rest/AdHocTeamResourceIntTest.kt new file mode 100644 index 0000000000..9ec59500df --- /dev/null +++ b/backend/team/src/test/kotlin/com/ritense/team/web/rest/AdHocTeamResourceIntTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.team.web.rest + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.team.BaseIntegrationTest +import com.ritense.team.service.TeamManagementServiceImpl +import com.ritense.team.web.rest.dto.AdHocTeamCreateRequestDto +import org.hamcrest.Matchers.hasSize +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithMockUser +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.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import java.util.UUID + +class AdHocTeamResourceIntTest : BaseIntegrationTest() { + + @Autowired + lateinit var teamManagementService: TeamManagementServiceImpl + + @Autowired + lateinit var objectMapper: ObjectMapper + + @Autowired + lateinit var webApplicationContext: WebApplicationContext + + lateinit var mockMvc: MockMvc + + @BeforeEach + override fun beforeEach() { + super.beforeEach() + mockMvc = MockMvcBuilders + .webAppContextSetup(this.webApplicationContext) + .build() + } + + @Test + @WithMockUser(username = "admin", authorities = [ADMIN]) + fun `should create ad hoc team with generated title via REST`() { + val caseId = UUID.randomUUID() + + mockMvc.perform( + post("/api/v1/case/$caseId/team") + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.key").isNotEmpty) + .andExpect(jsonPath("$.title").value("Ad hoc team")) + .andExpect(jsonPath("$.adHocCaseDocumentId").value(caseId.toString())) + } + + @Test + @WithMockUser(username = "admin", authorities = [ADMIN]) + fun `should create ad hoc team with custom title via REST`() { + val caseId = UUID.randomUUID() + val request = AdHocTeamCreateRequestDto(title = "My Custom Team") + + mockMvc.perform( + post("/api/v1/case/$caseId/team") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.title").value("My Custom Team")) + } + + @Test + @WithMockUser(username = "admin", authorities = [ADMIN]) + fun `should list ad hoc teams for a case via REST`() { + val caseId = UUID.randomUUID() + val otherCaseId = UUID.randomUUID() + + runWithoutAuthorization { + teamManagementService.createAdHocTeam(caseId, "Team A") + teamManagementService.createAdHocTeam(caseId, "Team B") + teamManagementService.createAdHocTeam(otherCaseId, "Team C") + } + + mockMvc.perform(get("/api/v1/case/$caseId/team")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.content", hasSize(2))) + } + + @Test + @WithMockUser(username = "admin", authorities = [ADMIN]) + fun `should delete ad hoc team via REST`() { + val caseId = UUID.randomUUID() + + val team = runWithoutAuthorization { + teamManagementService.createAdHocTeam(caseId, "To Delete") + } + + mockMvc.perform(delete("/api/v1/case/$caseId/team/${team.key}")) + .andExpect(status().isNoContent) + + mockMvc.perform(get("/api/v1/case/$caseId/team")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.content", hasSize(0))) + } + + @Test + @WithMockUser(username = "admin", authorities = [ADMIN]) + fun `should not list ad hoc teams in regular team endpoint`() { + val caseId = UUID.randomUUID() + + runWithoutAuthorization { + teamManagementService.createAdHocTeam(caseId, "Hidden Ad Hoc") + } + + mockMvc.perform(get("/api/v1/team")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.content[?(@.title == 'Hidden Ad Hoc')]").doesNotExist()) + } +}