diff --git a/pom.xml b/pom.xml
index 428f705..619b7b7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -301,6 +301,54 @@
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+ prepare-agent
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+ jacoco-check
+ verify
+
+ check
+
+
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.80
+
+
+
+
+
+
+
+
+
+ **/openapi/**
+ **/generated/**
+ **/SiteBackendApplication*
+
+
+
diff --git a/src/main/kotlin/be/sgl/backend/entity/setting/SettingId.kt b/src/main/kotlin/be/sgl/backend/entity/setting/SettingId.kt
index 1f7ba62..aab1d59 100644
--- a/src/main/kotlin/be/sgl/backend/entity/setting/SettingId.kt
+++ b/src/main/kotlin/be/sgl/backend/entity/setting/SettingId.kt
@@ -5,5 +5,6 @@ enum class SettingId {
REPRESENTATIVE_TITLE,
REPRESENTATIVE_USERNAME,
REPRESENTATIVE_SIGNATURE,
- CALENDAR_NAME
+ CALENDAR_NAME,
+ ORGANIZATION_NAME
}
\ No newline at end of file
diff --git a/src/test/kotlin/be/sgl/backend/alert/AlertLoggerTest.kt b/src/test/kotlin/be/sgl/backend/alert/AlertLoggerTest.kt
new file mode 100644
index 0000000..38eca98
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/alert/AlertLoggerTest.kt
@@ -0,0 +1,99 @@
+package be.sgl.backend.alert
+
+import be.sgl.backend.service.MailService
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.Mockito.*
+import org.mockito.MockitoAnnotations
+import org.springframework.test.util.ReflectionTestUtils
+
+class AlertLoggerTest {
+
+ @Mock
+ private lateinit var mailService: MailService
+
+ @Mock
+ private lateinit var mailBuilder: MailService.MailBuilder
+
+ @InjectMocks
+ private lateinit var alertLogger: AlertLogger
+
+ @BeforeEach
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ ReflectionTestUtils.setField(alertLogger, "enabled", true)
+ ReflectionTestUtils.setField(alertLogger, "mailRecipient", "admin@example.com")
+ ReflectionTestUtils.setField(alertLogger, "environment", "test")
+ ReflectionTestUtils.setField(alertLogger, "host", "https://test.example.com")
+ }
+
+ @Test
+ fun `alert with lambda should send email when enabled`() {
+ `when`(mailService.builder()).thenReturn(mailBuilder)
+ `when`(mailBuilder.to(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.subject(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.body(anyString())).thenReturn(mailBuilder)
+
+ alertLogger.alert(AlertCode.NEW_USER_EXISTS_NO_MEMBERSHIP) { "Test alert message" }
+
+ verify(mailService).builder()
+ verify(mailBuilder).to("admin@example.com")
+ verify(mailBuilder).subject("https://test.example.com - test: NEW_USER_EXISTS_NO_MEMBERSHIP")
+ verify(mailBuilder).body("Test alert message")
+ verify(mailBuilder).send()
+ }
+
+ @Test
+ fun `alert with string should send email when enabled`() {
+ `when`(mailService.builder()).thenReturn(mailBuilder)
+ `when`(mailBuilder.to(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.subject(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.body(anyString())).thenReturn(mailBuilder)
+
+ alertLogger.alert(AlertCode.NEW_USER_EXISTS_PAID_MEMBERSHIP, "Test alert message")
+
+ verify(mailService).builder()
+ verify(mailBuilder).to("admin@example.com")
+ verify(mailBuilder).subject("https://test.example.com - test: NEW_USER_EXISTS_PAID_MEMBERSHIP")
+ verify(mailBuilder).body("Test alert message")
+ verify(mailBuilder).send()
+ }
+
+ @Test
+ fun `alert should not send email when disabled`() {
+ ReflectionTestUtils.setField(alertLogger, "enabled", false)
+
+ alertLogger.alert(AlertCode.NEW_USER_EXISTS_NO_MEMBERSHIP, "Test alert message")
+
+ verify(mailService, never()).builder()
+ }
+
+ @Test
+ fun `alert should use correct mail recipient`() {
+ ReflectionTestUtils.setField(alertLogger, "mailRecipient", "custom@example.com")
+ `when`(mailService.builder()).thenReturn(mailBuilder)
+ `when`(mailBuilder.to(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.subject(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.body(anyString())).thenReturn(mailBuilder)
+
+ alertLogger.alert(AlertCode.NEW_USER_EXISTS_NO_MEMBERSHIP, "Test alert")
+
+ verify(mailBuilder).to("custom@example.com")
+ }
+
+ @Test
+ fun `alert should format subject with host environment and code`() {
+ ReflectionTestUtils.setField(alertLogger, "host", "https://prod.example.com")
+ ReflectionTestUtils.setField(alertLogger, "environment", "production")
+ `when`(mailService.builder()).thenReturn(mailBuilder)
+ `when`(mailBuilder.to(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.subject(anyString())).thenReturn(mailBuilder)
+ `when`(mailBuilder.body(anyString())).thenReturn(mailBuilder)
+
+ alertLogger.alert(AlertCode.NEW_USER_EXISTS_PAID_MEMBERSHIP, "Test")
+
+ verify(mailBuilder).subject("https://prod.example.com - production: NEW_USER_EXISTS_PAID_MEMBERSHIP")
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/config/CustomUserDetailsTest.kt b/src/test/kotlin/be/sgl/backend/config/CustomUserDetailsTest.kt
new file mode 100644
index 0000000..2fe2e21
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/config/CustomUserDetailsTest.kt
@@ -0,0 +1,166 @@
+package be.sgl.backend.config
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
+import org.springframework.security.core.authority.SimpleGrantedAuthority
+import org.springframework.security.oauth2.jwt.Jwt
+
+class CustomUserDetailsTest {
+
+ @Test
+ fun `CustomUserDetails should extract username from JWT`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertEquals("john.doe", userDetails.username)
+ }
+
+ @Test
+ fun `CustomUserDetails should extract firstName from JWT`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertEquals("John", userDetails.firstName)
+ }
+
+ @Test
+ fun `CustomUserDetails should extract lastName from JWT`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertEquals("Doe", userDetails.lastName)
+ }
+
+ @Test
+ fun `CustomUserDetails should extract email from JWT`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertEquals("john.doe@example.com", userDetails.email)
+ }
+
+ @Test
+ fun `CustomUserDetails should extract externalId from JWT`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertEquals("ext-12345", userDetails.externalId)
+ }
+
+ @Test
+ fun `CustomUserDetails should extract authorities from JWT roles`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(listOf("ROLE_USER", "ROLE_ADMIN"))
+
+ val userDetails = CustomUserDetails(jwt)
+
+ val authorities = userDetails.authorities
+ assertEquals(2, authorities.size)
+ assertTrue(authorities.contains(SimpleGrantedAuthority("ROLE_USER")))
+ assertTrue(authorities.contains(SimpleGrantedAuthority("ROLE_ADMIN")))
+ }
+
+ @Test
+ fun `CustomUserDetails should have empty authorities when roles claim is null`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ val authorities = userDetails.authorities
+ assertTrue(authorities.isEmpty())
+ }
+
+ @Test
+ fun `CustomUserDetails should have empty authorities when roles claim is empty list`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(emptyList())
+
+ val userDetails = CustomUserDetails(jwt)
+
+ val authorities = userDetails.authorities
+ assertTrue(authorities.isEmpty())
+ }
+
+ @Test
+ fun `CustomUserDetails getPassword should return null`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertNull(userDetails.password)
+ }
+
+ @Test
+ fun `CustomUserDetails should implement UserDetails interface`() {
+ val jwt = mock(Jwt::class.java)
+ `when`(jwt.getClaim("preferred_username")).thenReturn("john.doe")
+ `when`(jwt.getClaim("given_name")).thenReturn("John")
+ `when`(jwt.getClaim("family_name")).thenReturn("Doe")
+ `when`(jwt.getClaim("email")).thenReturn("john.doe@example.com")
+ `when`(jwt.getClaim("sub")).thenReturn("ext-12345")
+ `when`(jwt.getClaimAsStringList("roles")).thenReturn(null)
+
+ val userDetails = CustomUserDetails(jwt)
+
+ assertTrue(userDetails is org.springframework.security.core.userdetails.UserDetails)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/controller/BranchControllerIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/controller/BranchControllerIntegrationTest.kt
new file mode 100644
index 0000000..4bfc8fe
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/controller/BranchControllerIntegrationTest.kt
@@ -0,0 +1,271 @@
+package be.sgl.backend.controller
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.util.IntegrationTest
+import com.fasterxml.jackson.databind.ObjectMapper
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
+import org.springframework.context.annotation.Import
+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.*
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
+
+@IntegrationTest
+@AutoConfigureMockMvc
+@Import(TestConfigurations::class)
+@Transactional
+class BranchControllerIntegrationTest {
+
+ @Autowired
+ private lateinit var mockMvc: MockMvc
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var objectMapper: ObjectMapper
+
+ private lateinit var activeBranch: Branch
+ private lateinit var passiveBranch: Branch
+
+ @BeforeEach
+ fun setup() {
+ branchRepository.deleteAll()
+
+ activeBranch = Branch().apply {
+ name = "Active Branch"
+ email = "active@example.com"
+ minimumAge = 6
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Active Description"
+ law = "Law"
+ image = "active.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ activeBranch = branchRepository.save(activeBranch)
+
+ passiveBranch = Branch().apply {
+ name = "Passive Branch"
+ email = "passive@example.com"
+ minimumAge = 12
+ maximumAge = 16
+ sex = Sex.UNKNOWN
+ description = "Passive Description"
+ law = "Law"
+ image = "passive.jpg"
+ status = BranchStatus.PASSIVE
+ staffTitle = "Leader"
+ }
+ passiveBranch = branchRepository.save(passiveBranch)
+ branchRepository.flush()
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `GET all branches should return all branches for admin`() {
+ mockMvc.perform(get("/branches"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$").isArray)
+ .andExpect(jsonPath("$.length()").value(2))
+ }
+
+ @Test
+ fun `GET all branches should fail without authentication`() {
+ mockMvc.perform(get("/branches"))
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ @WithMockUser(roles = ["USER"])
+ fun `GET all branches should fail for regular user`() {
+ mockMvc.perform(get("/branches"))
+ .andExpect(status().isForbidden)
+ }
+
+ @Test
+ fun `GET visible branches should return only non-passive branches`() {
+ mockMvc.perform(get("/branches/visible"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$").isArray)
+ .andExpect(jsonPath("$[?(@.name == 'Active Branch')]").exists())
+ .andExpect(jsonPath("$[?(@.name == 'Passive Branch')]").doesNotExist())
+ }
+
+ @Test
+ fun `GET branches with calendar should return only active branches`() {
+ mockMvc.perform(get("/branches/with-calendar"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$").isArray)
+ .andExpect(jsonPath("$[?(@.name == 'Active Branch')]").exists())
+ }
+
+ @Test
+ fun `GET branch by id should return branch`() {
+ mockMvc.perform(get("/branches/${activeBranch.id}"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.name").value("Active Branch"))
+ .andExpect(jsonPath("$.email").value("active@example.com"))
+ .andExpect(jsonPath("$.status").value("ACTIVE"))
+ }
+
+ @Test
+ fun `GET branch by id should return 404 for non-existent branch`() {
+ mockMvc.perform(get("/branches/999"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `POST branch should create new branch`() {
+ val newBranch = mapOf(
+ "name" to "New Branch",
+ "email" to "new@example.com",
+ "minimumAge" to 16,
+ "maximumAge" to 18,
+ "sex" to "UNKNOWN",
+ "description" to "New Description",
+ "law" to "Law",
+ "status" to "ACTIVE",
+ "staffTitle" to "Leader"
+ )
+
+ mockMvc.perform(
+ post("/branches")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newBranch))
+ )
+ .andExpect(status().isCreated)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.name").value("New Branch"))
+ .andExpect(jsonPath("$.email").value("new@example.com"))
+ }
+
+ @Test
+ fun `POST branch should fail without authentication`() {
+ val newBranch = mapOf(
+ "name" to "New Branch",
+ "email" to "new@example.com",
+ "minimumAge" to 16,
+ "maximumAge" to 18,
+ "sex" to "UNKNOWN",
+ "description" to "New Description",
+ "law" to "Law",
+ "status" to "ACTIVE",
+ "staffTitle" to "Leader"
+ )
+
+ mockMvc.perform(
+ post("/branches")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newBranch))
+ )
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `PUT branch should update existing branch`() {
+ val updatedBranch = mapOf(
+ "id" to activeBranch.id,
+ "name" to "Updated Branch",
+ "email" to "updated@example.com",
+ "minimumAge" to activeBranch.minimumAge,
+ "maximumAge" to activeBranch.maximumAge,
+ "sex" to activeBranch.sex.toString(),
+ "description" to "Updated Description",
+ "law" to activeBranch.law,
+ "status" to activeBranch.status.toString(),
+ "staffTitle" to activeBranch.staffTitle
+ )
+
+ mockMvc.perform(
+ put("/branches/${activeBranch.id}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedBranch))
+ )
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.name").value("Updated Branch"))
+ .andExpect(jsonPath("$.email").value("updated@example.com"))
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `PUT branch should return 404 for non-existent branch`() {
+ val updatedBranch = mapOf(
+ "id" to 999,
+ "name" to "Updated Branch",
+ "email" to "updated@example.com",
+ "minimumAge" to 6,
+ "maximumAge" to 12,
+ "sex" to "UNKNOWN",
+ "description" to "Updated Description",
+ "law" to "Law",
+ "status" to "ACTIVE",
+ "staffTitle" to "Leader"
+ )
+
+ mockMvc.perform(
+ put("/branches/999")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedBranch))
+ )
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `PUT branch should fail without authentication`() {
+ val updatedBranch = mapOf(
+ "id" to activeBranch.id,
+ "name" to "Updated Branch",
+ "email" to "updated@example.com",
+ "minimumAge" to activeBranch.minimumAge,
+ "maximumAge" to activeBranch.maximumAge,
+ "sex" to activeBranch.sex.toString(),
+ "description" to "Updated Description",
+ "law" to activeBranch.law,
+ "status" to activeBranch.status.toString(),
+ "staffTitle" to activeBranch.staffTitle
+ )
+
+ mockMvc.perform(
+ put("/branches/${activeBranch.id}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedBranch))
+ )
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ fun `GET visible branches should be publicly accessible`() {
+ mockMvc.perform(get("/branches/visible"))
+ .andExpect(status().isOk)
+ }
+
+ @Test
+ fun `GET branch by id should be publicly accessible`() {
+ mockMvc.perform(get("/branches/${activeBranch.id}"))
+ .andExpect(status().isOk)
+ }
+
+ @Test
+ fun `GET branches with calendar should be publicly accessible`() {
+ mockMvc.perform(get("/branches/with-calendar"))
+ .andExpect(status().isOk)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/controller/NewsItemControllerIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/controller/NewsItemControllerIntegrationTest.kt
new file mode 100644
index 0000000..7d23ba0
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/controller/NewsItemControllerIntegrationTest.kt
@@ -0,0 +1,300 @@
+package be.sgl.backend.controller
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.NewsItem
+import be.sgl.backend.repository.NewsItemRepository
+import be.sgl.backend.util.IntegrationTest
+import com.fasterxml.jackson.databind.ObjectMapper
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
+import org.springframework.context.annotation.Import
+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.*
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
+
+@IntegrationTest
+@AutoConfigureMockMvc
+@Import(TestConfigurations::class)
+@Transactional
+class NewsItemControllerIntegrationTest {
+
+ @Autowired
+ private lateinit var mockMvc: MockMvc
+
+ @Autowired
+ private lateinit var newsItemRepository: NewsItemRepository
+
+ @Autowired
+ private lateinit var objectMapper: ObjectMapper
+
+ private lateinit var visibleNewsItem: NewsItem
+ private lateinit var hiddenNewsItem: NewsItem
+
+ @BeforeEach
+ fun setup() {
+ newsItemRepository.deleteAll()
+
+ visibleNewsItem = NewsItem().apply {
+ title = "Visible News"
+ content = "This is visible content"
+ visible = true
+ }
+ visibleNewsItem = newsItemRepository.save(visibleNewsItem)
+
+ hiddenNewsItem = NewsItem().apply {
+ title = "Hidden News"
+ content = "This is hidden content"
+ visible = false
+ }
+ hiddenNewsItem = newsItemRepository.save(hiddenNewsItem)
+ newsItemRepository.flush()
+ }
+
+ @Test
+ fun `GET visible news items should return only visible items`() {
+ mockMvc.perform(get("/news"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$").isArray)
+ .andExpect(jsonPath("$[?(@.title == 'Visible News')]").exists())
+ .andExpect(jsonPath("$[?(@.title == 'Hidden News')]").doesNotExist())
+ }
+
+ @Test
+ fun `GET visible news items should be publicly accessible`() {
+ mockMvc.perform(get("/news"))
+ .andExpect(status().isOk)
+ }
+
+ @Test
+ fun `GET news item by id should return news item`() {
+ mockMvc.perform(get("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.title").value("Visible News"))
+ .andExpect(jsonPath("$.content").value("This is visible content"))
+ .andExpect(jsonPath("$.visible").value(true))
+ }
+
+ @Test
+ fun `GET news item by id should return hidden items too`() {
+ mockMvc.perform(get("/news/${hiddenNewsItem.id}"))
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.title").value("Hidden News"))
+ .andExpect(jsonPath("$.visible").value(false))
+ }
+
+ @Test
+ fun `GET news item by id should return 404 for non-existent item`() {
+ mockMvc.perform(get("/news/999"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `GET news item by id should be publicly accessible`() {
+ mockMvc.perform(get("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isOk)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `POST news item should create new item`() {
+ val newNewsItem = mapOf(
+ "title" to "New News",
+ "content" to "New content",
+ "visible" to true
+ )
+
+ mockMvc.perform(
+ post("/news")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newNewsItem))
+ )
+ .andExpect(status().isCreated)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.title").value("New News"))
+ .andExpect(jsonPath("$.content").value("New content"))
+ .andExpect(jsonPath("$.visible").value(true))
+ }
+
+ @Test
+ fun `POST news item should fail without authentication`() {
+ val newNewsItem = mapOf(
+ "title" to "New News",
+ "content" to "New content",
+ "visible" to true
+ )
+
+ mockMvc.perform(
+ post("/news")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newNewsItem))
+ )
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ @WithMockUser(roles = ["USER"])
+ fun `POST news item should fail for regular user`() {
+ val newNewsItem = mapOf(
+ "title" to "New News",
+ "content" to "New content",
+ "visible" to true
+ )
+
+ mockMvc.perform(
+ post("/news")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(newNewsItem))
+ )
+ .andExpect(status().isForbidden)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `PUT news item should update existing item`() {
+ val updatedNewsItem = mapOf(
+ "id" to visibleNewsItem.id,
+ "title" to "Updated News",
+ "content" to "Updated content",
+ "visible" to false
+ )
+
+ mockMvc.perform(
+ put("/news/${visibleNewsItem.id}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedNewsItem))
+ )
+ .andExpect(status().isOk)
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.title").value("Updated News"))
+ .andExpect(jsonPath("$.content").value("Updated content"))
+ .andExpect(jsonPath("$.visible").value(false))
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `PUT news item should return 404 for non-existent item`() {
+ val updatedNewsItem = mapOf(
+ "id" to 999,
+ "title" to "Updated News",
+ "content" to "Updated content",
+ "visible" to true
+ )
+
+ mockMvc.perform(
+ put("/news/999")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedNewsItem))
+ )
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `PUT news item should fail without authentication`() {
+ val updatedNewsItem = mapOf(
+ "id" to visibleNewsItem.id,
+ "title" to "Updated News",
+ "content" to "Updated content",
+ "visible" to false
+ )
+
+ mockMvc.perform(
+ put("/news/${visibleNewsItem.id}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updatedNewsItem))
+ )
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `DELETE news item should delete existing item`() {
+ mockMvc.perform(delete("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isOk)
+
+ // Verify deletion
+ mockMvc.perform(get("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `DELETE news item should return 404 for non-existent item`() {
+ mockMvc.perform(delete("/news/999"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `DELETE news item should fail without authentication`() {
+ mockMvc.perform(delete("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isUnauthorized)
+ }
+
+ @Test
+ @WithMockUser(roles = ["USER"])
+ fun `DELETE news item should fail for regular user`() {
+ mockMvc.perform(delete("/news/${visibleNewsItem.id}"))
+ .andExpect(status().isForbidden)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `POST news item should validate required fields`() {
+ val invalidNewsItem = mapOf(
+ "title" to "", // Empty title
+ "content" to "Some content",
+ "visible" to true
+ )
+
+ mockMvc.perform(
+ post("/news")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(invalidNewsItem))
+ )
+ .andExpect(status().isBadRequest)
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `news items should be ordered by most recent first`() {
+ // Create a newer news item
+ Thread.sleep(10) // Ensure different timestamps
+ val newerNewsItem = NewsItem().apply {
+ title = "Newer News"
+ content = "Newer content"
+ visible = true
+ }
+ newsItemRepository.save(newerNewsItem)
+ newsItemRepository.flush()
+
+ mockMvc.perform(get("/news"))
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$[0].title").value("Newer News"))
+ }
+
+ @Test
+ @WithMockUser(roles = ["STAFF"])
+ fun `PUT news item can toggle visibility`() {
+ val toggledNewsItem = mapOf(
+ "id" to visibleNewsItem.id,
+ "title" to visibleNewsItem.title,
+ "content" to visibleNewsItem.content,
+ "visible" to false // Toggle from true to false
+ )
+
+ mockMvc.perform(
+ put("/news/${visibleNewsItem.id}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(toggledNewsItem))
+ )
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.visible").value(false))
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/controller/SettingControllerIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/controller/SettingControllerIntegrationTest.kt
new file mode 100644
index 0000000..fb09c04
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/controller/SettingControllerIntegrationTest.kt
@@ -0,0 +1,153 @@
+package be.sgl.backend.controller
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.setting.Setting
+import be.sgl.backend.entity.setting.SettingId
+import be.sgl.backend.repository.SettingRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+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.*
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class SettingControllerIntegrationTest {
+
+ @Autowired
+ private lateinit var mockMvc: MockMvc
+
+ @Autowired
+ private lateinit var settingRepository: SettingRepository
+
+ @BeforeEach
+ fun setup() {
+ settingRepository.deleteAll()
+ }
+
+ @Test
+ fun `GET setting should return setting value when it exists`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Test Calendar")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ mockMvc.perform(get("/settings/${SettingId.CALENDAR_NAME.name}"))
+ .andExpect(status().isOk)
+ .andExpect(content().string("Test Calendar"))
+ }
+
+ @Test
+ fun `GET setting should return empty when setting does not exist`() {
+ mockMvc.perform(get("/settings/${SettingId.CALENDAR_NAME.name}"))
+ .andExpect(status().isOk)
+ .andExpect(content().string(""))
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `PUT setting should create new setting`() {
+ mockMvc.perform(
+ put("/settings/${SettingId.CALENDAR_NAME.name}")
+ .param("value", "New Calendar")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isOk)
+
+ val saved = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assert(saved != null)
+ assert(saved?.value == "New Calendar")
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `PUT setting should update existing setting`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Old Value")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ mockMvc.perform(
+ put("/settings/${SettingId.CALENDAR_NAME.name}")
+ .param("value", "Updated Value")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isOk)
+
+ val updated = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assert(updated?.value == "Updated Value")
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `PUT setting with null value should delete setting`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "To Delete")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ mockMvc.perform(
+ put("/settings/${SettingId.CALENDAR_NAME.name}")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isOk)
+
+ val deleted = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assert(deleted == null)
+ }
+
+ @Test
+ @WithMockUser(roles = ["USER"])
+ fun `PUT setting should require ADMIN role`() {
+ mockMvc.perform(
+ put("/settings/${SettingId.CALENDAR_NAME.name}")
+ .param("value", "New Value")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isForbidden)
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `PUT setting should handle numeric values`() {
+ mockMvc.perform(
+ put("/settings/${SettingId.LATEST_DISPATCH_RATE.name}")
+ .param("value", "42.5")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isOk)
+
+ val saved = settingRepository.findByIdOrNull(SettingId.LATEST_DISPATCH_RATE.name)
+ assert(saved?.value == "42.5")
+ }
+
+ @Test
+ @WithMockUser(roles = ["ADMIN"])
+ fun `PUT setting should handle empty string`() {
+ mockMvc.perform(
+ put("/settings/${SettingId.CALENDAR_NAME.name}")
+ .param("value", "")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ )
+ .andExpect(status().isOk)
+
+ val saved = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assert(saved?.value == "")
+ }
+
+ @Test
+ fun `GET setting should be publicly accessible`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Public Calendar")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ // No authentication
+ mockMvc.perform(get("/settings/${SettingId.CALENDAR_NAME.name}"))
+ .andExpect(status().isOk)
+ .andExpect(content().string("Public Calendar"))
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/controller/SettingControllerTest.kt b/src/test/kotlin/be/sgl/backend/controller/SettingControllerTest.kt
new file mode 100644
index 0000000..f668800
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/controller/SettingControllerTest.kt
@@ -0,0 +1,101 @@
+package be.sgl.backend.controller
+
+import be.sgl.backend.entity.setting.SettingId
+import be.sgl.backend.service.SettingService
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.Mockito.*
+import org.mockito.MockitoAnnotations
+import org.springframework.http.HttpStatus
+
+class SettingControllerTest {
+
+ @Mock
+ private lateinit var settingService: SettingService
+
+ @InjectMocks
+ private lateinit var settingController: SettingController
+
+ @BeforeEach
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ }
+
+ @Test
+ fun `getSetting should return setting value when it exists`() {
+ val settingId = SettingId.CALENDAR_NAME
+ `when`(settingService.get(settingId)).thenReturn("Test Calendar")
+
+ val response = settingController.getSetting(settingId)
+
+ assertEquals(HttpStatus.OK, response.statusCode)
+ assertEquals("Test Calendar", response.body)
+ verify(settingService).get(settingId)
+ }
+
+ @Test
+ fun `getSetting should return null when setting does not exist`() {
+ val settingId = SettingId.CALENDAR_NAME
+ `when`(settingService.get(settingId)).thenReturn(null)
+
+ val response = settingController.getSetting(settingId)
+
+ assertEquals(HttpStatus.OK, response.statusCode)
+ assertNull(response.body)
+ verify(settingService).get(settingId)
+ }
+
+ @Test
+ fun `updateSetting should update setting value`() {
+ val settingId = SettingId.CALENDAR_NAME
+ val value = "New Calendar Name"
+
+ val response = settingController.updateSetting(settingId, value)
+
+ assertEquals(HttpStatus.OK, response.statusCode)
+ verify(settingService).update(settingId, value)
+ }
+
+ @Test
+ fun `updateSetting should handle null value`() {
+ val settingId = SettingId.CALENDAR_NAME
+
+ val response = settingController.updateSetting(settingId, null)
+
+ assertEquals(HttpStatus.OK, response.statusCode)
+ verify(settingService).update(settingId, null)
+ }
+
+ @Test
+ fun `updateSetting should handle empty value`() {
+ val settingId = SettingId.CALENDAR_NAME
+ val value = ""
+
+ val response = settingController.updateSetting(settingId, value)
+
+ assertEquals(HttpStatus.OK, response.statusCode)
+ verify(settingService).update(settingId, value)
+ }
+
+ @Test
+ fun `updateSetting should handle multiple different setting IDs`() {
+ val settings = listOf(
+ SettingId.CALENDAR_NAME to "Calendar",
+ SettingId.ORGANIZATION_NAME to "Organization",
+ SettingId.REPRESENTATIVE_TITLE to "Title",
+ SettingId.REPRESENTATIVE_USERNAME to "Username"
+ )
+
+ settings.forEach { (id, value) ->
+ val response = settingController.updateSetting(id, value)
+ assertEquals(HttpStatus.OK, response.statusCode)
+ }
+
+ settings.forEach { (id, value) ->
+ verify(settingService).update(id, value)
+ }
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/AddressTest.kt b/src/test/kotlin/be/sgl/backend/entity/AddressTest.kt
new file mode 100644
index 0000000..76383d1
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/AddressTest.kt
@@ -0,0 +1,130 @@
+package be.sgl.backend.entity
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class AddressTest {
+
+ @Test
+ fun `getStreetAddress should format street and number correctly`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertEquals("Main Street 123", address.getStreetAdress())
+ }
+
+ @Test
+ fun `getStreetAddress should include subPremise when present`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ subPremise = "A"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertEquals("Main Street 123A", address.getStreetAdress())
+ }
+
+ @Test
+ fun `getStreetAddress should not include subPremise when null`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ subPremise = null
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertEquals("Main Street 123", address.getStreetAdress())
+ }
+
+ @Test
+ fun `toString should format complete address correctly`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertEquals("Main Street 123, 1000 Brussels (BE)", address.toString())
+ }
+
+ @Test
+ fun `toString should format complete address with subPremise`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ subPremise = "B"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertEquals("Main Street 123B, 1000 Brussels (BE)", address.toString())
+ }
+
+ @Test
+ fun `postalAddress should default to false`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ assertFalse(address.postalAdress)
+ }
+
+ @Test
+ fun `postalAddress can be set to true`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ postalAdress = true
+ }
+
+ assertTrue(address.postalAdress)
+ }
+
+ @Test
+ fun `externalId can be set and retrieved`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ externalId = "EXT-12345"
+ }
+
+ assertEquals("EXT-12345", address.externalId)
+ }
+
+ @Test
+ fun `description can be set and retrieved`() {
+ val address = Address().apply {
+ street = "Main Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ description = "Main office"
+ }
+
+ assertEquals("Main office", address.description)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/MembershipPeriodTest.kt b/src/test/kotlin/be/sgl/backend/entity/MembershipPeriodTest.kt
new file mode 100644
index 0000000..74dac3e
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/MembershipPeriodTest.kt
@@ -0,0 +1,283 @@
+package be.sgl.backend.entity
+
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.membership.MembershipRestriction
+import be.sgl.backend.entity.user.Sex
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+
+class MembershipPeriodTest {
+
+ @Test
+ fun `getLimitForBranch should return limit when restriction exists for branch`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val restriction = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.registrationLimit = 50
+ }
+
+ period.restrictions.add(restriction)
+
+ val limit = period.getLimitForBranch(branch)
+
+ assertEquals(50, limit)
+ }
+
+ @Test
+ fun `getLimitForBranch should return null when no restriction for branch`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val limit = period.getLimitForBranch(branch)
+
+ assertNull(limit)
+ }
+
+ @Test
+ fun `toString should format dates in Belgian format`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val result = period.toString()
+
+ assertEquals("01/09/2024 - 31/08/2025", result)
+ }
+
+ @Test
+ fun `validateRestrictions should pass when no restrictions`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ assertDoesNotThrow {
+ period.validateRestrictions()
+ }
+ }
+
+ @Test
+ fun `validateRestrictions should pass with valid time restrictions`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val timeRestriction1 = MembershipRestriction().apply {
+ this.period = period
+ this.alternativeStart = LocalDate.of(2024, 7, 1)
+ this.alternativePrice = 80.0
+ }
+
+ val timeRestriction2 = MembershipRestriction().apply {
+ this.period = period
+ this.alternativeStart = LocalDate.of(2024, 8, 1)
+ this.alternativePrice = 90.0
+ }
+
+ period.restrictions.addAll(listOf(timeRestriction1, timeRestriction2))
+
+ assertDoesNotThrow {
+ period.validateRestrictions()
+ }
+ }
+
+ @Test
+ fun `validateRestrictions should pass with one branch restriction per branch`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val branch1 = Branch().apply {
+ name = "Branch 1"
+ email = "branch1@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val branch2 = Branch().apply {
+ name = "Branch 2"
+ email = "branch2@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val restriction1 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch1
+ this.registrationLimit = 50
+ }
+
+ val restriction2 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch2
+ this.registrationLimit = 60
+ }
+
+ period.restrictions.addAll(listOf(restriction1, restriction2))
+
+ assertDoesNotThrow {
+ period.validateRestrictions()
+ }
+ }
+
+ @Test
+ fun `validateRestrictions should fail with multiple non-time restrictions for same branch`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val restriction1 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.registrationLimit = 50
+ // No alternativeStart, so not a time restriction
+ }
+
+ val restriction2 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.alternativePrice = 75.0
+ // No alternativeStart, so not a time restriction
+ }
+
+ period.restrictions.addAll(listOf(restriction1, restriction2))
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ period.validateRestrictions()
+ }
+
+ assertEquals("A branch should at most have one single non-time related restriction!", exception.message)
+ }
+
+ @Test
+ fun `validateRestrictions should allow time restrictions plus one regular restriction per branch`() {
+ val period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val branchRestriction = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.registrationLimit = 50
+ }
+
+ val timeRestriction1 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.alternativeStart = LocalDate.of(2024, 7, 1)
+ this.alternativePrice = 80.0
+ }
+
+ val timeRestriction2 = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.alternativeStart = LocalDate.of(2024, 8, 1)
+ this.alternativePrice = 90.0
+ }
+
+ period.restrictions.addAll(listOf(branchRestriction, timeRestriction1, timeRestriction2))
+
+ assertDoesNotThrow {
+ period.validateRestrictions()
+ }
+ }
+
+ @Test
+ fun `period should have default values`() {
+ val period = MembershipPeriod()
+
+ assertEquals(0.0, period.price)
+ assertNull(period.registrationLimit)
+ assertEquals(3.0, period.reductionFactor)
+ assertEquals(0.0, period.siblingReduction)
+ assertNotNull(period.restrictions)
+ assertTrue(period.restrictions.isEmpty())
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/PayableAndRegistrableTest.kt b/src/test/kotlin/be/sgl/backend/entity/PayableAndRegistrableTest.kt
new file mode 100644
index 0000000..b806ddc
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/PayableAndRegistrableTest.kt
@@ -0,0 +1,198 @@
+package be.sgl.backend.entity
+
+import be.sgl.backend.entity.registrable.Registrable
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.time.LocalDateTime
+
+class PayableAndRegistrableTest {
+
+ private class TestPayable : Payable() {
+ init {
+ name = "Test Payable"
+ description = "Test description"
+ }
+ }
+
+ private class TestRegistrable : Registrable() {
+ init {
+ name = "Test Event"
+ description = "Test description"
+ }
+ }
+
+ @Test
+ fun `Payable should initialize with default dates`() {
+ val payable = TestPayable()
+
+ assertNotNull(payable.open)
+ assertNotNull(payable.closed)
+ }
+
+ @Test
+ fun `Payable should allow setting name and description`() {
+ val payable = TestPayable()
+
+ assertEquals("Test Payable", payable.name)
+ assertEquals("Test description", payable.description)
+ }
+
+ @Test
+ fun `Payable should allow setting open and closed dates`() {
+ val payable = TestPayable()
+ val openDate = LocalDateTime.of(2024, 1, 1, 0, 0)
+ val closedDate = LocalDateTime.of(2024, 12, 31, 23, 59)
+
+ payable.open = openDate
+ payable.closed = closedDate
+
+ assertEquals(openDate, payable.open)
+ assertEquals(closedDate, payable.closed)
+ }
+
+ @Test
+ fun `Registrable should extend Payable`() {
+ val registrable = TestRegistrable()
+
+ assertTrue(registrable is Payable)
+ assertNotNull(registrable.open)
+ assertNotNull(registrable.closed)
+ }
+
+ @Test
+ fun `Registrable should have start and end dates`() {
+ val registrable = TestRegistrable()
+
+ assertNotNull(registrable.start)
+ assertNotNull(registrable.end)
+ }
+
+ @Test
+ fun `Registrable should allow setting price and limits`() {
+ val registrable = TestRegistrable()
+
+ registrable.price = 50.0
+ registrable.registrationLimit = 100
+
+ assertEquals(50.0, registrable.price)
+ assertEquals(100, registrable.registrationLimit)
+ }
+
+ @Test
+ fun `Registrable should have default boolean flags`() {
+ val registrable = TestRegistrable()
+
+ assertTrue(registrable.cancellable)
+ assertTrue(registrable.sendConfirmation)
+ assertFalse(registrable.sendCompleteConfirmation)
+ assertFalse(registrable.cancelled)
+ }
+
+ @Test
+ fun `Registrable should allow setting boolean flags`() {
+ val registrable = TestRegistrable()
+
+ registrable.cancellable = false
+ registrable.sendConfirmation = false
+ registrable.sendCompleteConfirmation = true
+ registrable.cancelled = true
+
+ assertFalse(registrable.cancellable)
+ assertFalse(registrable.sendConfirmation)
+ assertTrue(registrable.sendCompleteConfirmation)
+ assertTrue(registrable.cancelled)
+ }
+
+ @Test
+ fun `Registrable readAdditionalData should return 0 when no rule`() {
+ val registrable = TestRegistrable()
+ registrable.additionalFormRule = null
+
+ val result = registrable.readAdditionalData("{\"field\":\"value\"}")
+
+ assertEquals(0.0, result)
+ }
+
+ @Test
+ fun `Registrable readAdditionalData should return 0 when no data`() {
+ val registrable = TestRegistrable()
+ registrable.additionalFormRule = "price + 10"
+
+ val result = registrable.readAdditionalData(null)
+
+ assertEquals(0.0, result)
+ }
+
+ @Test
+ fun `Registrable readAdditionalData should evaluate JSONata expression`() {
+ val registrable = TestRegistrable()
+ registrable.additionalFormRule = "nights * 10"
+
+ val result = registrable.readAdditionalData("{\"nights\":\"3\"}")
+
+ assertEquals(30.0, result)
+ }
+
+ @Test
+ fun `Registrable readAdditionalData should coerce negative results to 0`() {
+ val registrable = TestRegistrable()
+ registrable.additionalFormRule = "nights - 10"
+
+ val result = registrable.readAdditionalData("{\"nights\":\"2\"}")
+
+ assertEquals(0.0, result)
+ }
+
+ @Test
+ fun `Registrable readAdditionalData should handle complex JSONata expressions`() {
+ val registrable = TestRegistrable()
+ registrable.additionalFormRule = "adults * 20 + children * 10"
+
+ val result = registrable.readAdditionalData("{\"adults\":\"2\",\"children\":\"3\"}")
+
+ assertEquals(70.0, result)
+ }
+
+ @Test
+ fun `Registrable should allow setting address`() {
+ val registrable = TestRegistrable()
+ val address = Address().apply {
+ street = "Test Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ registrable.address = address
+
+ assertNotNull(registrable.address)
+ assertEquals("Test Street", registrable.address?.street)
+ }
+
+ @Test
+ fun `Registrable should allow setting communication options`() {
+ val registrable = TestRegistrable()
+
+ registrable.communicationCC = "cc@example.com"
+ registrable.sendConfirmation = true
+ registrable.sendCompleteConfirmation = true
+
+ assertEquals("cc@example.com", registrable.communicationCC)
+ assertTrue(registrable.sendConfirmation)
+ assertTrue(registrable.sendCompleteConfirmation)
+ }
+
+ @Test
+ fun `Registrable should allow setting additional form and rule`() {
+ val registrable = TestRegistrable()
+ val formJson = "{\"fields\":[{\"name\":\"nights\",\"type\":\"number\"}]}"
+ val rule = "nights * 25"
+
+ registrable.additionalForm = formJson
+ registrable.additionalFormRule = rule
+
+ assertEquals(formJson, registrable.additionalForm)
+ assertEquals(rule, registrable.additionalFormRule)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/PaymentTest.kt b/src/test/kotlin/be/sgl/backend/entity/PaymentTest.kt
new file mode 100644
index 0000000..639fc1a
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/PaymentTest.kt
@@ -0,0 +1,71 @@
+package be.sgl.backend.entity
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class PaymentTest {
+
+ private class TestPayment : Payment() {
+ override fun getDescription(): String = "Test Payment Description"
+ }
+
+ @Test
+ fun `Payment should initialize with default values`() {
+ val payment = TestPayment()
+
+ assertNull(payment.id)
+ assertFalse(payment.paid)
+ assertEquals(0.0, payment.price)
+ assertNull(payment.paymentId)
+ }
+
+ @Test
+ fun `markPaid should set paid to true`() {
+ val payment = TestPayment()
+ assertFalse(payment.paid)
+
+ payment.markPaid()
+
+ assertTrue(payment.paid)
+ }
+
+ @Test
+ fun `price can be set and retrieved`() {
+ val payment = TestPayment()
+ payment.price = 42.50
+
+ assertEquals(42.50, payment.price)
+ }
+
+ @Test
+ fun `paymentId can be set and retrieved`() {
+ val payment = TestPayment()
+ payment.paymentId = "PAY-12345"
+
+ assertEquals("PAY-12345", payment.paymentId)
+ }
+
+ @Test
+ fun `getDescription should return correct description`() {
+ val payment = TestPayment()
+
+ assertEquals("Test Payment Description", payment.getDescription())
+ }
+
+ @Test
+ fun `paid can be set directly`() {
+ val payment = TestPayment()
+ payment.paid = true
+
+ assertTrue(payment.paid)
+ }
+
+ @Test
+ fun `markPaid should be idempotent`() {
+ val payment = TestPayment()
+ payment.markPaid()
+ payment.markPaid()
+
+ assertTrue(payment.paid)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/RegistrableStatusTest.kt b/src/test/kotlin/be/sgl/backend/entity/RegistrableStatusTest.kt
new file mode 100644
index 0000000..b18534c
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/RegistrableStatusTest.kt
@@ -0,0 +1,161 @@
+package be.sgl.backend.entity
+
+import be.sgl.backend.entity.registrable.Registrable
+import be.sgl.backend.entity.registrable.RegistrableStatus
+import be.sgl.backend.entity.registrable.RegistrableStatus.Companion.getStatus
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.time.LocalDateTime
+
+class RegistrableStatusTest {
+
+ private class TestRegistrable : Registrable() {
+ fun setCancelled(value: Boolean) {
+ this.cancelled = value
+ }
+
+ fun setOpenTime(time: LocalDateTime) {
+ this.open = time
+ }
+
+ fun setClosedTime(time: LocalDateTime) {
+ this.closed = time
+ }
+
+ fun setStartTime(time: LocalDateTime) {
+ this.start = time
+ }
+
+ fun setEndTime(time: LocalDateTime) {
+ this.end = time
+ }
+ }
+
+ @Test
+ fun `RegistrableStatus should have all expected values`() {
+ val values = RegistrableStatus.values()
+ assertEquals(6, values.size)
+ assertTrue(values.contains(RegistrableStatus.NOT_YET_OPEN))
+ assertTrue(values.contains(RegistrableStatus.REGISTRATIONS_OPENED))
+ assertTrue(values.contains(RegistrableStatus.REGISTRATIONS_COMPLETED))
+ assertTrue(values.contains(RegistrableStatus.STARTED))
+ assertTrue(values.contains(RegistrableStatus.COMPLETED))
+ assertTrue(values.contains(RegistrableStatus.CANCELLED))
+ }
+
+ @Test
+ fun `getStatus should return CANCELLED when registrable is cancelled`() {
+ val registrable = TestRegistrable()
+ registrable.setCancelled(true)
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.CANCELLED, status)
+ }
+
+ @Test
+ fun `getStatus should return NOT_YET_OPEN when before open time`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(false)
+ setOpenTime(now.plusDays(1))
+ setClosedTime(now.plusDays(2))
+ setStartTime(now.plusDays(3))
+ setEndTime(now.plusDays(4))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.NOT_YET_OPEN, status)
+ }
+
+ @Test
+ fun `getStatus should return REGISTRATIONS_OPENED when between open and closed`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(false)
+ setOpenTime(now.minusDays(1))
+ setClosedTime(now.plusDays(1))
+ setStartTime(now.plusDays(2))
+ setEndTime(now.plusDays(3))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.REGISTRATIONS_OPENED, status)
+ }
+
+ @Test
+ fun `getStatus should return REGISTRATIONS_COMPLETED when between closed and start`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(false)
+ setOpenTime(now.minusDays(3))
+ setClosedTime(now.minusDays(2))
+ setStartTime(now.plusDays(1))
+ setEndTime(now.plusDays(2))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.REGISTRATIONS_COMPLETED, status)
+ }
+
+ @Test
+ fun `getStatus should return STARTED when between start and end`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(false)
+ setOpenTime(now.minusDays(4))
+ setClosedTime(now.minusDays(3))
+ setStartTime(now.minusDays(2))
+ setEndTime(now.plusDays(1))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.STARTED, status)
+ }
+
+ @Test
+ fun `getStatus should return COMPLETED when after end time`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(false)
+ setOpenTime(now.minusDays(5))
+ setClosedTime(now.minusDays(4))
+ setStartTime(now.minusDays(3))
+ setEndTime(now.minusDays(2))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.COMPLETED, status)
+ }
+
+ @Test
+ fun `getStatus should prioritize CANCELLED over other statuses`() {
+ val now = LocalDateTime.now()
+ val registrable = TestRegistrable().apply {
+ setCancelled(true)
+ setOpenTime(now.minusDays(1))
+ setClosedTime(now.plusDays(1))
+ setStartTime(now.plusDays(2))
+ setEndTime(now.plusDays(3))
+ }
+
+ val status = registrable.getStatus()
+
+ assertEquals(RegistrableStatus.CANCELLED, status)
+ }
+
+ @Test
+ fun `RegistrableStatus valueOf should return correct status`() {
+ assertEquals(RegistrableStatus.NOT_YET_OPEN, RegistrableStatus.valueOf("NOT_YET_OPEN"))
+ assertEquals(RegistrableStatus.REGISTRATIONS_OPENED, RegistrableStatus.valueOf("REGISTRATIONS_OPENED"))
+ assertEquals(RegistrableStatus.REGISTRATIONS_COMPLETED, RegistrableStatus.valueOf("REGISTRATIONS_COMPLETED"))
+ assertEquals(RegistrableStatus.STARTED, RegistrableStatus.valueOf("STARTED"))
+ assertEquals(RegistrableStatus.COMPLETED, RegistrableStatus.valueOf("COMPLETED"))
+ assertEquals(RegistrableStatus.CANCELLED, RegistrableStatus.valueOf("CANCELLED"))
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/entity/UserEntityTest.kt b/src/test/kotlin/be/sgl/backend/entity/UserEntityTest.kt
new file mode 100644
index 0000000..2acd087
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/entity/UserEntityTest.kt
@@ -0,0 +1,261 @@
+package be.sgl.backend.entity
+
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+
+class UserEntityTest {
+
+ @Test
+ fun `getFullName should combine firstName and name`() {
+ val user = User().apply {
+ firstName = "John"
+ name = "Doe"
+ email = "john@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ assertEquals("John Doe", user.getFullName())
+ }
+
+ @Test
+ fun `getAge should calculate age correctly`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 6, 15)
+ }
+
+ val age = user.getAge(LocalDate.of(2024, 6, 15))
+
+ assertEquals(24, age)
+ }
+
+ @Test
+ fun `getAge should calculate age correctly before birthday`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 6, 15)
+ }
+
+ val age = user.getAge(LocalDate.of(2024, 6, 14))
+
+ assertEquals(23, age) // Still 23, birthday is tomorrow
+ }
+
+ @Test
+ fun `getAge should calculate age correctly after birthday`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 6, 15)
+ }
+
+ val age = user.getAge(LocalDate.of(2024, 6, 16))
+
+ assertEquals(24, age) // Already 24, birthday was yesterday
+ }
+
+ @Test
+ fun `getAge with current date should calculate current age`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.now().minusYears(25)
+ }
+
+ val age = user.getAge()
+
+ assertEquals(25, age)
+ }
+
+ @Test
+ fun `getHomeAddress should return postal address`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ val homeAddress = Address().apply {
+ street = "Home Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ postalAdress = true
+ }
+
+ val otherAddress = Address().apply {
+ street = "Other Street"
+ number = "456"
+ zipcode = "2000"
+ town = "Antwerp"
+ country = "BE"
+ postalAdress = false
+ }
+
+ user.addresses.add(otherAddress)
+ user.addresses.add(homeAddress)
+
+ val result = user.getHomeAddress()
+
+ assertNotNull(result)
+ assertEquals("Home Street", result?.street)
+ assertEquals("123", result?.number)
+ }
+
+ @Test
+ fun `getHomeAddress should return null when no postal address`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ val address = Address().apply {
+ street = "Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ postalAdress = false
+ }
+
+ user.addresses.add(address)
+
+ val result = user.getHomeAddress()
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `user should have default values`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ assertNull(user.username)
+ assertNull(user.externalId)
+ assertNull(user.customerId)
+ assertNull(user.memberId)
+ assertEquals(Sex.UNKNOWN, user.sex)
+ assertEquals(0, user.ageDeviation)
+ assertFalse(user.hasReduction)
+ assertFalse(user.hasHandicap)
+ assertNotNull(user.addresses)
+ assertNotNull(user.contacts)
+ assertNotNull(user.roles)
+ }
+
+ @Test
+ fun `user should allow setting all optional fields`() {
+ val user = User().apply {
+ username = "johndoe"
+ externalId = "ext-123"
+ customerId = "cust-456"
+ memberId = "mem-789"
+ firstName = "John"
+ name = "Doe"
+ email = "john@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ sex = Sex.MALE
+ ageDeviation = 1
+ image = "profile.jpg"
+ mobile = "0123456789"
+ nis = "12345678901"
+ accountNo = "BE12345678901234"
+ hasReduction = true
+ hasHandicap = false
+ }
+
+ assertEquals("johndoe", user.username)
+ assertEquals("ext-123", user.externalId)
+ assertEquals("cust-456", user.customerId)
+ assertEquals("mem-789", user.memberId)
+ assertEquals("John", user.firstName)
+ assertEquals("Doe", user.name)
+ assertEquals("john@example.com", user.email)
+ assertEquals(Sex.MALE, user.sex)
+ assertEquals(1, user.ageDeviation)
+ assertEquals("profile.jpg", user.image)
+ assertEquals("0123456789", user.mobile)
+ assertEquals("12345678901", user.nis)
+ assertEquals("BE12345678901234", user.accountNo)
+ assertTrue(user.hasReduction)
+ assertFalse(user.hasHandicap)
+ }
+
+ @Test
+ fun `user addresses should be mutable list`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ val address1 = Address().apply {
+ street = "Street 1"
+ number = "1"
+ zipcode = "1000"
+ town = "City 1"
+ country = "BE"
+ }
+
+ val address2 = Address().apply {
+ street = "Street 2"
+ number = "2"
+ zipcode = "2000"
+ town = "City 2"
+ country = "BE"
+ }
+
+ user.addresses.add(address1)
+ user.addresses.add(address2)
+
+ assertEquals(2, user.addresses.size)
+ assertEquals("Street 1", user.addresses[0].street)
+ assertEquals("Street 2", user.addresses[1].street)
+ }
+
+ @Test
+ fun `user contacts should be mutable list`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ assertTrue(user.contacts.isEmpty())
+
+ // Contacts can be added
+ assertEquals(0, user.contacts.size)
+ }
+
+ @Test
+ fun `staffData should be initialized`() {
+ val user = User().apply {
+ firstName = "Test"
+ name = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+
+ assertNotNull(user.staffData)
+ assertEquals(user, user.staffData.user)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/mapper/MapperIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/mapper/MapperIntegrationTest.kt
new file mode 100644
index 0000000..16cd18f
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/mapper/MapperIntegrationTest.kt
@@ -0,0 +1,227 @@
+package be.sgl.backend.mapper
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.AddressDTO
+import be.sgl.backend.dto.BranchDTO
+import be.sgl.backend.entity.Address
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.util.IntegrationTest
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+class MapperIntegrationTest {
+
+ @Autowired
+ private lateinit var addressMapper: AddressMapper
+
+ @Autowired
+ private lateinit var branchMapper: BranchMapper
+
+ @Test
+ fun `AddressMapper should map entity to DTO correctly`() {
+ val address = Address().apply {
+ street = "Test Street"
+ number = "123"
+ subPremise = "A"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ description = "Main office"
+ }
+
+ val dto = addressMapper.toDto(address)
+
+ assertEquals("Test Street", dto.street)
+ assertEquals("123", dto.number)
+ assertEquals("A", dto.subPremise)
+ assertEquals("1000", dto.zipcode)
+ assertEquals("Brussels", dto.town)
+ assertEquals("BE", dto.country)
+ assertEquals("Main office", dto.description)
+ }
+
+ @Test
+ fun `AddressMapper should map DTO to entity correctly`() {
+ val dto = AddressDTO(
+ id = null,
+ street = "DTO Street",
+ number = "456",
+ subPremise = "B",
+ zipcode = "2000",
+ town = "Antwerp",
+ country = "BE",
+ description = "Branch office"
+ )
+
+ val entity = addressMapper.toEntity(dto)
+
+ assertEquals("DTO Street", entity.street)
+ assertEquals("456", entity.number)
+ assertEquals("B", entity.subPremise)
+ assertEquals("2000", entity.zipcode)
+ assertEquals("Antwerp", entity.town)
+ assertEquals("BE", entity.country)
+ assertEquals("Branch office", entity.description)
+ }
+
+ @Test
+ fun `AddressMapper should handle null subPremise and description`() {
+ val address = Address().apply {
+ street = "Simple Street"
+ number = "1"
+ subPremise = null
+ zipcode = "3000"
+ town = "Leuven"
+ country = "BE"
+ description = null
+ }
+
+ val dto = addressMapper.toDto(address)
+
+ assertNull(dto.subPremise)
+ assertNull(dto.description)
+
+ val backToEntity = addressMapper.toEntity(dto)
+
+ assertNull(backToEntity.subPremise)
+ assertNull(backToEntity.description)
+ }
+
+ @Test
+ fun `BranchMapper should map entity to DTO correctly`() {
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.MALE
+ description = "Test description"
+ law = "Test law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val dto = branchMapper.toDto(branch)
+
+ assertEquals("Test Branch", dto.name)
+ assertEquals("test@example.com", dto.email)
+ assertEquals(6, dto.minimumAge)
+ assertEquals(8, dto.maximumAge)
+ assertEquals(Sex.MALE, dto.sex)
+ assertEquals("Test description", dto.description)
+ assertEquals("Test law", dto.law)
+ assertEquals("test.jpg", dto.image)
+ assertEquals(BranchStatus.ACTIVE, dto.status)
+ assertEquals("Leader", dto.staffTitle)
+ }
+
+ @Test
+ fun `BranchMapper should map DTO to entity correctly`() {
+ val dto = BranchDTO(
+ id = null,
+ name = "DTO Branch",
+ email = "dto@example.com",
+ minimumAge = 9,
+ maximumAge = 11,
+ sex = Sex.FEMALE,
+ description = "DTO description",
+ law = "DTO law",
+ image = "dto.jpg",
+ status = BranchStatus.MEMBER,
+ staffTitle = "Coach",
+ staff = emptyList()
+ )
+
+ val entity = branchMapper.toEntity(dto)
+
+ assertEquals("DTO Branch", entity.name)
+ assertEquals("dto@example.com", entity.email)
+ assertEquals(9, entity.minimumAge)
+ assertEquals(11, entity.maximumAge)
+ assertEquals(Sex.FEMALE, entity.sex)
+ assertEquals("DTO description", entity.description)
+ assertEquals("DTO law", entity.law)
+ assertEquals("dto.jpg", entity.image)
+ assertEquals(BranchStatus.MEMBER, entity.status)
+ assertEquals("Coach", entity.staffTitle)
+ }
+
+ @Test
+ fun `BranchMapper toBaseDto should map correctly`() {
+ val branch = Branch().apply {
+ name = "Base Branch"
+ email = "base@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Base description"
+ law = "Base law"
+ image = "base.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val baseDto = branchMapper.toBaseDto(branch)
+
+ assertEquals("Base Branch", baseDto.name)
+ assertEquals("Base description", baseDto.description)
+ assertEquals("base.jpg", baseDto.image)
+ assertEquals(6, baseDto.minimumAge)
+ assertEquals(8, baseDto.maximumAge)
+ assertEquals(Sex.UNKNOWN, baseDto.sex)
+ }
+
+ @Test
+ fun `mappers should be thread-safe for concurrent operations`() {
+ val addresses = (1..10).map { i ->
+ Address().apply {
+ street = "Street $i"
+ number = "$i"
+ zipcode = "${1000 + i}"
+ town = "Town $i"
+ country = "BE"
+ }
+ }
+
+ val dtos = addresses.parallelStream()
+ .map { addressMapper.toDto(it) }
+ .toList()
+
+ assertEquals(10, dtos.size)
+ dtos.forEachIndexed { index, dto ->
+ assertEquals("Street ${index + 1}", dto.street)
+ assertEquals("${index + 1}", dto.number)
+ }
+ }
+
+ @Test
+ fun `mappers should preserve data integrity in round-trip conversion`() {
+ val originalAddress = Address().apply {
+ street = "Round Trip Street"
+ number = "999"
+ subPremise = "Z"
+ zipcode = "9999"
+ town = "Round Trip City"
+ country = "BE"
+ description = "Round trip test"
+ }
+
+ val dto = addressMapper.toDto(originalAddress)
+ val backToEntity = addressMapper.toEntity(dto)
+
+ assertEquals(originalAddress.street, backToEntity.street)
+ assertEquals(originalAddress.number, backToEntity.number)
+ assertEquals(originalAddress.subPremise, backToEntity.subPremise)
+ assertEquals(originalAddress.zipcode, backToEntity.zipcode)
+ assertEquals(originalAddress.town, backToEntity.town)
+ assertEquals(originalAddress.country, backToEntity.country)
+ assertEquals(originalAddress.description, backToEntity.description)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/repository/BranchRepositoryIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/repository/BranchRepositoryIntegrationTest.kt
new file mode 100644
index 0000000..c256dc7
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/repository/BranchRepositoryIntegrationTest.kt
@@ -0,0 +1,330 @@
+package be.sgl.backend.repository
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.calendar.Calendar
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.repository.calendar.CalendarRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class BranchRepositoryIntegrationTest {
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var calendarRepository: CalendarRepository
+
+ @BeforeEach
+ fun setup() {
+ branchRepository.deleteAll()
+ calendarRepository.deleteAll()
+ }
+
+ @Test
+ fun `getBranchesWithCalendar should return branches that have calendars`() {
+ val branchWithCalendar = Branch().apply {
+ name = "Branch with Calendar"
+ email = "with@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Has calendar"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ val savedBranch = branchRepository.save(branchWithCalendar)
+
+ val calendar = Calendar().apply {
+ name = "Test Calendar"
+ branch = savedBranch
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ calendarRepository.save(calendar)
+ calendarRepository.flush()
+
+ val branchWithoutCalendar = Branch().apply {
+ name = "Branch without Calendar"
+ email = "without@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "No calendar"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branchWithoutCalendar)
+ branchRepository.flush()
+
+ val branches = branchRepository.getBranchesWithCalendar()
+
+ assertTrue(branches.any { it.name == "Branch with Calendar" })
+ assertFalse(branches.any { it.name == "Branch without Calendar" })
+ }
+
+ @Test
+ fun `getVisibleBranches should return non-hidden branches`() {
+ val activeBranch = Branch().apply {
+ name = "Active Branch"
+ email = "active@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Active"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val memberBranch = Branch().apply {
+ name = "Member Branch"
+ email = "member@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Member"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.MEMBER
+ staffTitle = "Leader"
+ }
+
+ val passiveBranch = Branch().apply {
+ name = "Passive Branch"
+ email = "passive@example.com"
+ minimumAge = 12
+ maximumAge = 14
+ sex = Sex.UNKNOWN
+ description = "Passive"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.PASSIVE
+ staffTitle = "Leader"
+ }
+
+ val hiddenBranch = Branch().apply {
+ name = "Hidden Branch"
+ email = "hidden@example.com"
+ minimumAge = 15
+ maximumAge = 17
+ sex = Sex.UNKNOWN
+ description = "Hidden"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.HIDDEN
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(activeBranch)
+ branchRepository.save(memberBranch)
+ branchRepository.save(passiveBranch)
+ branchRepository.save(hiddenBranch)
+ branchRepository.flush()
+
+ val visibleBranches = branchRepository.getVisibleBranches()
+
+ assertTrue(visibleBranches.any { it.name == "Active Branch" })
+ assertTrue(visibleBranches.any { it.name == "Member Branch" })
+ assertTrue(visibleBranches.any { it.name == "Passive Branch" })
+ assertFalse(visibleBranches.any { it.name == "Hidden Branch" })
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should match age range exactly`() {
+ val branch = Branch().apply {
+ name = "Exact Age Branch"
+ email = "exact@example.com"
+ minimumAge = 10
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Ages 10-12"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branch)
+ branchRepository.flush()
+
+ val matchesMin = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 10)
+ val matchesMax = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 12)
+ val matchesMiddle = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 11)
+ val tooYoung = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 9)
+ val tooOld = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 13)
+
+ assertTrue(matchesMin.any { it.name == "Exact Age Branch" })
+ assertTrue(matchesMax.any { it.name == "Exact Age Branch" })
+ assertTrue(matchesMiddle.any { it.name == "Exact Age Branch" })
+ assertFalse(tooYoung.any { it.name == "Exact Age Branch" })
+ assertFalse(tooOld.any { it.name == "Exact Age Branch" })
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should respect sex restrictions`() {
+ val boysBranch = Branch().apply {
+ name = "Boys Only"
+ email = "boys@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.MALE
+ description = "For boys"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val girlsBranch = Branch().apply {
+ name = "Girls Only"
+ email = "girls@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.FEMALE
+ description = "For girls"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val mixedBranch = Branch().apply {
+ name = "Mixed"
+ email = "mixed@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "For all"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(boysBranch)
+ branchRepository.save(girlsBranch)
+ branchRepository.save(mixedBranch)
+ branchRepository.flush()
+
+ val boysMatches = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 7)
+ val girlsMatches = branchRepository.getPossibleBranchesForSexAndAge(Sex.FEMALE, 7)
+
+ // Boys should match boys branch and mixed branch
+ assertTrue(boysMatches.any { it.name == "Boys Only" })
+ assertFalse(boysMatches.any { it.name == "Girls Only" })
+ assertTrue(boysMatches.any { it.name == "Mixed" })
+
+ // Girls should match girls branch and mixed branch
+ assertFalse(girlsMatches.any { it.name == "Boys Only" })
+ assertTrue(girlsMatches.any { it.name == "Girls Only" })
+ assertTrue(girlsMatches.any { it.name == "Mixed" })
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should only return active branches`() {
+ val activeBranch = Branch().apply {
+ name = "Active"
+ email = "active@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Active"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val passiveBranch = Branch().apply {
+ name = "Passive"
+ email = "passive@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Passive"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.PASSIVE
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(activeBranch)
+ branchRepository.save(passiveBranch)
+ branchRepository.flush()
+
+ val matches = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 7)
+
+ assertTrue(matches.any { it.name == "Active" })
+ assertFalse(matches.any { it.name == "Passive" })
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should return empty list when no matches`() {
+ val branch = Branch().apply {
+ name = "Teenage Branch"
+ email = "teen@example.com"
+ minimumAge = 13
+ maximumAge = 17
+ sex = Sex.UNKNOWN
+ description = "Teens"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branch)
+ branchRepository.flush()
+
+ val matches = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 5)
+
+ assertTrue(matches.isEmpty())
+ }
+
+ @Test
+ fun `branches should be persistable with all properties`() {
+ val branch = Branch().apply {
+ name = "Complete Branch"
+ email = "complete@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.MALE
+ description = "Complete description"
+ law = "Complete law text"
+ image = "complete.jpg"
+ status = BranchStatus.MEMBER
+ staffTitle = "Head Leader"
+ }
+
+ val saved = branchRepository.save(branch)
+ branchRepository.flush()
+
+ val retrieved = branchRepository.findById(saved.id!!).get()
+
+ assertEquals("Complete Branch", retrieved.name)
+ assertEquals("complete@example.com", retrieved.email)
+ assertEquals(6, retrieved.minimumAge)
+ assertEquals(8, retrieved.maximumAge)
+ assertEquals(Sex.MALE, retrieved.sex)
+ assertEquals("Complete description", retrieved.description)
+ assertEquals("Complete law text", retrieved.law)
+ assertEquals("complete.jpg", retrieved.image)
+ assertEquals(BranchStatus.MEMBER, retrieved.status)
+ assertEquals("Head Leader", retrieved.staffTitle)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/repository/MembershipRepositoryIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/repository/MembershipRepositoryIntegrationTest.kt
new file mode 100644
index 0000000..35df864
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/repository/MembershipRepositoryIntegrationTest.kt
@@ -0,0 +1,276 @@
+package be.sgl.backend.repository
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.Membership
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.membership.MembershipPeriodRepository
+import be.sgl.backend.repository.membership.MembershipRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class MembershipRepositoryIntegrationTest {
+
+ @Autowired
+ private lateinit var membershipRepository: MembershipRepository
+
+ @Autowired
+ private lateinit var membershipPeriodRepository: MembershipPeriodRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ private lateinit var testPeriod: MembershipPeriod
+ private lateinit var testBranch: Branch
+ private lateinit var testUser: User
+
+ @BeforeEach
+ fun setup() {
+ membershipRepository.deleteAll()
+ membershipPeriodRepository.deleteAll()
+ branchRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Test law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+
+ testPeriod = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ }
+ testPeriod = membershipPeriodRepository.save(testPeriod)
+
+ testUser = User().apply {
+ name = "Test"
+ firstName = "User"
+ email = "test@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ }
+
+ @Test
+ fun `countByPeriod should return correct count`() {
+ // Create 3 memberships for the period
+ repeat(3) { index ->
+ val user = User().apply {
+ name = "User$index"
+ firstName = "Test"
+ email = "user$index@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ val membership = Membership(user, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership)
+ }
+ membershipRepository.flush()
+
+ val count = membershipRepository.countByPeriod(testPeriod)
+
+ assertEquals(3, count)
+ }
+
+ @Test
+ fun `countByPeriodAndBranch should return correct count for specific branch`() {
+ val otherBranch = Branch().apply {
+ name = "Other Branch"
+ email = "other@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Other"
+ law = "Other law"
+ image = "other.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ otherBranch = branchRepository.save(otherBranch)
+
+ // Create 2 memberships for test branch
+ repeat(2) { index ->
+ val user = User().apply {
+ name = "User$index"
+ firstName = "Test"
+ email = "user$index@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ val membership = Membership(user, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership)
+ }
+
+ // Create 1 membership for other branch
+ val otherUser = User().apply {
+ name = "OtherUser"
+ firstName = "Test"
+ email = "other@example.com"
+ birthdate = LocalDate.of(2005, 1, 1)
+ sex = Sex.MALE
+ }
+ membershipRepository.save(Membership(otherUser, testPeriod, otherBranch, 100.0))
+ membershipRepository.flush()
+
+ val count = membershipRepository.countByPeriodAndBranch(testPeriod, testBranch)
+
+ assertEquals(2, count)
+ }
+
+ @Test
+ fun `getMembershipsByUser should return all memberships for user`() {
+ val membership1 = Membership(testUser, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership1)
+
+ val oldPeriod = MembershipPeriod().apply {
+ start = LocalDate.of(2023, 9, 1)
+ end = LocalDate.of(2024, 8, 31)
+ price = 90.0
+ }
+ membershipPeriodRepository.save(oldPeriod)
+
+ val membership2 = Membership(testUser, oldPeriod, testBranch, 90.0)
+ membershipRepository.save(membership2)
+ membershipRepository.flush()
+
+ val memberships = membershipRepository.getMembershipsByUser(testUser)
+
+ assertEquals(2, memberships.size)
+ assertTrue(memberships.any { it.period.id == testPeriod.id })
+ assertTrue(memberships.any { it.period.id == oldPeriod.id })
+ }
+
+ @Test
+ fun `getCurrentByUser should return only current period membership`() {
+ // Set test period as current
+ testPeriod.start = LocalDate.now().minusMonths(2)
+ testPeriod.end = LocalDate.now().plusMonths(10)
+ membershipPeriodRepository.save(testPeriod)
+
+ val membership = Membership(testUser, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val currentMembership = membershipRepository.getCurrentByUser(testUser)
+
+ assertNotNull(currentMembership)
+ assertEquals(testPeriod.id, currentMembership?.period?.id)
+ }
+
+ @Test
+ fun `getCurrentByBranch should return current memberships for branch`() {
+ testPeriod.start = LocalDate.now().minusMonths(2)
+ testPeriod.end = LocalDate.now().plusMonths(10)
+ membershipPeriodRepository.save(testPeriod)
+
+ repeat(3) { index ->
+ val user = User().apply {
+ name = "User$index"
+ firstName = "Test"
+ email = "user$index@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ val membership = Membership(user, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership)
+ }
+ membershipRepository.flush()
+
+ val currentMemberships = membershipRepository.getCurrentByBranch(testBranch)
+
+ assertEquals(3, currentMemberships.size)
+ assertTrue(currentMemberships.all { it.branch.id == testBranch.id })
+ }
+
+ @Test
+ fun `existsByPeriodAndUser should return true when membership exists`() {
+ val membership = Membership(testUser, testPeriod, testBranch, 100.0)
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val exists = membershipRepository.existsByPeriodAndUser(testPeriod, testUser)
+
+ assertTrue(exists)
+ }
+
+ @Test
+ fun `existsByPeriodAndUser should return false when membership does not exist`() {
+ val otherUser = User().apply {
+ name = "Other"
+ firstName = "User"
+ email = "other@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+
+ val exists = membershipRepository.existsByPeriodAndUser(testPeriod, otherUser)
+
+ assertFalse(exists)
+ }
+
+ @Test
+ fun `getCurrent should return all current memberships`() {
+ testPeriod.start = LocalDate.now().minusMonths(2)
+ testPeriod.end = LocalDate.now().plusMonths(10)
+ membershipPeriodRepository.save(testPeriod)
+
+ val otherBranch = Branch().apply {
+ name = "Other Branch"
+ email = "other@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Other"
+ law = "Other law"
+ image = "other.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(otherBranch)
+
+ // Create memberships for different branches
+ val user1 = User().apply {
+ name = "User1"
+ firstName = "Test"
+ email = "user1@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ membershipRepository.save(Membership(user1, testPeriod, testBranch, 100.0))
+
+ val user2 = User().apply {
+ name = "User2"
+ firstName = "Test"
+ email = "user2@example.com"
+ birthdate = LocalDate.of(2005, 1, 1)
+ sex = Sex.MALE
+ }
+ membershipRepository.save(Membership(user2, testPeriod, otherBranch, 100.0))
+ membershipRepository.flush()
+
+ val currentMemberships = membershipRepository.getCurrent()
+
+ assertEquals(2, currentMemberships.size)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/BranchServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/BranchServiceIntegrationTest.kt
new file mode 100644
index 0000000..c4b9a07
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/BranchServiceIntegrationTest.kt
@@ -0,0 +1,282 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.BranchDTO
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class BranchServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var branchService: BranchService
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Test
+ fun `getAllBranches should return all branches`() {
+ val branch1 = Branch().apply {
+ name = "Branch 1"
+ email = "branch1@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test 1"
+ law = "Law 1"
+ image = "test1.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val branch2 = Branch().apply {
+ name = "Branch 2"
+ email = "branch2@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Test 2"
+ law = "Law 2"
+ image = "test2.jpg"
+ status = BranchStatus.HIDDEN
+ staffTitle = "Coach"
+ }
+
+ branchRepository.save(branch1)
+ branchRepository.save(branch2)
+ branchRepository.flush()
+
+ val branches = branchService.getAllBranches()
+
+ assertTrue(branches.size >= 2)
+ assertTrue(branches.any { it.name == "Branch 1" })
+ assertTrue(branches.any { it.name == "Branch 2" })
+ }
+
+ @Test
+ fun `getVisibleBranches should only return non-hidden branches`() {
+ val visibleBranch = Branch().apply {
+ name = "Visible Branch"
+ email = "visible@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Visible"
+ law = "Law"
+ image = "visible.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val hiddenBranch = Branch().apply {
+ name = "Hidden Branch"
+ email = "hidden@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Hidden"
+ law = "Law"
+ image = "hidden.jpg"
+ status = BranchStatus.HIDDEN
+ staffTitle = "Coach"
+ }
+
+ branchRepository.save(visibleBranch)
+ branchRepository.save(hiddenBranch)
+ branchRepository.flush()
+
+ val branches = branchService.getVisibleBranches()
+
+ assertTrue(branches.any { it.name == "Visible Branch" })
+ assertFalse(branches.any { it.name == "Hidden Branch" })
+ }
+
+ @Test
+ fun `getBranchDTOById should return branch with staff`() {
+ val branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ val savedBranch = branchRepository.save(branch)
+ branchRepository.flush()
+
+ val branchDTO = branchService.getBranchDTOById(savedBranch.id!!)
+
+ assertNotNull(branchDTO)
+ assertEquals("Test Branch", branchDTO.name)
+ assertEquals("test@example.com", branchDTO.email)
+ assertEquals(6, branchDTO.minimumAge)
+ assertEquals(8, branchDTO.maximumAge)
+ }
+
+ @Test
+ fun `mergeBranchDTOChanges should update branch properties`() {
+ val branch = Branch().apply {
+ name = "Original Branch"
+ email = "original@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.MALE
+ description = "Original description"
+ law = "Original law"
+ image = "original.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ val savedBranch = branchRepository.save(branch)
+ branchRepository.flush()
+
+ val updatedDTO = BranchDTO(
+ id = savedBranch.id,
+ name = "Updated Branch",
+ email = "updated@example.com",
+ minimumAge = 7,
+ maximumAge = 9,
+ sex = Sex.FEMALE,
+ description = "Updated description",
+ law = "Updated law",
+ image = "original.jpg",
+ status = BranchStatus.PASSIVE,
+ staffTitle = "Coach",
+ staff = emptyList()
+ )
+
+ val result = branchService.mergeBranchDTOChanges(savedBranch.id!!, updatedDTO)
+
+ assertEquals("Updated Branch", result.name)
+ assertEquals("updated@example.com", result.email)
+ assertEquals(7, result.minimumAge)
+ assertEquals(9, result.maximumAge)
+ assertEquals(Sex.FEMALE, result.sex)
+ assertEquals(BranchStatus.PASSIVE, result.status)
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should return matching branches`() {
+ val branch1 = Branch().apply {
+ name = "Young Boys"
+ email = "young@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.MALE
+ description = "For young boys"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ val branch2 = Branch().apply {
+ name = "Older Girls"
+ email = "older@example.com"
+ minimumAge = 12
+ maximumAge = 15
+ sex = Sex.FEMALE
+ description = "For older girls"
+ law = "Law"
+ image = "test2.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(branch1)
+ branchRepository.save(branch2)
+ branchRepository.flush()
+
+ val matchingBranches = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 7)
+
+ assertEquals(1, matchingBranches.size)
+ assertEquals("Young Boys", matchingBranches[0].name)
+ }
+
+ @Test
+ fun `getPossibleBranchesForSexAndAge should include UNKNOWN sex branches`() {
+ val mixedBranch = Branch().apply {
+ name = "Mixed Branch"
+ email = "mixed@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "For all"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(mixedBranch)
+ branchRepository.flush()
+
+ val matchingBoys = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 7)
+ val matchingGirls = branchRepository.getPossibleBranchesForSexAndAge(Sex.FEMALE, 7)
+
+ assertTrue(matchingBoys.any { it.name == "Mixed Branch" })
+ assertTrue(matchingGirls.any { it.name == "Mixed Branch" })
+ }
+
+ @Test
+ fun `branch should not match when age is outside range`() {
+ val branch = Branch().apply {
+ name = "Specific Age Branch"
+ email = "age@example.com"
+ minimumAge = 10
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "For specific ages"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(branch)
+ branchRepository.flush()
+
+ val tooYoung = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 8)
+ val tooOld = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 15)
+
+ assertFalse(tooYoung.any { it.name == "Specific Age Branch" })
+ assertFalse(tooOld.any { it.name == "Specific Age Branch" })
+ }
+
+ @Test
+ fun `hidden branches should not be returned for matching`() {
+ val hiddenBranch = Branch().apply {
+ name = "Hidden Branch"
+ email = "hidden@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Hidden"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.HIDDEN
+ staffTitle = "Leader"
+ }
+
+ branchRepository.save(hiddenBranch)
+ branchRepository.flush()
+
+ val matching = branchRepository.getPossibleBranchesForSexAndAge(Sex.MALE, 7)
+
+ assertFalse(matching.any { it.name == "Hidden Branch" })
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/CalendarServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/CalendarServiceIntegrationTest.kt
new file mode 100644
index 0000000..6d45054
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/CalendarServiceIntegrationTest.kt
@@ -0,0 +1,364 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.CalendarPeriodDTO
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.calendar.Calendar
+import be.sgl.backend.entity.calendar.CalendarItem
+import be.sgl.backend.entity.calendar.CalendarPeriod
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.calendar.CalendarItemRepository
+import be.sgl.backend.repository.calendar.CalendarPeriodRepository
+import be.sgl.backend.repository.calendar.CalendarRepository
+import be.sgl.backend.service.exception.CalendarPeriodNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.DayOfWeek
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class CalendarServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var calendarService: CalendarService
+
+ @Autowired
+ private lateinit var periodRepository: CalendarPeriodRepository
+
+ @Autowired
+ private lateinit var calendarRepository: CalendarRepository
+
+ @Autowired
+ private lateinit var itemRepository: CalendarItemRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ private lateinit var testBranch: Branch
+
+ @BeforeEach
+ fun setup() {
+ itemRepository.deleteAll()
+ calendarRepository.deleteAll()
+ periodRepository.deleteAll()
+ branchRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+ }
+
+ @Test
+ fun `saveCalendarPeriodDTO should create period and calendars for all branches with calendars`() {
+ // Create another branch
+ val branch2 = Branch().apply {
+ name = "Branch 2"
+ email = "branch2@example.com"
+ minimumAge = 9
+ maximumAge = 11
+ sex = Sex.UNKNOWN
+ description = "Test 2"
+ law = "Law"
+ image = "test2.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branch2)
+
+ // Create calendar for first branch to mark it as "has calendar"
+ val existingPeriod = CalendarPeriod().apply {
+ name = "Existing Period"
+ start = LocalDate.of(2023, 9, 1)
+ end = LocalDate.of(2024, 8, 31)
+ }
+ val savedPeriod = periodRepository.save(existingPeriod)
+ calendarRepository.save(Calendar(savedPeriod, testBranch))
+ calendarRepository.flush()
+
+ val dto = CalendarPeriodDTO(
+ id = null,
+ name = "2024-2025",
+ start = LocalDate.of(2024, 9, 1),
+ end = LocalDate.of(2025, 8, 31)
+ )
+
+ val saved = calendarService.saveCalendarPeriodDTO(dto)
+
+ assertNotNull(saved.id)
+ assertEquals("2024-2025", saved.name)
+
+ // Should create calendar for branch with existing calendar
+ val calendars = calendarRepository.getCalendarsByPeriod(periodRepository.findById(saved.id!!).get())
+ assertEquals(1, calendars.size)
+ assertEquals(testBranch.id, calendars[0].branch.id)
+ }
+
+ @Test
+ fun `saveCalendarPeriodDTO should reject overlapping periods`() {
+ val existingPeriod = CalendarPeriod().apply {
+ name = "Existing Period"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ periodRepository.save(existingPeriod)
+
+ val overlappingDto = CalendarPeriodDTO(
+ id = null,
+ name = "Overlapping Period",
+ start = LocalDate.of(2025, 1, 1),
+ end = LocalDate.of(2025, 12, 31)
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ calendarService.saveCalendarPeriodDTO(overlappingDto)
+ }
+
+ assertTrue(exception.message!!.contains("overlaps with existing periods"))
+ }
+
+ @Test
+ fun `saveCalendarPeriodDTO should allow adjacent periods`() {
+ val existingPeriod = CalendarPeriod().apply {
+ name = "Existing Period"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ periodRepository.save(existingPeriod)
+
+ val adjacentDto = CalendarPeriodDTO(
+ id = null,
+ name = "Adjacent Period",
+ start = LocalDate.of(2025, 9, 1),
+ end = LocalDate.of(2026, 8, 31)
+ )
+
+ val saved = calendarService.saveCalendarPeriodDTO(adjacentDto)
+
+ assertNotNull(saved.id)
+ assertEquals("Adjacent Period", saved.name)
+ }
+
+ @Test
+ fun `deleteCalendarPeriod should remove all associated calendars and items`() {
+ val period = CalendarPeriod().apply {
+ name = "To Delete"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ val savedPeriod = periodRepository.save(period)
+
+ val calendar = Calendar(savedPeriod, testBranch)
+ val savedCalendar = calendarRepository.save(calendar)
+
+ val item = CalendarItem().apply {
+ title = "Test Item"
+ content = "Content"
+ start = LocalDate.of(2024, 10, 6).atTime(14, 0)
+ end = LocalDate.of(2024, 10, 6).atTime(16, 0)
+ calendars = mutableListOf(savedCalendar)
+ }
+ itemRepository.save(item)
+ itemRepository.flush()
+
+ calendarService.deleteCalendarPeriod(savedPeriod.id!!)
+
+ assertFalse(periodRepository.existsById(savedPeriod.id!!))
+ assertFalse(calendarRepository.existsById(savedCalendar.id!!))
+ // Item should be deleted because it's not linked to any other calendar
+ assertFalse(itemRepository.existsById(item.id!!))
+ }
+
+ @Test
+ fun `deleteCalendarPeriod should keep items linked to other calendars`() {
+ val period1 = CalendarPeriod().apply {
+ name = "Period 1"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ val savedPeriod1 = periodRepository.save(period1)
+
+ val period2 = CalendarPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ }
+ val savedPeriod2 = periodRepository.save(period2)
+
+ val calendar1 = calendarRepository.save(Calendar(savedPeriod1, testBranch))
+ val calendar2 = calendarRepository.save(Calendar(savedPeriod2, testBranch))
+
+ val sharedItem = CalendarItem().apply {
+ title = "Shared Item"
+ content = "Content"
+ start = LocalDate.of(2024, 10, 6).atTime(14, 0)
+ end = LocalDate.of(2024, 10, 6).atTime(16, 0)
+ calendars = mutableListOf(calendar1, calendar2)
+ }
+ val savedItem = itemRepository.save(sharedItem)
+ itemRepository.flush()
+
+ calendarService.deleteCalendarPeriod(savedPeriod1.id!!)
+
+ // Item should still exist, linked to calendar2
+ assertTrue(itemRepository.existsById(savedItem.id!!))
+ val remainingItem = itemRepository.findById(savedItem.id!!).get()
+ assertEquals(1, remainingItem.calendars.size)
+ assertEquals(calendar2.id, remainingItem.calendars[0].id)
+ }
+
+ @Test
+ fun `getCurrentCalendars should return calendars in current period`() {
+ val currentPeriod = CalendarPeriod().apply {
+ name = "Current"
+ start = LocalDate.now().minusMonths(2)
+ end = LocalDate.now().plusMonths(10)
+ }
+ val savedPeriod = periodRepository.save(currentPeriod)
+ calendarRepository.save(Calendar(savedPeriod, testBranch))
+ calendarRepository.flush()
+
+ val calendars = calendarService.getCurrentCalendars()
+
+ assertTrue(calendars.size >= 1)
+ assertTrue(calendars.any { it.period.name == "Current" })
+ }
+
+ @Test
+ fun `getCalendarDTOById with withDefaults should generate default Sunday items`() {
+ val period = CalendarPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2024, 10, 1) // Tuesday
+ end = LocalDate.of(2024, 10, 31)
+ }
+ val savedPeriod = periodRepository.save(period)
+ val calendar = calendarRepository.save(Calendar(savedPeriod, testBranch))
+ calendarRepository.flush()
+
+ val calendarDTO = calendarService.getCalendarDTOById(calendar.id!!, withDefaults = true)
+
+ // October 2024 has Sundays on: 6, 13, 20, 27
+ // Should generate default items for these dates
+ assertTrue(calendarDTO.items.size >= 4)
+ assertTrue(calendarDTO.items.any { it.start.toLocalDate().dayOfWeek == DayOfWeek.SUNDAY })
+ }
+
+ @Test
+ fun `getCalendarDTOById without withDefaults should not generate items`() {
+ val period = CalendarPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2024, 10, 1)
+ end = LocalDate.of(2024, 10, 31)
+ }
+ val savedPeriod = periodRepository.save(period)
+ val calendar = calendarRepository.save(Calendar(savedPeriod, testBranch))
+ calendarRepository.flush()
+
+ val calendarDTO = calendarService.getCalendarDTOById(calendar.id!!, withDefaults = false)
+
+ assertEquals(0, calendarDTO.items.size)
+ }
+
+ @Test
+ fun `getAllCalendarPeriods should return all periods`() {
+ val period1 = CalendarPeriod().apply {
+ name = "Period 1"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ val period2 = CalendarPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ }
+ periodRepository.save(period1)
+ periodRepository.save(period2)
+ periodRepository.flush()
+
+ val periods = calendarService.getAllCalendarPeriods()
+
+ assertTrue(periods.size >= 2)
+ assertTrue(periods.any { it.name == "Period 1" })
+ assertTrue(periods.any { it.name == "Period 2" })
+ }
+
+ @Test
+ fun `mergeCalendarPeriodDTOChanges should update period dates`() {
+ val period = CalendarPeriod().apply {
+ name = "Original"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ val saved = periodRepository.save(period)
+ periodRepository.flush()
+
+ val dto = CalendarPeriodDTO(
+ id = saved.id,
+ name = "Updated",
+ start = LocalDate.of(2024, 10, 1),
+ end = LocalDate.of(2025, 9, 30)
+ )
+
+ val updated = calendarService.mergeCalendarPeriodDTOChanges(saved.id!!, dto)
+
+ assertEquals("Updated", updated.name)
+ assertEquals(LocalDate.of(2024, 10, 1), updated.start)
+ assertEquals(LocalDate.of(2025, 9, 30), updated.end)
+ }
+
+ @Test
+ fun `mergeCalendarPeriodDTOChanges should reject overlaps with other periods`() {
+ val period1 = CalendarPeriod().apply {
+ name = "Period 1"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ }
+ val saved1 = periodRepository.save(period1)
+
+ val period2 = CalendarPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ }
+ val saved2 = periodRepository.save(period2)
+ periodRepository.flush()
+
+ val dto = CalendarPeriodDTO(
+ id = saved2.id,
+ name = "Period 2",
+ start = LocalDate.of(2025, 1, 1), // Overlaps with period1
+ end = LocalDate.of(2026, 8, 31)
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ calendarService.mergeCalendarPeriodDTOChanges(saved2.id!!, dto)
+ }
+
+ assertTrue(exception.message!!.contains("overlaps"))
+ }
+
+ @Test
+ fun `getCalendarPeriodDTOById should throw exception when not found`() {
+ assertThrows(CalendarPeriodNotFoundException::class.java) {
+ calendarService.getCalendarPeriodDTOById(999)
+ }
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/NewsItemServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/NewsItemServiceIntegrationTest.kt
new file mode 100644
index 0000000..03c53dc
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/NewsItemServiceIntegrationTest.kt
@@ -0,0 +1,128 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.NewsItem
+import be.sgl.backend.repository.NewsItemRepository
+import be.sgl.backend.service.exception.NewsItemNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class NewsItemServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var newsItemService: NewsItemService
+
+ @Autowired
+ private lateinit var newsItemRepository: NewsItemRepository
+
+ @BeforeEach
+ fun setup() {
+ newsItemRepository.deleteAll()
+ }
+
+ @Test
+ fun `getVisibleItems should only return visible news items`() {
+ val visibleItem = NewsItem().apply {
+ title = "Visible News"
+ content = "This is visible"
+ visible = true
+ }
+
+ val hiddenItem = NewsItem().apply {
+ title = "Hidden News"
+ content = "This is hidden"
+ visible = false
+ }
+
+ newsItemRepository.save(visibleItem)
+ newsItemRepository.save(hiddenItem)
+ newsItemRepository.flush()
+
+ val items = newsItemService.getVisibleItems()
+
+ assertTrue(items.any { it.title == "Visible News" })
+ assertFalse(items.any { it.title == "Hidden News" })
+ }
+
+ @Test
+ fun `getNewsItemDTOById should return item when exists`() {
+ val newsItem = NewsItem().apply {
+ title = "Test News"
+ content = "Test content"
+ visible = true
+ }
+ val saved = newsItemRepository.save(newsItem)
+ newsItemRepository.flush()
+
+ val result = newsItemService.getNewsItemDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals("Test News", result.title)
+ assertEquals("Test content", result.content)
+ }
+
+ @Test
+ fun `getNewsItemDTOById should throw exception when not found`() {
+ assertThrows(NewsItemNotFoundException::class.java) {
+ newsItemService.getNewsItemDTOById(999)
+ }
+ }
+
+ @Test
+ fun `deleteNewsItem should remove news item`() {
+ val newsItem = NewsItem().apply {
+ title = "To Delete"
+ content = "Will be deleted"
+ visible = true
+ }
+ val saved = newsItemRepository.save(newsItem)
+ newsItemRepository.flush()
+
+ newsItemService.deleteNewsItem(saved.id!!)
+
+ assertFalse(newsItemRepository.existsById(saved.id!!))
+ }
+
+ @Test
+ fun `deleteNewsItem should throw exception when not found`() {
+ assertThrows(NewsItemNotFoundException::class.java) {
+ newsItemService.deleteNewsItem(999)
+ }
+ }
+
+ @Test
+ fun `news items should be ordered by creation date`() {
+ val older = NewsItem().apply {
+ title = "Older News"
+ content = "Older content"
+ visible = true
+ }
+
+ val newer = NewsItem().apply {
+ title = "Newer News"
+ content = "Newer content"
+ visible = true
+ }
+
+ newsItemRepository.save(older)
+ Thread.sleep(10) // Ensure different timestamps
+ newsItemRepository.save(newer)
+ newsItemRepository.flush()
+
+ val items = newsItemService.getVisibleItems()
+
+ assertTrue(items.size >= 2)
+ // Newer items should come first
+ val newerIndex = items.indexOfFirst { it.title == "Newer News" }
+ val olderIndex = items.indexOfFirst { it.title == "Older News" }
+ assertTrue(newerIndex < olderIndex)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/RoleServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/RoleServiceIntegrationTest.kt
new file mode 100644
index 0000000..823d9c8
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/RoleServiceIntegrationTest.kt
@@ -0,0 +1,284 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.RoleDTO
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.user.Role
+import be.sgl.backend.entity.user.RoleLevel
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.RoleRepository
+import be.sgl.backend.service.exception.BranchNotFoundException
+import be.sgl.backend.service.exception.RoleNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class RoleServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var roleService: RoleService
+
+ @Autowired
+ private lateinit var roleRepository: RoleRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ private lateinit var testBranch: Branch
+
+ @BeforeEach
+ fun setup() {
+ roleRepository.deleteAll()
+ branchRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+ }
+
+ @Test
+ fun `getAllRoles should return all roles from database`() {
+ val role1 = Role().apply {
+ name = "Leader"
+ level = RoleLevel.STAFF
+ }
+ val role2 = Role().apply {
+ name = "Assistant"
+ level = RoleLevel.STAFF
+ }
+
+ roleRepository.save(role1)
+ roleRepository.save(role2)
+ roleRepository.flush()
+
+ val roles = roleService.getAllRoles()
+
+ assertTrue(roles.size >= 2)
+ assertTrue(roles.any { it.name == "Leader" })
+ assertTrue(roles.any { it.name == "Assistant" })
+ }
+
+ @Test
+ fun `saveRoleDTO should persist role to database`() {
+ val dto = RoleDTO(
+ id = null,
+ name = "New Role",
+ externalId = null,
+ backupExternalId = null,
+ branch = null,
+ staffBranch = null,
+ level = RoleLevel.STAFF
+ )
+
+ val saved = roleService.saveRoleDTO(dto)
+
+ assertNotNull(saved.id)
+ assertEquals("New Role", saved.name)
+ assertEquals(RoleLevel.STAFF, saved.level)
+
+ // Verify it's in the database
+ val fromDb = roleRepository.findById(saved.id!!).get()
+ assertEquals("New Role", fromDb.name)
+ }
+
+ @Test
+ fun `mergeRoleDTOChanges should update existing role`() {
+ val role = Role().apply {
+ name = "Original Role"
+ level = RoleLevel.STAFF
+ }
+ val savedRole = roleRepository.save(role)
+ roleRepository.flush()
+
+ val dto = RoleDTO(
+ id = savedRole.id,
+ name = "Updated Role",
+ externalId = "ext-123",
+ backupExternalId = "backup-456",
+ branch = null,
+ staffBranch = null,
+ level = RoleLevel.ADMIN
+ )
+
+ val updated = roleService.mergeRoleDTOChanges(savedRole.id!!, dto)
+
+ assertEquals("Updated Role", updated.name)
+ assertEquals("ext-123", updated.externalId)
+ assertEquals("backup-456", updated.backupExternalId)
+ assertEquals(RoleLevel.ADMIN, updated.level)
+
+ // Verify changes are persisted
+ val fromDb = roleRepository.findById(savedRole.id!!).get()
+ assertEquals("Updated Role", fromDb.name)
+ assertEquals(RoleLevel.ADMIN, fromDb.level)
+ }
+
+ @Test
+ fun `mergeRoleDTOChanges should update branch associations`() {
+ val role = Role().apply {
+ name = "Role with Branch"
+ level = RoleLevel.STAFF
+ }
+ val savedRole = roleRepository.save(role)
+ roleRepository.flush()
+
+ val dto = RoleDTO(
+ id = savedRole.id,
+ name = "Role with Branch",
+ externalId = null,
+ backupExternalId = null,
+ branch = be.sgl.backend.dto.BranchBaseDTO(
+ id = testBranch.id,
+ name = testBranch.name,
+ description = testBranch.description,
+ image = testBranch.image,
+ minimumAge = testBranch.minimumAge,
+ maximumAge = testBranch.maximumAge,
+ sex = testBranch.sex
+ ),
+ staffBranch = null,
+ level = RoleLevel.STAFF
+ )
+
+ val updated = roleService.mergeRoleDTOChanges(savedRole.id!!, dto)
+
+ assertNotNull(updated.branch)
+ assertEquals(testBranch.id, updated.branch?.id)
+ }
+
+ @Test
+ fun `deleteRole should remove role from database`() {
+ val role = Role().apply {
+ name = "To Delete"
+ level = RoleLevel.STAFF
+ }
+ val savedRole = roleRepository.save(role)
+ roleRepository.flush()
+
+ roleService.deleteRole(savedRole.id!!)
+
+ assertFalse(roleRepository.existsById(savedRole.id!!))
+ }
+
+ @Test
+ fun `deleteRole should throw exception when role not found`() {
+ assertThrows(RoleNotFoundException::class.java) {
+ roleService.deleteRole(999)
+ }
+ }
+
+ @Test
+ fun `mergeRoleDTOChanges should throw exception when role not found`() {
+ val dto = RoleDTO(
+ id = 999,
+ name = "Non-existent",
+ externalId = null,
+ backupExternalId = null,
+ branch = null,
+ staffBranch = null,
+ level = RoleLevel.STAFF
+ )
+
+ assertThrows(RoleNotFoundException::class.java) {
+ roleService.mergeRoleDTOChanges(999, dto)
+ }
+ }
+
+ @Test
+ fun `mergeRoleDTOChanges should throw exception when branch not found`() {
+ val role = Role().apply {
+ name = "Role"
+ level = RoleLevel.STAFF
+ }
+ val savedRole = roleRepository.save(role)
+ roleRepository.flush()
+
+ val dto = RoleDTO(
+ id = savedRole.id,
+ name = "Role",
+ externalId = null,
+ backupExternalId = null,
+ branch = be.sgl.backend.dto.BranchBaseDTO(
+ id = 999, // Non-existent branch
+ name = "Fake",
+ description = "Fake",
+ image = "fake.jpg",
+ minimumAge = 6,
+ maximumAge = 8,
+ sex = Sex.UNKNOWN
+ ),
+ staffBranch = null,
+ level = RoleLevel.STAFF
+ )
+
+ assertThrows(BranchNotFoundException::class.java) {
+ roleService.mergeRoleDTOChanges(savedRole.id!!, dto)
+ }
+ }
+
+ @Test
+ fun `saveRoleDTO should handle all role levels`() {
+ val levels = listOf(
+ RoleLevel.GUEST,
+ RoleLevel.STAFF,
+ RoleLevel.ADMIN
+ )
+
+ levels.forEach { level ->
+ val dto = RoleDTO(
+ id = null,
+ name = "Role for $level",
+ externalId = null,
+ backupExternalId = null,
+ branch = null,
+ staffBranch = null,
+ level = level
+ )
+
+ val saved = roleService.saveRoleDTO(dto)
+ assertEquals(level, saved.level)
+ }
+ }
+
+ @Test
+ fun `roles should persist with external IDs`() {
+ val dto = RoleDTO(
+ id = null,
+ name = "External Role",
+ externalId = "ext-abc-123",
+ backupExternalId = "backup-xyz-789",
+ branch = null,
+ staffBranch = null,
+ level = RoleLevel.STAFF
+ )
+
+ val saved = roleService.saveRoleDTO(dto)
+
+ assertEquals("ext-abc-123", saved.externalId)
+ assertEquals("backup-xyz-789", saved.backupExternalId)
+
+ // Verify persistence
+ val fromDb = roleRepository.findById(saved.id!!).get()
+ assertEquals("ext-abc-123", fromDb.externalId)
+ assertEquals("backup-xyz-789", fromDb.backupExternalId)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/SettingServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/SettingServiceIntegrationTest.kt
new file mode 100644
index 0000000..6309e61
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/SettingServiceIntegrationTest.kt
@@ -0,0 +1,161 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.setting.Setting
+import be.sgl.backend.entity.setting.SettingId
+import be.sgl.backend.repository.SettingRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class SettingServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var settingService: SettingService
+
+ @Autowired
+ private lateinit var settingRepository: SettingRepository
+
+ @BeforeEach
+ fun setup() {
+ settingRepository.deleteAll()
+ }
+
+ @Test
+ fun `get should return value from database`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Integration Test Calendar")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ val result = settingService.get(SettingId.CALENDAR_NAME)
+
+ assertEquals("Integration Test Calendar", result)
+ }
+
+ @Test
+ fun `get should return null when setting does not exist in database`() {
+ val result = settingService.get(SettingId.CALENDAR_NAME)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `getOrDefault with String should return value from database`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Database Calendar")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ val result = settingService.getOrDefault(SettingId.CALENDAR_NAME, "Default Calendar")
+
+ assertEquals("Database Calendar", result)
+ }
+
+ @Test
+ fun `getOrDefault with String should return default when not in database`() {
+ val result = settingService.getOrDefault(SettingId.CALENDAR_NAME, "Default Calendar")
+
+ assertEquals("Default Calendar", result)
+ }
+
+ @Test
+ fun `getOrDefault with Double should parse value from database`() {
+ val setting = Setting(SettingId.LATEST_DISPATCH_RATE, "42.75")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ val result = settingService.getOrDefault(SettingId.LATEST_DISPATCH_RATE, 0.0)
+
+ assertEquals(42.75, result)
+ }
+
+ @Test
+ fun `getOrDefault with Double should return default when not in database`() {
+ val result = settingService.getOrDefault(SettingId.LATEST_DISPATCH_RATE, 10.0)
+
+ assertEquals(10.0, result)
+ }
+
+ @Test
+ fun `update should persist new setting to database`() {
+ settingService.update(SettingId.CALENDAR_NAME, "Persisted Calendar")
+
+ val saved = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assertNotNull(saved)
+ assertEquals("Persisted Calendar", saved?.value)
+ }
+
+ @Test
+ fun `update should modify existing setting in database`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "Original")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ settingService.update(SettingId.CALENDAR_NAME, "Modified")
+
+ val updated = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assertEquals("Modified", updated?.value)
+ }
+
+ @Test
+ fun `update with null should delete setting from database`() {
+ val setting = Setting(SettingId.CALENDAR_NAME, "To Delete")
+ settingRepository.save(setting)
+ settingRepository.flush()
+
+ settingService.update(SettingId.CALENDAR_NAME, null)
+
+ val deleted = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assertNull(deleted)
+ }
+
+ @Test
+ fun `update should handle numeric values and persist correctly`() {
+ settingService.update(SettingId.LATEST_DISPATCH_RATE, 99.99)
+
+ val saved = settingRepository.findByIdOrNull(SettingId.LATEST_DISPATCH_RATE.name)
+ assertEquals("99.99", saved?.value)
+
+ // Should be able to retrieve as double
+ val retrieved = settingService.getOrDefault(SettingId.LATEST_DISPATCH_RATE, 0.0)
+ assertEquals(99.99, retrieved)
+ }
+
+ @Test
+ fun `update should handle boolean values and persist correctly`() {
+ settingService.update(SettingId.CALENDAR_NAME, true)
+
+ val saved = settingRepository.findByIdOrNull(SettingId.CALENDAR_NAME.name)
+ assertEquals("true", saved?.value)
+ }
+
+ @Test
+ fun `multiple updates should persist correctly`() {
+ settingService.update(SettingId.CALENDAR_NAME, "First")
+ settingService.update(SettingId.ORGANIZATION_NAME, "Org Name")
+ settingService.update(SettingId.REPRESENTATIVE_TITLE, "Title")
+
+ assertEquals("First", settingService.get(SettingId.CALENDAR_NAME))
+ assertEquals("Org Name", settingService.get(SettingId.ORGANIZATION_NAME))
+ assertEquals("Title", settingService.get(SettingId.REPRESENTATIVE_TITLE))
+ }
+
+ @Test
+ fun `settings should persist across service calls`() {
+ settingService.update(SettingId.CALENDAR_NAME, "Persistent Value")
+ settingRepository.flush()
+
+ // Simulate new service usage
+ val retrieved1 = settingService.get(SettingId.CALENDAR_NAME)
+ val retrieved2 = settingService.get(SettingId.CALENDAR_NAME)
+
+ assertEquals("Persistent Value", retrieved1)
+ assertEquals("Persistent Value", retrieved2)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/SettingServiceTest.kt b/src/test/kotlin/be/sgl/backend/service/SettingServiceTest.kt
new file mode 100644
index 0000000..584a5b2
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/SettingServiceTest.kt
@@ -0,0 +1,153 @@
+package be.sgl.backend.service
+
+import be.sgl.backend.entity.setting.Setting
+import be.sgl.backend.entity.setting.SettingId
+import be.sgl.backend.repository.SettingRepository
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.Mockito.*
+import org.mockito.MockitoAnnotations
+
+class SettingServiceTest {
+
+ @Mock
+ private lateinit var settingRepository: SettingRepository
+
+ @InjectMocks
+ private lateinit var settingService: SettingService
+
+ @BeforeEach
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ }
+
+ @Test
+ fun `get should return value when setting exists`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val setting = Setting(settingId, "Test Organization")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(setting)
+
+ val result = settingService.get(settingId)
+
+ assertEquals("Test Organization", result)
+ verify(settingRepository).findByIdOrNull(settingId.name)
+ }
+
+ @Test
+ fun `get should return null when setting does not exist`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(null)
+
+ val result = settingService.get(settingId)
+
+ assertNull(result)
+ verify(settingRepository).findByIdOrNull(settingId.name)
+ }
+
+ @Test
+ fun `getOrDefault with String default should return value when setting exists`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val setting = Setting(settingId, "Test Organization")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(setting)
+
+ val result = settingService.getOrDefault(settingId, "Default Name")
+
+ assertEquals("Test Organization", result)
+ }
+
+ @Test
+ fun `getOrDefault with String default should return default when setting does not exist`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(null)
+
+ val result = settingService.getOrDefault(settingId, "Default Name")
+
+ assertEquals("Default Name", result)
+ }
+
+ @Test
+ fun `getOrDefault with Double default should return value when setting exists`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val setting = Setting(settingId, "42.5")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(setting)
+
+ val result = settingService.getOrDefault(settingId, 0.0)
+
+ assertEquals(42.5, result)
+ }
+
+ @Test
+ fun `getOrDefault with Double default should return default when setting does not exist`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(null)
+
+ val result = settingService.getOrDefault(settingId, 10.0)
+
+ assertEquals(10.0, result)
+ }
+
+ @Test
+ fun `update should create new setting when it does not exist`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val newSetting = Setting(settingId, "New Organization")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(null)
+ `when`(settingRepository.save(any(Setting::class.java))).thenReturn(newSetting)
+
+ settingService.update(settingId, "New Organization")
+
+ verify(settingRepository).findByIdOrNull(settingId.name)
+ verify(settingRepository).save(any(Setting::class.java))
+ }
+
+ @Test
+ fun `update should update existing setting when it exists`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val existingSetting = Setting(settingId, "Old Organization")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(existingSetting)
+ `when`(settingRepository.save(existingSetting)).thenReturn(existingSetting)
+
+ settingService.update(settingId, "Updated Organization")
+
+ assertEquals("Updated Organization", existingSetting.value)
+ verify(settingRepository).save(existingSetting)
+ }
+
+ @Test
+ fun `update should delete setting when value is null`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+
+ settingService.update(settingId, null)
+
+ verify(settingRepository).deleteById(settingId.name)
+ verify(settingRepository, never()).save(any(Setting::class.java))
+ }
+
+ @Test
+ fun `update should handle numeric values`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val existingSetting = Setting(settingId, "0")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(existingSetting)
+ `when`(settingRepository.save(existingSetting)).thenReturn(existingSetting)
+
+ settingService.update(settingId, 42)
+
+ assertEquals("42", existingSetting.value)
+ verify(settingRepository).save(existingSetting)
+ }
+
+ @Test
+ fun `update should handle boolean values`() {
+ val settingId = SettingId.ORGANIZATION_NAME
+ val existingSetting = Setting(settingId, "false")
+ `when`(settingRepository.findByIdOrNull(settingId.name)).thenReturn(existingSetting)
+ `when`(settingRepository.save(existingSetting)).thenReturn(existingSetting)
+
+ settingService.update(settingId, true)
+
+ assertEquals("true", existingSetting.value)
+ verify(settingRepository).save(existingSetting)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/activity/ActivityRegistrationServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/activity/ActivityRegistrationServiceIntegrationTest.kt
new file mode 100644
index 0000000..58c62a5
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/activity/ActivityRegistrationServiceIntegrationTest.kt
@@ -0,0 +1,533 @@
+package be.sgl.backend.service.activity
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.Membership
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.registrable.activity.Activity
+import be.sgl.backend.entity.registrable.activity.ActivityRegistration
+import be.sgl.backend.entity.registrable.activity.ActivityRestriction
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.Sibling
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.activity.ActivityRegistrationRepository
+import be.sgl.backend.repository.activity.ActivityRepository
+import be.sgl.backend.repository.activity.ActivityRestrictionRepository
+import be.sgl.backend.repository.membership.MembershipPeriodRepository
+import be.sgl.backend.repository.membership.MembershipRepository
+import be.sgl.backend.repository.user.SiblingRepository
+import be.sgl.backend.repository.user.UserRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class ActivityRegistrationServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var registrationService: ActivityRegistrationService
+
+ @Autowired
+ private lateinit var activityRepository: ActivityRepository
+
+ @Autowired
+ private lateinit var registrationRepository: ActivityRegistrationRepository
+
+ @Autowired
+ private lateinit var restrictionRepository: ActivityRestrictionRepository
+
+ @Autowired
+ private lateinit var membershipRepository: MembershipRepository
+
+ @Autowired
+ private lateinit var membershipPeriodRepository: MembershipPeriodRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var userRepository: UserRepository
+
+ @Autowired
+ private lateinit var siblingRepository: SiblingRepository
+
+ private lateinit var testUser: User
+ private lateinit var testBranch: Branch
+ private lateinit var testActivity: Activity
+ private lateinit var testRestriction: ActivityRestriction
+ private lateinit var testPeriod: MembershipPeriod
+
+ @BeforeEach
+ fun setup() {
+ siblingRepository.deleteAll()
+ registrationRepository.deleteAll()
+ restrictionRepository.deleteAll()
+ activityRepository.deleteAll()
+ membershipRepository.deleteAll()
+ userRepository.deleteAll()
+ branchRepository.deleteAll()
+ membershipPeriodRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+
+ testUser = User().apply {
+ username = "testuser"
+ firstName = "Test"
+ name = "User"
+ email = "testuser@example.com"
+ birthdate = LocalDate.of(2014, 1, 1) // Age 10-11
+ sex = Sex.UNKNOWN
+ hasReduction = false
+ hasHandicap = false
+ }
+ testUser = userRepository.save(testUser)
+
+ testPeriod = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.now().minusMonths(2)
+ end = LocalDate.now().plusMonths(10)
+ basePrice = 100.0
+ }
+ testPeriod = membershipPeriodRepository.save(testPeriod)
+
+ // Create active membership for user
+ val membership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+
+ testActivity = Activity().apply {
+ name = "Test Activity"
+ description = "Test Description"
+ open = LocalDateTime.now().minusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ reductionFactor = 3
+ siblingReduction = 10.0
+ cancellable = true
+ sendConfirmation = true
+ sendCompleteConfirmation = false
+ }
+ testActivity = activityRepository.save(testActivity)
+
+ testRestriction = ActivityRestriction().apply {
+ name = "Test Restriction"
+ activity = testActivity
+ branch = testBranch
+ }
+ testRestriction = restrictionRepository.save(testRestriction)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should return open options when user has active membership`() {
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertTrue(status.activeMembership)
+ assertEquals(1, status.openOptions.size)
+ assertEquals(testRestriction.id, status.openOptions[0].id)
+ assertTrue(status.closedOptions.isEmpty())
+ assertNull(status.currentRegistration)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should return no active membership when user has no membership`() {
+ membershipRepository.deleteAll()
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertFalse(status.activeMembership)
+ assertTrue(status.openOptions.isEmpty())
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should apply reduction for user with hasReduction flag`() {
+ testUser.hasReduction = true
+ userRepository.save(testUser)
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertTrue(status.openOptions.isNotEmpty())
+ // Price should be divided by reductionFactor (3)
+ val expectedPrice = 50.0 / 3
+ assertEquals(expectedPrice, status.openOptions[0].alternativePrice)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should show closed options when global limit is reached`() {
+ testActivity.registrationLimit = 1
+ activityRepository.save(testActivity)
+
+ // Create another user and registration
+ val otherUser = User().apply {
+ username = "otheruser"
+ firstName = "Other"
+ name = "User"
+ email = "other@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(otherUser)
+
+ val registration = ActivityRegistration(testActivity, otherUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertTrue(status.openOptions.isEmpty())
+ assertEquals(1, status.closedOptions.size)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should show closed options when restriction limit is reached`() {
+ testRestriction.alternativeLimit = 1
+ restrictionRepository.save(testRestriction)
+
+ val otherUser = User().apply {
+ username = "otheruser"
+ firstName = "Other"
+ name = "User"
+ email = "other@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(otherUser)
+
+ val registration = ActivityRegistration(testActivity, otherUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertTrue(status.openOptions.isEmpty())
+ assertEquals(1, status.closedOptions.size)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should show closed options when branch limit is reached`() {
+ testActivity.branchLimits = mutableMapOf(testBranch.id!! to 1)
+ activityRepository.save(testActivity)
+
+ val otherUser = User().apply {
+ username = "otheruser"
+ firstName = "Other"
+ name = "User"
+ email = "other@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(otherUser)
+
+ // Create membership for other user in same branch
+ val membership = Membership(testPeriod, otherUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+
+ val registration = ActivityRegistration(testActivity, otherUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertTrue(status.openOptions.isEmpty())
+ assertEquals(1, status.closedOptions.size)
+ }
+
+ @Test
+ fun `getStatusForActivityAndUser should return current registration when already registered`() {
+ val registration = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertNotNull(status.currentRegistration)
+ assertEquals(registration.id, status.currentRegistration!!.id)
+ }
+
+ @Test
+ fun `getAllRegistrationsForActivity should return only paid registrations`() {
+ val paidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(paidReg)
+
+ val otherUser = User().apply {
+ username = "unpaiduser"
+ firstName = "Unpaid"
+ name = "User"
+ email = "unpaid@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(otherUser)
+
+ val unpaidReg = ActivityRegistration(testActivity, otherUser, testRestriction, 50.0, null).apply {
+ paid = false
+ }
+ registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val registrations = registrationService.getAllRegistrationsForActivity(testActivity.id!!)
+
+ assertEquals(1, registrations.size)
+ assertEquals(testUser.email, registrations[0].user.email)
+ }
+
+ @Test
+ fun `getAllRegistrationsForUser should return all registrations for user`() {
+ val reg1 = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(reg1)
+
+ val activity2 = Activity().apply {
+ name = "Activity 2"
+ description = "Test"
+ open = LocalDateTime.now().minusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 75.0
+ }
+ activityRepository.save(activity2)
+
+ val restriction2 = ActivityRestriction().apply {
+ name = "Restriction 2"
+ activity = activity2
+ branch = testBranch
+ }
+ restrictionRepository.save(restriction2)
+
+ val reg2 = ActivityRegistration(activity2, testUser, restriction2, 75.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(reg2)
+ registrationRepository.flush()
+
+ val registrations = registrationService.getAllRegistrationsForUser(testUser.username!!)
+
+ assertEquals(2, registrations.size)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should fail for unpaid registration`() {
+ val unpaidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = false
+ }
+ val saved = registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ assertEquals("Only a paid activity can be marked as completed!", exception.message)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should fail if activity hasn't started yet`() {
+ testActivity.start = LocalDateTime.now().plusDays(5)
+ activityRepository.save(testActivity)
+
+ val paidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ assertEquals("Registrations can only be completed starting one hour before the activity!", exception.message)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should succeed for paid registration near activity start`() {
+ testActivity.start = LocalDateTime.now().minusMinutes(30)
+ testActivity.end = LocalDateTime.now().plusHours(2)
+ activityRepository.save(testActivity)
+
+ val paidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ completed = false
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+
+ val updated = registrationRepository.findById(saved.id!!).get()
+ assertTrue(updated.completed)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should not fail if already completed`() {
+ testActivity.start = LocalDateTime.now().minusMinutes(30)
+ testActivity.end = LocalDateTime.now().plusHours(2)
+ activityRepository.save(testActivity)
+
+ val paidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ completed = true
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ assertDoesNotThrow {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ val registration = registrationRepository.findById(saved.id!!).get()
+ assertTrue(registration.completed)
+ }
+
+ @Test
+ fun `cancelRegistration should fail for unpaid registration`() {
+ val unpaidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = false
+ }
+ val saved = registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.cancelRegistration(saved.id!!)
+ }
+
+ assertEquals("Only a paid activity registration can be cancelled!", exception.message)
+ }
+
+ @Test
+ fun `cancelRegistration should fail when registrations are closed`() {
+ testActivity.closed = LocalDateTime.now().minusDays(1)
+ activityRepository.save(testActivity)
+
+ val paidReg = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.cancelRegistration(saved.id!!)
+ }
+
+ assertEquals("Cancellation is only possible when registrations are still open!", exception.message)
+ }
+
+ @Test
+ fun `price calculation should apply sibling reduction when sibling already registered`() {
+ // Create sibling user
+ val sibling = User().apply {
+ username = "sibling"
+ firstName = "Sibling"
+ name = "User"
+ email = "sibling@example.com"
+ birthdate = LocalDate.of(2012, 1, 1)
+ hasReduction = false
+ }
+ userRepository.save(sibling)
+
+ // Create sibling relationship
+ val siblingRelation = Sibling(testUser, sibling)
+ siblingRepository.save(siblingRelation)
+
+ // Register sibling for activity
+ val siblingRegistration = ActivityRegistration(testActivity, sibling, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ registrationRepository.save(siblingRegistration)
+ registrationRepository.flush()
+
+ // Get status for test user - should show reduced price
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ // Price should be reduced by siblingReduction (10.0) -> 50.0 - 10.0 = 40.0
+ // However, status shows alternativePrice which is the base price
+ // The reduction is applied during payment creation
+ assertTrue(status.openOptions.isNotEmpty())
+ }
+
+ @Test
+ fun `getActivityRegistrationDTOById should return registration when exists`() {
+ val registration = ActivityRegistration(testActivity, testUser, testRestriction, 50.0, null).apply {
+ paid = true
+ }
+ val saved = registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val result = registrationService.getActivityRegistrationDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals(testUser.email, result?.user?.email)
+ }
+
+ @Test
+ fun `getActivityRegistrationDTOById should return null when not found`() {
+ val result = registrationService.getActivityRegistrationDTOById(999)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `price calculation should handle additional data with JSONata`() {
+ testActivity.additionalFormRule = "nights * 15"
+ activityRepository.save(testActivity)
+
+ val registration = ActivityRegistration(
+ testActivity,
+ testUser,
+ testRestriction,
+ 50.0,
+ "{\"nights\":\"2\"}"
+ ).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ // Additional price should be 2 * 15 = 30.0, total with base price would be 80.0
+ val result = registrationService.getActivityRegistrationDTOById(registration.id!!)
+ assertNotNull(result)
+ }
+
+ @Test
+ fun `restriction with alternative price should use alternative price instead of base price`() {
+ testRestriction.alternativePrice = 25.0
+ restrictionRepository.save(testRestriction)
+
+ val status = registrationService.getStatusForActivityAndUser(testActivity.id!!, testUser.username!!)
+
+ assertEquals(25.0, status.openOptions[0].alternativePrice)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/activity/ActivityServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/activity/ActivityServiceIntegrationTest.kt
new file mode 100644
index 0000000..0fbc704
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/activity/ActivityServiceIntegrationTest.kt
@@ -0,0 +1,347 @@
+package be.sgl.backend.service.activity
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.ActivityDTO
+import be.sgl.backend.entity.Address
+import be.sgl.backend.entity.registrable.activity.Activity
+import be.sgl.backend.entity.registrable.activity.ActivityRegistration
+import be.sgl.backend.entity.registrable.activity.ActivityRestriction
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.activity.ActivityRegistrationRepository
+import be.sgl.backend.repository.activity.ActivityRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDateTime
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class ActivityServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var activityService: ActivityService
+
+ @Autowired
+ private lateinit var activityRepository: ActivityRepository
+
+ @Autowired
+ private lateinit var registrationRepository: ActivityRegistrationRepository
+
+ private lateinit var testActivity: Activity
+ private lateinit var testUser: User
+
+ @BeforeEach
+ fun setup() {
+ testUser = User().apply {
+ name = "Test"
+ firstName = "User"
+ email = "test@example.com"
+ birthdate = java.time.LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+
+ testActivity = Activity().apply {
+ name = "Test Activity"
+ description = "Test Description"
+ open = LocalDateTime.now().plusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ registrationLimit = 10
+ cancellable = true
+ sendConfirmation = true
+ sendCompleteConfirmation = false
+ }
+ }
+
+ @Test
+ fun `getAllActivities should return all activities with registration data`() {
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ val activities = activityService.getAllActivities()
+
+ assertNotNull(activities)
+ assertTrue(activities.any { it.activity.id == savedActivity.id })
+ }
+
+ @Test
+ fun `getVisibleActivities should only return non-cancelled activities`() {
+ val visibleActivity = testActivity
+ val cancelledActivity = Activity().apply {
+ name = "Cancelled Activity"
+ description = "Cancelled"
+ open = LocalDateTime.now().plusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ cancelled = true
+ }
+
+ activityRepository.save(visibleActivity)
+ activityRepository.save(cancelledActivity)
+ activityRepository.flush()
+
+ val activities = activityService.getVisibleActivities()
+
+ assertTrue(activities.any { it.name == "Test Activity" })
+ assertFalse(activities.any { it.name == "Cancelled Activity" })
+ }
+
+ @Test
+ fun `saveActivityDTO should validate open-closed-start-end sequence`() {
+ val dto = ActivityDTO(
+ id = null,
+ name = "Invalid Activity",
+ description = "Test",
+ open = LocalDateTime.now().plusDays(10),
+ closed = LocalDateTime.now().plusDays(5), // Closed before open!
+ start = LocalDateTime.now().plusDays(15),
+ end = LocalDateTime.now().plusDays(20),
+ price = 50.0,
+ reductionFactor = 3.0,
+ siblingReduction = 10.0,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null,
+ restrictions = emptyList()
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ activityService.saveActivityDTO(dto)
+ }
+
+ assertEquals("The closure of registrations should be after the opening of registrations!", exception.message)
+ }
+
+ @Test
+ fun `saveActivityDTO should validate closed before start`() {
+ val dto = ActivityDTO(
+ id = null,
+ name = "Invalid Activity",
+ description = "Test",
+ open = LocalDateTime.now().plusDays(5),
+ closed = LocalDateTime.now().plusDays(10),
+ start = LocalDateTime.now().plusDays(8), // Start before closed!
+ end = LocalDateTime.now().plusDays(20),
+ price = 50.0,
+ reductionFactor = 3.0,
+ siblingReduction = 10.0,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null,
+ restrictions = emptyList()
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ activityService.saveActivityDTO(dto)
+ }
+
+ assertEquals("The start date of an activity should be after the closure of registrations!", exception.message)
+ }
+
+ @Test
+ fun `mergeActivityDTOChanges should not allow editing cancelled activity`() {
+ testActivity.cancelled = true
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ val dto = ActivityDTO(
+ id = savedActivity.id,
+ name = "Updated Activity",
+ description = "Updated",
+ open = testActivity.open,
+ closed = testActivity.closed,
+ start = testActivity.start,
+ end = testActivity.end,
+ price = 60.0,
+ reductionFactor = 3.0,
+ siblingReduction = 10.0,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null,
+ restrictions = emptyList()
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ activityService.mergeActivityDTOChanges(savedActivity.id!!, dto)
+ }
+
+ assertEquals("A cancelled activity cannot be edited anymore!", exception.message)
+ }
+
+ @Test
+ fun `mergeActivityDTOChanges should allow full edit when not yet open`() {
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ val dto = ActivityDTO(
+ id = savedActivity.id,
+ name = "Updated Activity",
+ description = "Updated Description",
+ open = testActivity.open,
+ closed = testActivity.closed,
+ start = testActivity.start,
+ end = testActivity.end,
+ price = 75.0,
+ reductionFactor = 2.0,
+ siblingReduction = 15.0,
+ registrationLimit = 20,
+ address = null,
+ additionalForm = "{\"field\": \"value\"}",
+ additionalFormRule = "price + 10",
+ cancellable = false,
+ sendConfirmation = false,
+ sendCompleteConfirmation = true,
+ communicationCC = "cc@example.com",
+ restrictions = emptyList()
+ )
+
+ val updatedActivity = activityService.mergeActivityDTOChanges(savedActivity.id!!, dto)
+
+ assertEquals("Updated Activity", updatedActivity.name)
+ assertEquals(75.0, updatedActivity.price)
+ assertEquals(2.0, updatedActivity.reductionFactor)
+ assertEquals(15.0, updatedActivity.siblingReduction)
+ }
+
+ @Test
+ fun `mergeActivityDTOChanges should not allow lowering registration limit below current registrations`() {
+ // Create activity with limit
+ testActivity.registrationLimit = 10
+ testActivity.open = LocalDateTime.now().minusDays(5) // Already open
+ testActivity.closed = LocalDateTime.now().plusDays(5)
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ // Create 3 paid registrations
+ repeat(3) { index ->
+ val user = User().apply {
+ name = "User$index"
+ firstName = "Test"
+ email = "user$index@example.com"
+ birthdate = java.time.LocalDate.of(2010, 1, 1)
+ sex = Sex.MALE
+ }
+ val registration = ActivityRegistration(user, savedActivity, savedActivity.price).apply {
+ paid = true
+ }
+ registrationRepository.save(registration)
+ }
+ registrationRepository.flush()
+
+ val dto = ActivityDTO(
+ id = savedActivity.id,
+ name = savedActivity.name,
+ description = savedActivity.description,
+ open = savedActivity.open,
+ closed = savedActivity.closed,
+ start = savedActivity.start,
+ end = savedActivity.end,
+ price = savedActivity.price,
+ reductionFactor = 3.0,
+ siblingReduction = 10.0,
+ registrationLimit = 2, // Trying to lower below 3 registrations!
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null,
+ restrictions = emptyList()
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ activityService.mergeActivityDTOChanges(savedActivity.id!!, dto)
+ }
+
+ assertEquals("The registration limit cannot be lowered below the current registration count!", exception.message)
+ }
+
+ @Test
+ fun `cancelActivity should mark activity as cancelled and initiate refunds`() {
+ testActivity.open = LocalDateTime.now().minusDays(5)
+ testActivity.closed = LocalDateTime.now().plusDays(5)
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ // Create a paid registration
+ val registration = ActivityRegistration(testUser, savedActivity, savedActivity.price).apply {
+ paid = true
+ paymentId = "payment-123"
+ }
+ registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ activityService.cancelActivity(savedActivity.id!!)
+
+ val cancelledActivity = activityRepository.findById(savedActivity.id!!).get()
+ assertTrue(cancelledActivity.cancelled)
+ }
+
+ @Test
+ fun `cancelActivity should not allow cancelling already cancelled activity`() {
+ testActivity.cancelled = true
+ val savedActivity = activityRepository.save(testActivity)
+ activityRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ activityService.cancelActivity(savedActivity.id!!)
+ }
+
+ assertEquals("This activity is already cancelled!", exception.message)
+ }
+
+ @Test
+ fun `saveActivityDTO should create activity with address`() {
+ val address = Address().apply {
+ street = "Test Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+
+ val activityWithAddress = Activity().apply {
+ name = "Activity with Address"
+ description = "Test"
+ open = LocalDateTime.now().plusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ this.address = address
+ }
+
+ val saved = activityRepository.save(activityWithAddress)
+ val retrieved = activityService.getActivityDTOById(saved.id!!)
+
+ assertNotNull(retrieved.address)
+ assertEquals("Test Street", retrieved.address?.street)
+ assertEquals("123", retrieved.address?.number)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/event/EventRegistrationServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/event/EventRegistrationServiceIntegrationTest.kt
new file mode 100644
index 0000000..64eaacd
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/event/EventRegistrationServiceIntegrationTest.kt
@@ -0,0 +1,353 @@
+package be.sgl.backend.service.event
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.EventRegistrationAttemptData
+import be.sgl.backend.entity.registrable.event.Event
+import be.sgl.backend.entity.registrable.event.EventRegistration
+import be.sgl.backend.repository.event.EventRegistrationRepository
+import be.sgl.backend.repository.event.EventRepository
+import be.sgl.backend.service.exception.EventRegistrationNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDateTime
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class EventRegistrationServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var registrationService: EventRegistrationService
+
+ @Autowired
+ private lateinit var eventRepository: EventRepository
+
+ @Autowired
+ private lateinit var registrationRepository: EventRegistrationRepository
+
+ private lateinit var testEvent: Event
+
+ @BeforeEach
+ fun setup() {
+ registrationRepository.deleteAll()
+ eventRepository.deleteAll()
+
+ testEvent = Event().apply {
+ name = "Test Event"
+ description = "Test Description"
+ open = LocalDateTime.now().minusDays(1) // Already open
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ needsMobile = true
+ cancellable = true
+ sendConfirmation = true
+ sendCompleteConfirmation = false
+ }
+ testEvent = eventRepository.save(testEvent)
+ }
+
+ @Test
+ fun `createPaymentForEvent should fail without mobile when needsMobile is true`() {
+ val attempt = EventRegistrationAttemptData(
+ name = "Doe",
+ firstName = "John",
+ email = "john@example.com",
+ mobile = null, // Missing mobile
+ additionalData = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.createPaymentForEvent(testEvent.id!!, attempt, null)
+ }
+
+ assertEquals("No valid mobile number provided!", exception.message)
+ }
+
+ @Test
+ fun `createPaymentForEvent should succeed with mobile when needsMobile is true`() {
+ val attempt = EventRegistrationAttemptData(
+ name = "Doe",
+ firstName = "John",
+ email = "john@example.com",
+ mobile = "0123456789",
+ additionalData = null
+ )
+
+ val checkoutUrl = registrationService.createPaymentForEvent(testEvent.id!!, attempt, null)
+
+ assertNotNull(checkoutUrl)
+ assertTrue(checkoutUrl.isNotBlank())
+
+ // Verify registration was created
+ val registrations = registrationRepository.findAll()
+ assertEquals(1, registrations.size)
+ assertEquals("Doe", registrations[0].name)
+ assertEquals("john@example.com", registrations[0].email)
+ }
+
+ @Test
+ fun `createPaymentForEvent should calculate price with additional data`() {
+ testEvent.additionalFormRule = "nights * 10"
+ eventRepository.save(testEvent)
+
+ val attempt = EventRegistrationAttemptData(
+ name = "Doe",
+ firstName = "John",
+ email = "john@example.com",
+ mobile = "0123456789",
+ additionalData = "{\"nights\":\"3\"}"
+ )
+
+ registrationService.createPaymentForEvent(testEvent.id!!, attempt, null)
+
+ val registration = registrationRepository.findAll()[0]
+ assertEquals(80.0, registration.price) // 50 base + 30 (3 nights * 10)
+ }
+
+ @Test
+ fun `getAllRegistrationsForEvent should return only paid registrations`() {
+ val paidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "Paid"
+ firstName = "User"
+ email = "paid@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ }
+
+ val unpaidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "Unpaid"
+ firstName = "User"
+ email = "unpaid@example.com"
+ mobile = "0987654321"
+ price = testEvent.price
+ paid = false
+ }
+
+ registrationRepository.save(paidReg)
+ registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val registrations = registrationService.getAllRegistrationsForEvent(testEvent.id!!)
+
+ assertEquals(1, registrations.size)
+ assertEquals("paid@example.com", registrations[0].email)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should fail for unpaid registration`() {
+ val unpaidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = false
+ }
+ val saved = registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ assertEquals("Only a paid event can be marked as completed!", exception.message)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should fail if event hasn't started yet`() {
+ testEvent.start = LocalDateTime.now().plusDays(5) // Event in future
+ eventRepository.save(testEvent)
+
+ val paidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ assertEquals("Registrations can only be completed starting one hour before the event!", exception.message)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should succeed for paid registration near event start`() {
+ testEvent.start = LocalDateTime.now().minusMinutes(30) // Started 30 min ago
+ testEvent.end = LocalDateTime.now().plusHours(2)
+ eventRepository.save(testEvent)
+
+ val paidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ completed = false
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+
+ val updated = registrationRepository.findById(saved.id!!).get()
+ assertTrue(updated.completed)
+ }
+
+ @Test
+ fun `markRegistrationAsCompleted should not fail if already completed`() {
+ testEvent.start = LocalDateTime.now().minusMinutes(30)
+ testEvent.end = LocalDateTime.now().plusHours(2)
+ eventRepository.save(testEvent)
+
+ val paidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ completed = true // Already completed
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ // Should not throw exception, just log warning
+ assertDoesNotThrow {
+ registrationService.markRegistrationAsCompleted(saved.id!!)
+ }
+
+ val registration = registrationRepository.findById(saved.id!!).get()
+ assertTrue(registration.completed)
+ }
+
+ @Test
+ fun `cancelRegistration should fail for unpaid registration`() {
+ val unpaidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = false
+ }
+ val saved = registrationRepository.save(unpaidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.cancelRegistration(saved.id!!)
+ }
+
+ assertEquals("Only a paid event registration can be cancelled!", exception.message)
+ }
+
+ @Test
+ fun `cancelRegistration should fail when registrations are closed`() {
+ testEvent.closed = LocalDateTime.now().minusDays(1) // Already closed
+ eventRepository.save(testEvent)
+
+ val paidReg = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ }
+ val saved = registrationRepository.save(paidReg)
+ registrationRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ registrationService.cancelRegistration(saved.id!!)
+ }
+
+ assertEquals("Cancellation is only possible when registrations are still open!", exception.message)
+ }
+
+ @Test
+ fun `getEventRegistrationDTOById should return registration when exists`() {
+ val registration = EventRegistration().apply {
+ subscribable = testEvent
+ name = "User"
+ firstName = "Test"
+ email = "test@example.com"
+ mobile = "0123456789"
+ price = testEvent.price
+ paid = true
+ }
+ val saved = registrationRepository.save(registration)
+ registrationRepository.flush()
+
+ val result = registrationService.getEventRegistrationDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals("test@example.com", result?.email)
+ }
+
+ @Test
+ fun `getEventRegistrationDTOById should return null when not found`() {
+ val result = registrationService.getEventRegistrationDTOById(999)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `createPaymentForEvent should allow registration without mobile when not needed`() {
+ testEvent.needsMobile = false
+ eventRepository.save(testEvent)
+
+ val attempt = EventRegistrationAttemptData(
+ name = "Doe",
+ firstName = "John",
+ email = "john@example.com",
+ mobile = null, // No mobile, but that's OK now
+ additionalData = null
+ )
+
+ val checkoutUrl = registrationService.createPaymentForEvent(testEvent.id!!, attempt, null)
+
+ assertNotNull(checkoutUrl)
+ }
+
+ @Test
+ fun `createPaymentForEvent should handle additional data with complex expressions`() {
+ testEvent.additionalFormRule = "adults * 20 + children * 10 + (lunch ? 15 : 0)"
+ eventRepository.save(testEvent)
+
+ val attempt = EventRegistrationAttemptData(
+ name = "Doe",
+ firstName = "John",
+ email = "john@example.com",
+ mobile = "0123456789",
+ additionalData = "{\"adults\":\"2\",\"children\":\"3\",\"lunch\":\"true\"}"
+ )
+
+ registrationService.createPaymentForEvent(testEvent.id!!, attempt, null)
+
+ val registration = registrationRepository.findAll()[0]
+ // Base: 50 + (2*20 + 3*10 + 15) = 50 + (40 + 30 + 15) = 135
+ assertEquals(135.0, registration.price)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/event/EventServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/event/EventServiceIntegrationTest.kt
new file mode 100644
index 0000000..e385bcd
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/event/EventServiceIntegrationTest.kt
@@ -0,0 +1,384 @@
+package be.sgl.backend.service.event
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.EventDTO
+import be.sgl.backend.entity.registrable.event.Event
+import be.sgl.backend.entity.registrable.event.EventRegistration
+import be.sgl.backend.repository.event.EventRegistrationRepository
+import be.sgl.backend.repository.event.EventRepository
+import be.sgl.backend.service.exception.EventNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDateTime
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class EventServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var eventService: EventService
+
+ @Autowired
+ private lateinit var eventRepository: EventRepository
+
+ @Autowired
+ private lateinit var registrationRepository: EventRegistrationRepository
+
+ private lateinit var testEvent: Event
+
+ @BeforeEach
+ fun setup() {
+ registrationRepository.deleteAll()
+ eventRepository.deleteAll()
+
+ testEvent = Event().apply {
+ name = "Test Event"
+ description = "Test Description"
+ open = LocalDateTime.now().plusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ needsMobile = true
+ cancellable = true
+ sendConfirmation = true
+ sendCompleteConfirmation = false
+ }
+ }
+
+ @Test
+ fun `getAllEvents should return all events with registration data`() {
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val events = eventService.getAllEvents()
+
+ assertNotNull(events)
+ assertTrue(events.any { it.event.id == saved.id })
+ }
+
+ @Test
+ fun `getVisibleEvents should only return non-cancelled events`() {
+ val visibleEvent = testEvent
+ val cancelledEvent = Event().apply {
+ name = "Cancelled Event"
+ description = "Cancelled"
+ open = LocalDateTime.now().plusDays(1)
+ closed = LocalDateTime.now().plusDays(7)
+ start = LocalDateTime.now().plusDays(10)
+ end = LocalDateTime.now().plusDays(12)
+ price = 50.0
+ cancelled = true
+ }
+
+ eventRepository.save(visibleEvent)
+ eventRepository.save(cancelledEvent)
+ eventRepository.flush()
+
+ val events = eventService.getVisibleEvents()
+
+ assertTrue(events.any { it.name == "Test Event" })
+ assertFalse(events.any { it.name == "Cancelled Event" })
+ }
+
+ @Test
+ fun `saveEventDTO should validate date sequence`() {
+ val dto = EventDTO(
+ id = null,
+ name = "Invalid Event",
+ description = "Test",
+ open = LocalDateTime.now().plusDays(10),
+ closed = LocalDateTime.now().plusDays(5), // Closed before open!
+ start = LocalDateTime.now().plusDays(15),
+ end = LocalDateTime.now().plusDays(20),
+ price = 50.0,
+ needsMobile = true,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.saveEventDTO(dto)
+ }
+
+ assertEquals("The closure of registrations should be after the opening of registrations!", exception.message)
+ }
+
+ @Test
+ fun `saveEventDTO should validate closed before start`() {
+ val dto = EventDTO(
+ id = null,
+ name = "Invalid Event",
+ description = "Test",
+ open = LocalDateTime.now().plusDays(5),
+ closed = LocalDateTime.now().plusDays(10),
+ start = LocalDateTime.now().plusDays(8), // Start before closed!
+ end = LocalDateTime.now().plusDays(20),
+ price = 50.0,
+ needsMobile = true,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.saveEventDTO(dto)
+ }
+
+ assertEquals("The start date of an event should be after the closure of registrations!", exception.message)
+ }
+
+ @Test
+ fun `saveEventDTO should validate start before end`() {
+ val dto = EventDTO(
+ id = null,
+ name = "Invalid Event",
+ description = "Test",
+ open = LocalDateTime.now().plusDays(5),
+ closed = LocalDateTime.now().plusDays(10),
+ start = LocalDateTime.now().plusDays(20),
+ end = LocalDateTime.now().plusDays(15), // End before start!
+ price = 50.0,
+ needsMobile = true,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.saveEventDTO(dto)
+ }
+
+ assertEquals("The start date of an event should be before its end date!", exception.message)
+ }
+
+ @Test
+ fun `mergeEventDTOChanges should not allow editing cancelled event`() {
+ testEvent.cancelled = true
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val dto = EventDTO(
+ id = saved.id,
+ name = "Updated Event",
+ description = "Updated",
+ open = testEvent.open,
+ closed = testEvent.closed,
+ start = testEvent.start,
+ end = testEvent.end,
+ price = 60.0,
+ needsMobile = true,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.mergeEventDTOChanges(saved.id!!, dto)
+ }
+
+ assertEquals("A cancelled event cannot be edited anymore!", exception.message)
+ }
+
+ @Test
+ fun `mergeEventDTOChanges should allow full edit when not yet open`() {
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val dto = EventDTO(
+ id = saved.id,
+ name = "Updated Event",
+ description = "Updated Description",
+ open = testEvent.open,
+ closed = testEvent.closed,
+ start = testEvent.start,
+ end = testEvent.end,
+ price = 75.0,
+ needsMobile = false,
+ registrationLimit = 100,
+ address = null,
+ additionalForm = "{\"field\": \"value\"}",
+ additionalFormRule = "price + 10",
+ cancellable = false,
+ sendConfirmation = false,
+ sendCompleteConfirmation = true,
+ communicationCC = "cc@example.com"
+ )
+
+ val updated = eventService.mergeEventDTOChanges(saved.id!!, dto)
+
+ assertEquals("Updated Event", updated.name)
+ assertEquals(75.0, updated.price)
+ assertFalse(updated.needsMobile)
+ assertEquals(100, updated.registrationLimit)
+ }
+
+ @Test
+ fun `mergeEventDTOChanges should not allow lowering limit below registrations`() {
+ testEvent.registrationLimit = 10
+ testEvent.open = LocalDateTime.now().minusDays(5) // Already open
+ testEvent.closed = LocalDateTime.now().plusDays(5)
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ // Create 3 paid registrations
+ repeat(3) { index ->
+ val registration = EventRegistration().apply {
+ subscribable = saved
+ name = "User$index"
+ firstName = "Test"
+ email = "user$index@example.com"
+ price = saved.price
+ paid = true
+ }
+ registrationRepository.save(registration)
+ }
+ registrationRepository.flush()
+
+ val dto = EventDTO(
+ id = saved.id,
+ name = saved.name,
+ description = saved.description,
+ open = saved.open,
+ closed = saved.closed,
+ start = saved.start,
+ end = saved.end,
+ price = saved.price,
+ needsMobile = saved.needsMobile,
+ registrationLimit = 2, // Trying to lower below 3 registrations!
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = true,
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.mergeEventDTOChanges(saved.id!!, dto)
+ }
+
+ assertEquals("The registration limit cannot be lowered below the current registration count!", exception.message)
+ }
+
+ @Test
+ fun `cancelEvent should mark event as cancelled`() {
+ testEvent.open = LocalDateTime.now().minusDays(5)
+ testEvent.closed = LocalDateTime.now().plusDays(5)
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ eventService.cancelEvent(saved.id!!)
+
+ val cancelled = eventRepository.findById(saved.id!!).get()
+ assertTrue(cancelled.cancelled)
+ }
+
+ @Test
+ fun `cancelEvent should not allow cancelling already cancelled event`() {
+ testEvent.cancelled = true
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.cancelEvent(saved.id!!)
+ }
+
+ assertEquals("This event is already cancelled!", exception.message)
+ }
+
+ @Test
+ fun `cancelEvent should not allow cancelling started event`() {
+ testEvent.open = LocalDateTime.now().minusDays(15)
+ testEvent.closed = LocalDateTime.now().minusDays(10)
+ testEvent.start = LocalDateTime.now().minusDays(5)
+ testEvent.end = LocalDateTime.now().plusDays(1)
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.cancelEvent(saved.id!!)
+ }
+
+ assertEquals("A started event cannot be cancelled anymore!", exception.message)
+ }
+
+ @Test
+ fun `getEventDTOById should return event when exists`() {
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val result = eventService.getEventDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals("Test Event", result.name)
+ }
+
+ @Test
+ fun `getEventDTOById should throw exception when not found`() {
+ assertThrows(EventNotFoundException::class.java) {
+ eventService.getEventDTOById(999)
+ }
+ }
+
+ @Test
+ fun `mergeEventDTOChanges should not allow making previously cancellable event uncancellable`() {
+ testEvent.cancellable = true
+ val saved = eventRepository.save(testEvent)
+ eventRepository.flush()
+
+ val dto = EventDTO(
+ id = saved.id,
+ name = saved.name,
+ description = saved.description,
+ open = saved.open,
+ closed = saved.closed,
+ start = saved.start,
+ end = saved.end,
+ price = saved.price,
+ needsMobile = saved.needsMobile,
+ registrationLimit = null,
+ address = null,
+ additionalForm = null,
+ additionalFormRule = null,
+ cancellable = false, // Trying to make it uncancellable!
+ sendConfirmation = true,
+ sendCompleteConfirmation = false,
+ communicationCC = null
+ )
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ eventService.mergeEventDTOChanges(saved.id!!, dto)
+ }
+
+ assertEquals("A previously cancellable event cannot be made uncancellable!", exception.message)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/membership/MembershipPeriodServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/membership/MembershipPeriodServiceIntegrationTest.kt
new file mode 100644
index 0000000..d9a201e
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/membership/MembershipPeriodServiceIntegrationTest.kt
@@ -0,0 +1,425 @@
+package be.sgl.backend.service.membership
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.MembershipPeriodDTO
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.Membership
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.membership.MembershipRestriction
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.membership.MembershipPeriodRepository
+import be.sgl.backend.repository.membership.MembershipRepository
+import be.sgl.backend.repository.user.UserRepository
+import be.sgl.backend.service.exception.MembershipPeriodNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class MembershipPeriodServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var periodService: MembershipPeriodService
+
+ @Autowired
+ private lateinit var periodRepository: MembershipPeriodRepository
+
+ @Autowired
+ private lateinit var membershipRepository: MembershipRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var userRepository: UserRepository
+
+ private lateinit var testBranch: Branch
+
+ @BeforeEach
+ fun setup() {
+ membershipRepository.deleteAll()
+ userRepository.deleteAll()
+ branchRepository.deleteAll()
+ periodRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+ }
+
+ @Test
+ fun `getAllMembershipPeriods should return all periods with member counts`() {
+ val period1 = MembershipPeriod().apply {
+ name = "Period 1"
+ start = LocalDate.of(2023, 9, 1)
+ end = LocalDate.of(2024, 8, 31)
+ basePrice = 100.0
+ }
+ periodRepository.save(period1)
+
+ val period2 = MembershipPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ basePrice = 110.0
+ }
+ periodRepository.save(period2)
+ periodRepository.flush()
+
+ val periods = periodService.getAllMembershipPeriods()
+
+ assertTrue(periods.size >= 2)
+ assertTrue(periods.any { it.period.name == "Period 1" })
+ assertTrue(periods.any { it.period.name == "Period 2" })
+ }
+
+ @Test
+ fun `getAllMembershipPeriods should include paid member count`() {
+ val period = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.now().minusMonths(2)
+ end = LocalDate.now().plusMonths(10)
+ basePrice = 100.0
+ }
+ val savedPeriod = periodRepository.save(period)
+
+ val user1 = User().apply {
+ username = "user1"
+ firstName = "User"
+ name = "One"
+ email = "user1@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(user1)
+
+ val user2 = User().apply {
+ username = "user2"
+ firstName = "User"
+ name = "Two"
+ email = "user2@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(user2)
+
+ val membership1 = Membership(savedPeriod, user1, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership1)
+
+ val membership2 = Membership(savedPeriod, user2, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership2)
+
+ // Unpaid membership should not be counted
+ val user3 = User().apply {
+ username = "user3"
+ firstName = "User"
+ name = "Three"
+ email = "user3@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(user3)
+
+ val unpaidMembership = Membership(savedPeriod, user3, testBranch, 100.0).apply {
+ paid = false
+ }
+ membershipRepository.save(unpaidMembership)
+ membershipRepository.flush()
+
+ val periods = periodService.getAllMembershipPeriods()
+
+ val testPeriod = periods.find { it.period.name == "Test Period" }
+ assertNotNull(testPeriod)
+ assertEquals(2, testPeriod?.memberships?.size)
+ }
+
+ @Test
+ fun `getMembershipPeriodDTOById should return period when exists`() {
+ val period = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ basePrice = 100.0
+ }
+ val saved = periodRepository.save(period)
+ periodRepository.flush()
+
+ val result = periodService.getMembershipPeriodDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals("Test Period", result.name)
+ assertEquals(LocalDate.of(2024, 9, 1), result.start)
+ assertEquals(LocalDate.of(2025, 8, 31), result.end)
+ }
+
+ @Test
+ fun `getMembershipPeriodDTOById should throw exception when not found`() {
+ assertThrows(MembershipPeriodNotFoundException::class.java) {
+ periodService.getMembershipPeriodDTOById(999)
+ }
+ }
+
+ @Test
+ fun `getCurrentMembershipPeriod should return active period`() {
+ val currentPeriod = MembershipPeriod().apply {
+ name = "Current Period"
+ start = LocalDate.now().minusMonths(2)
+ end = LocalDate.now().plusMonths(10)
+ basePrice = 100.0
+ }
+ periodRepository.save(currentPeriod)
+ periodRepository.flush()
+
+ val result = periodService.getCurrentMembershipPeriod()
+
+ assertNotNull(result)
+ assertEquals("Current Period", result.name)
+ }
+
+ @Test
+ fun `saveMembershipPeriodDTO should create new period`() {
+ val dto = MembershipPeriodDTO(
+ id = null,
+ name = "New Period",
+ start = LocalDate.of(2025, 9, 1),
+ end = LocalDate.of(2026, 8, 31),
+ basePrice = 120.0,
+ restrictions = emptyList()
+ )
+
+ val saved = periodService.saveMembershipPeriodDTO(dto)
+
+ assertNotNull(saved.id)
+ assertEquals("New Period", saved.name)
+ assertEquals(120.0, saved.basePrice)
+
+ val persisted = periodRepository.findById(saved.id!!).get()
+ assertEquals("New Period", persisted.name)
+ }
+
+ @Test
+ fun `saveMembershipPeriodDTO should create period with restrictions`() {
+ val restriction1 = MembershipRestriction(
+ testBranch,
+ LocalDate.of(2025, 9, 1),
+ 100
+ )
+
+ val period = MembershipPeriod().apply {
+ name = "Period with Restrictions"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ restrictions.add(restriction1)
+ }
+
+ val saved = periodRepository.save(period)
+ periodRepository.flush()
+
+ val result = periodService.getMembershipPeriodDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals(1, result.restrictions.size)
+ }
+
+ @Test
+ fun `membership period should validate restriction dates`() {
+ val period = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ }
+
+ // Restriction with date before period start should fail validation
+ val invalidRestriction = MembershipRestriction(
+ testBranch,
+ LocalDate.of(2025, 8, 1), // Before period start
+ 100
+ )
+ period.restrictions.add(invalidRestriction)
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ period.validateRestrictions()
+ }
+
+ assertTrue(exception.message!!.contains("restriction") || exception.message!!.contains("before"))
+ }
+
+ @Test
+ fun `membership period should validate restriction limits`() {
+ val period = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ }
+
+ // Restriction with non-positive limit should fail validation
+ val invalidRestriction = MembershipRestriction(
+ testBranch,
+ LocalDate.of(2025, 10, 1),
+ 0 // Non-positive limit
+ )
+ period.restrictions.add(invalidRestriction)
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ period.validateRestrictions()
+ }
+
+ assertTrue(exception.message!!.contains("limit") || exception.message!!.contains("positive"))
+ }
+
+ @Test
+ fun `periods should be ordered by start date descending`() {
+ val period1 = MembershipPeriod().apply {
+ name = "2023-2024"
+ start = LocalDate.of(2023, 9, 1)
+ end = LocalDate.of(2024, 8, 31)
+ basePrice = 100.0
+ }
+ periodRepository.save(period1)
+
+ val period2 = MembershipPeriod().apply {
+ name = "2024-2025"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ basePrice = 110.0
+ }
+ periodRepository.save(period2)
+
+ val period3 = MembershipPeriod().apply {
+ name = "2025-2026"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 120.0
+ }
+ periodRepository.save(period3)
+ periodRepository.flush()
+
+ val periods = periodService.getAllMembershipPeriods()
+
+ assertTrue(periods.size >= 3)
+ // Most recent period should be first
+ val firstPeriod = periods.find { it.period.name == "2025-2026" }
+ val lastPeriod = periods.find { it.period.name == "2023-2024" }
+ assertNotNull(firstPeriod)
+ assertNotNull(lastPeriod)
+
+ val firstIndex = periods.indexOf(firstPeriod)
+ val lastIndex = periods.indexOf(lastPeriod)
+ assertTrue(firstIndex < lastIndex)
+ }
+
+ @Test
+ fun `membership period with multiple branches should track restrictions separately`() {
+ val branch2 = Branch().apply {
+ name = "Branch 2"
+ email = "branch2@example.com"
+ minimumAge = 12
+ maximumAge = 16
+ sex = Sex.UNKNOWN
+ description = "Test 2"
+ law = "Law"
+ image = "test2.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branch2)
+
+ val period = MembershipPeriod().apply {
+ name = "Multi-Branch Period"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ }
+
+ val restriction1 = MembershipRestriction(testBranch, LocalDate.of(2025, 10, 1), 50)
+ val restriction2 = MembershipRestriction(branch2, LocalDate.of(2025, 10, 15), 75)
+
+ period.restrictions.add(restriction1)
+ period.restrictions.add(restriction2)
+
+ val saved = periodRepository.save(period)
+ periodRepository.flush()
+
+ val result = periodService.getMembershipPeriodDTOById(saved.id!!)
+
+ assertEquals(2, result.restrictions.size)
+ assertTrue(result.restrictions.any { it.branch.name == "Test Branch" && it.maxCount == 50 })
+ assertTrue(result.restrictions.any { it.branch.name == "Branch 2" && it.maxCount == 75 })
+ }
+
+ @Test
+ fun `period toString should format dates correctly`() {
+ val period = MembershipPeriod().apply {
+ name = "2024-2025"
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ basePrice = 100.0
+ }
+
+ val toString = period.toString()
+
+ assertTrue(toString.contains("2024") || toString.contains("2025"))
+ }
+
+ @Test
+ fun `period with no restrictions should be valid`() {
+ val period = MembershipPeriod().apply {
+ name = "No Restrictions"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ }
+
+ assertDoesNotThrow {
+ period.validateRestrictions()
+ }
+ }
+
+ @Test
+ fun `saveMembershipPeriodDTO should set bidirectional relationship with restrictions`() {
+ val restriction = MembershipRestriction(
+ testBranch,
+ LocalDate.of(2025, 10, 1),
+ 100
+ )
+
+ val period = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.of(2025, 9, 1)
+ end = LocalDate.of(2026, 8, 31)
+ basePrice = 100.0
+ restrictions.add(restriction)
+ }
+
+ val saved = periodRepository.save(period)
+ periodRepository.flush()
+
+ val retrieved = periodRepository.findById(saved.id!!).get()
+ assertEquals(1, retrieved.restrictions.size)
+ assertEquals(saved.id, retrieved.restrictions[0].period?.id)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/membership/MembershipServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/membership/MembershipServiceIntegrationTest.kt
new file mode 100644
index 0000000..061e034
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/membership/MembershipServiceIntegrationTest.kt
@@ -0,0 +1,396 @@
+package be.sgl.backend.service.membership
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.UserRegistrationDTO
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.Membership
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.membership.MembershipPeriodRepository
+import be.sgl.backend.repository.membership.MembershipRepository
+import be.sgl.backend.repository.user.UserRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class MembershipServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var membershipService: MembershipService
+
+ @Autowired
+ private lateinit var membershipRepository: MembershipRepository
+
+ @Autowired
+ private lateinit var membershipPeriodRepository: MembershipPeriodRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var userRepository: UserRepository
+
+ private lateinit var testBranch: Branch
+ private lateinit var testPeriod: MembershipPeriod
+ private lateinit var testUser: User
+
+ @BeforeEach
+ fun setup() {
+ membershipRepository.deleteAll()
+ userRepository.deleteAll()
+ branchRepository.deleteAll()
+ membershipPeriodRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+
+ testPeriod = MembershipPeriod().apply {
+ name = "Test Period"
+ start = LocalDate.now().minusMonths(2)
+ end = LocalDate.now().plusMonths(10)
+ basePrice = 100.0
+ }
+ testPeriod = membershipPeriodRepository.save(testPeriod)
+
+ testUser = User().apply {
+ username = "testuser"
+ firstName = "Test"
+ name = "User"
+ email = "testuser@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ sex = Sex.UNKNOWN
+ }
+ testUser = userRepository.save(testUser)
+ }
+
+ @Test
+ fun `getAllMembershipsForUser should return all user memberships`() {
+ val membership1 = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership1)
+
+ val period2 = MembershipPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.now().plusYears(1)
+ end = LocalDate.now().plusYears(2)
+ basePrice = 110.0
+ }
+ membershipPeriodRepository.save(period2)
+
+ val membership2 = Membership(period2, testUser, testBranch, 110.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership2)
+ membershipRepository.flush()
+
+ val memberships = membershipService.getAllMembershipsForUser(testUser.username!!)
+
+ assertTrue(memberships.size >= 2)
+ assertTrue(memberships.any { it.period.name == "Test Period" })
+ assertTrue(memberships.any { it.period.name == "Period 2" })
+ }
+
+ @Test
+ fun `getCurrentMembershipForUser should return active membership`() {
+ val membership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val current = membershipService.getCurrentMembershipForUser(testUser.username!!)
+
+ assertNotNull(current)
+ assertEquals(testPeriod.name, current?.period?.name)
+ assertEquals(testUser.email, current?.user?.email)
+ }
+
+ @Test
+ fun `getCurrentMembershipForUser should return null when no active membership`() {
+ val futurePeriod = MembershipPeriod().apply {
+ name = "Future Period"
+ start = LocalDate.now().plusYears(1)
+ end = LocalDate.now().plusYears(2)
+ basePrice = 100.0
+ }
+ membershipPeriodRepository.save(futurePeriod)
+
+ val membership = Membership(futurePeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val current = membershipService.getCurrentMembershipForUser(testUser.username!!)
+
+ assertNull(current)
+ }
+
+ @Test
+ fun `getCurrentMembershipsForBranch should return all current memberships for branch`() {
+ val user1 = User().apply {
+ username = "user1"
+ firstName = "User"
+ name = "One"
+ email = "user1@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(user1)
+
+ val user2 = User().apply {
+ username = "user2"
+ firstName = "User"
+ name = "Two"
+ email = "user2@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(user2)
+
+ val membership1 = Membership(testPeriod, user1, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership1)
+
+ val membership2 = Membership(testPeriod, user2, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership2)
+ membershipRepository.flush()
+
+ val memberships = membershipService.getCurrentMembershipsForBranch(testBranch.id)
+
+ assertTrue(memberships.size >= 2)
+ assertTrue(memberships.any { it.user.email == "user1@example.com" })
+ assertTrue(memberships.any { it.user.email == "user2@example.com" })
+ }
+
+ @Test
+ fun `getCurrentMembershipsForBranch with null should return all current memberships`() {
+ val membership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val memberships = membershipService.getCurrentMembershipsForBranch(null)
+
+ assertTrue(memberships.size >= 1)
+ }
+
+ @Test
+ fun `createMembershipForExistingUser should fail when user already has active membership`() {
+ val membership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ membershipService.createMembershipForExistingUser(testUser.username!!)
+ }
+
+ assertTrue(exception.message!!.contains("already an active membership"))
+ }
+
+ @Test
+ fun `getMembershipDTOById should return membership when exists`() {
+ val membership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ val saved = membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val result = membershipService.getMembershipDTOById(saved.id!!)
+
+ assertNotNull(result)
+ assertEquals(testUser.email, result?.user?.email)
+ assertEquals(testBranch.name, result?.branch?.name)
+ }
+
+ @Test
+ fun `getMembershipDTOById should return null when not found`() {
+ val result = membershipService.getMembershipDTOById(999)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `membership should only be active during period dates`() {
+ // Create past period
+ val pastPeriod = MembershipPeriod().apply {
+ name = "Past Period"
+ start = LocalDate.now().minusYears(2)
+ end = LocalDate.now().minusYears(1)
+ basePrice = 90.0
+ }
+ membershipPeriodRepository.save(pastPeriod)
+
+ val pastMembership = Membership(pastPeriod, testUser, testBranch, 90.0).apply {
+ paid = true
+ }
+ membershipRepository.save(pastMembership)
+
+ val currentMembership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(currentMembership)
+ membershipRepository.flush()
+
+ val current = membershipService.getCurrentMembershipForUser(testUser.username!!)
+
+ assertNotNull(current)
+ assertEquals(testPeriod.name, current?.period?.name)
+ assertEquals("Test Period", current?.period?.name)
+ }
+
+ @Test
+ fun `getCurrentMembershipsForBranch should only return paid memberships`() {
+ val paidMembership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(paidMembership)
+
+ val unpaidUser = User().apply {
+ username = "unpaiduser"
+ firstName = "Unpaid"
+ name = "User"
+ email = "unpaid@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ }
+ userRepository.save(unpaidUser)
+
+ val unpaidMembership = Membership(testPeriod, unpaidUser, testBranch, 100.0).apply {
+ paid = false
+ }
+ membershipRepository.save(unpaidMembership)
+ membershipRepository.flush()
+
+ val memberships = membershipService.getCurrentMembershipsForBranch(testBranch.id)
+
+ // Should only contain paid membership
+ assertTrue(memberships.any { it.user.email == testUser.email })
+ assertFalse(memberships.any { it.user.email == unpaidUser.email })
+ }
+
+ @Test
+ fun `membership for multiple branches should be tracked separately`() {
+ val branch2 = Branch().apply {
+ name = "Branch 2"
+ email = "branch2@example.com"
+ minimumAge = 12
+ maximumAge = 16
+ sex = Sex.UNKNOWN
+ description = "Test 2"
+ law = "Law"
+ image = "test2.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branchRepository.save(branch2)
+
+ val membership1 = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership1)
+
+ val user2 = User().apply {
+ username = "user2"
+ firstName = "User"
+ name = "Two"
+ email = "user2@example.com"
+ birthdate = LocalDate.of(2010, 1, 1)
+ }
+ userRepository.save(user2)
+
+ val membership2 = Membership(testPeriod, user2, branch2, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(membership2)
+ membershipRepository.flush()
+
+ val branch1Memberships = membershipService.getCurrentMembershipsForBranch(testBranch.id)
+ val branch2Memberships = membershipService.getCurrentMembershipsForBranch(branch2.id)
+
+ assertTrue(branch1Memberships.any { it.user.email == testUser.email })
+ assertFalse(branch1Memberships.any { it.user.email == user2.email })
+
+ assertTrue(branch2Memberships.any { it.user.email == user2.email })
+ assertFalse(branch2Memberships.any { it.user.email == testUser.email })
+ }
+
+ @Test
+ fun `membership with reduced price should be saved correctly`() {
+ val reducedUser = User().apply {
+ username = "reduceduser"
+ firstName = "Reduced"
+ name = "User"
+ email = "reduced@example.com"
+ birthdate = LocalDate.of(2014, 1, 1)
+ hasReduction = true
+ }
+ userRepository.save(reducedUser)
+
+ // Price reduced by factor of 3: 100.0 / 3 = 33.33
+ val membership = Membership(testPeriod, reducedUser, testBranch, 33.33).apply {
+ paid = true
+ }
+ membershipRepository.save(membership)
+ membershipRepository.flush()
+
+ val result = membershipService.getMembershipDTOById(membership.id!!)
+
+ assertNotNull(result)
+ assertEquals(33.33, result?.price)
+ }
+
+ @Test
+ fun `getAllMembershipsForUser should include both paid and unpaid memberships`() {
+ val paidMembership = Membership(testPeriod, testUser, testBranch, 100.0).apply {
+ paid = true
+ }
+ membershipRepository.save(paidMembership)
+
+ val period2 = MembershipPeriod().apply {
+ name = "Period 2"
+ start = LocalDate.now().plusYears(1)
+ end = LocalDate.now().plusYears(2)
+ basePrice = 110.0
+ }
+ membershipPeriodRepository.save(period2)
+
+ val unpaidMembership = Membership(period2, testUser, testBranch, 110.0).apply {
+ paid = false
+ }
+ membershipRepository.save(unpaidMembership)
+ membershipRepository.flush()
+
+ val memberships = membershipService.getAllMembershipsForUser(testUser.username!!)
+
+ assertTrue(memberships.size >= 2)
+ assertTrue(memberships.any { it.paid })
+ assertTrue(memberships.any { !it.paid })
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/membership/ValidateAndCreateMembershipTest.kt b/src/test/kotlin/be/sgl/backend/service/membership/ValidateAndCreateMembershipTest.kt
new file mode 100644
index 0000000..303a10f
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/membership/ValidateAndCreateMembershipTest.kt
@@ -0,0 +1,323 @@
+package be.sgl.backend.service.membership
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.membership.MembershipPeriod
+import be.sgl.backend.entity.membership.MembershipRestriction
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.membership.MembershipRepository
+import be.sgl.backend.repository.user.SiblingRepository
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class ValidateAndCreateMembershipTest {
+
+ @Autowired
+ private lateinit var validateAndCreateMembership: ValidateAndCreateMembership
+
+ @Autowired
+ private lateinit var membershipRepository: MembershipRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ @Autowired
+ private lateinit var siblingRepository: SiblingRepository
+
+ private lateinit var period: MembershipPeriod
+ private lateinit var branch: Branch
+ private lateinit var user: User
+
+ @BeforeEach
+ fun setup() {
+ // Create a test branch
+ branch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 8
+ sex = Sex.UNKNOWN
+ description = "Test branch description"
+ law = "Test law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ branch = branchRepository.save(branch)
+
+ // Create a test membership period
+ period = MembershipPeriod().apply {
+ start = LocalDate.of(2024, 9, 1)
+ end = LocalDate.of(2025, 8, 31)
+ price = 100.0
+ reductionFactor = 3.0
+ siblingReduction = 10.0
+ }
+
+ // Create a test user
+ user = User().apply {
+ name = "Doe"
+ firstName = "John"
+ email = "john.doe@example.com"
+ birthdate = LocalDate.of(2017, 5, 15) // 7 years old
+ sex = Sex.MALE
+ }
+ }
+
+ @Test
+ fun `execute should create membership with base price for regular user`() {
+ val membership = validateAndCreateMembership.execute(period, user)
+
+ assertNotNull(membership)
+ assertEquals(user, membership.user)
+ assertEquals(period, membership.period)
+ assertEquals(branch, membership.branch)
+ assertEquals(100.0, membership.price)
+ }
+
+ @Test
+ fun `execute should apply reduction for user with hasReduction flag`() {
+ user.hasReduction = true
+
+ val membership = validateAndCreateMembership.execute(period, user)
+
+ assertEquals(33.33, membership.price) // 100 / 3 rounded to 2 decimals
+ }
+
+ @Test
+ fun `execute should apply sibling reduction when sibling has membership`() {
+ // Create sibling
+ val sibling = User().apply {
+ name = "Doe"
+ firstName = "Jane"
+ email = "jane.doe@example.com"
+ birthdate = LocalDate.of(2016, 3, 10)
+ sex = Sex.FEMALE
+ hasReduction = false
+ }
+
+ // Save sibling first
+ siblingRepository.flush()
+
+ // Create membership for sibling
+ val siblingMembership = validateAndCreateMembership.execute(period, sibling)
+ membershipRepository.save(siblingMembership)
+ membershipRepository.flush()
+
+ // Register sibling relationship
+ val siblingRelation = be.sgl.backend.entity.user.SiblingRelation().apply {
+ this.user = user
+ this.sibling = sibling
+ }
+ siblingRepository.save(siblingRelation)
+ siblingRepository.flush()
+
+ val membership = validateAndCreateMembership.execute(period, user)
+
+ assertEquals(90.0, membership.price) // 100 - 10 sibling reduction
+ }
+
+ @Test
+ fun `execute should not apply sibling reduction if sibling has reduction flag`() {
+ val sibling = User().apply {
+ name = "Doe"
+ firstName = "Jane"
+ email = "jane.doe@example.com"
+ birthdate = LocalDate.of(2016, 3, 10)
+ sex = Sex.FEMALE
+ hasReduction = true // Sibling has reduction
+ }
+
+ siblingRepository.flush()
+
+ val siblingMembership = validateAndCreateMembership.execute(period, sibling)
+ membershipRepository.save(siblingMembership)
+ membershipRepository.flush()
+
+ val siblingRelation = be.sgl.backend.entity.user.SiblingRelation().apply {
+ this.user = user
+ this.sibling = sibling
+ }
+ siblingRepository.save(siblingRelation)
+ siblingRepository.flush()
+
+ val membership = validateAndCreateMembership.execute(period, user)
+
+ assertEquals(100.0, membership.price) // Full price, no sibling reduction
+ }
+
+ @Test
+ fun `execute should respect period registration limit`() {
+ period.registrationLimit = 1
+
+ // Create first membership
+ val firstUser = User().apply {
+ name = "Smith"
+ firstName = "Alice"
+ email = "alice@example.com"
+ birthdate = LocalDate.of(2017, 1, 1)
+ sex = Sex.FEMALE
+ }
+ val firstMembership = validateAndCreateMembership.execute(period, firstUser)
+ membershipRepository.save(firstMembership)
+ membershipRepository.flush()
+
+ // Try to create second membership - should fail
+ val exception = assertThrows(IllegalStateException::class.java) {
+ validateAndCreateMembership.execute(period, user)
+ }
+
+ assertEquals("This period already has its maximum number of members!", exception.message)
+ }
+
+ @Test
+ fun `execute should respect branch-specific registration limit`() {
+ val restriction = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.registrationLimit = 1
+ }
+ period.restrictions.add(restriction)
+
+ // Create first membership for this branch
+ val firstUser = User().apply {
+ name = "Smith"
+ firstName = "Alice"
+ email = "alice@example.com"
+ birthdate = LocalDate.of(2017, 1, 1)
+ sex = Sex.FEMALE
+ }
+ val firstMembership = validateAndCreateMembership.execute(period, firstUser)
+ membershipRepository.save(firstMembership)
+ membershipRepository.flush()
+
+ // Try to create second membership - should fail
+ val exception = assertThrows(IllegalStateException::class.java) {
+ validateAndCreateMembership.execute(period, user)
+ }
+
+ assertEquals("No more free membership spots for this branch!", exception.message)
+ }
+
+ @Test
+ fun `execute should apply time-based price restriction`() {
+ val earlyBirdPrice = 80.0
+ val earlyBirdDate = LocalDate.of(2024, 7, 1)
+
+ val restriction = MembershipRestriction().apply {
+ this.period = period
+ this.alternativeStart = earlyBirdDate
+ this.alternativePrice = earlyBirdPrice
+ }
+ period.restrictions.add(restriction)
+
+ val membershipBeforeDeadline = validateAndCreateMembership.execute(
+ period, user, at = LocalDate.of(2024, 7, 15)
+ )
+
+ assertEquals(earlyBirdPrice, membershipBeforeDeadline.price)
+
+ val membershipAfterDeadline = validateAndCreateMembership.execute(
+ period, user, at = LocalDate.of(2024, 8, 15)
+ )
+
+ assertEquals(100.0, membershipAfterDeadline.price) // Back to base price
+ }
+
+ @Test
+ fun `execute should apply branch-specific alternative price`() {
+ val branchSpecificPrice = 75.0
+
+ val restriction = MembershipRestriction().apply {
+ this.period = period
+ this.branch = branch
+ this.alternativePrice = branchSpecificPrice
+ }
+ period.restrictions.add(restriction)
+
+ val membership = validateAndCreateMembership.execute(period, user)
+
+ assertEquals(branchSpecificPrice, membership.price)
+ }
+
+ @Test
+ fun `execute should throw exception when no branch matches user age and sex`() {
+ // Create user that doesn't fit any branch
+ val tooOldUser = User().apply {
+ name = "Old"
+ firstName = "Person"
+ email = "old@example.com"
+ birthdate = LocalDate.of(2000, 1, 1) // Too old for our test branch
+ sex = Sex.MALE
+ }
+
+ val exception = assertThrows(IllegalStateException::class.java) {
+ validateAndCreateMembership.execute(period, tooOldUser)
+ }
+
+ assertEquals("No active branch can be linked to a user of this age and sex!", exception.message)
+ }
+
+ @Test
+ fun `execute should consider user age deviation`() {
+ // User is normally 7 years old, but with deviation of 2, effectively 9
+ user.ageDeviation = 2
+
+ // This should fail because effective age (9) exceeds branch maximum (8)
+ val exception = assertThrows(IllegalStateException::class.java) {
+ validateAndCreateMembership.execute(period, user)
+ }
+
+ assertEquals("No active branch can be linked to a user of this age and sex!", exception.message)
+ }
+
+ @Test
+ fun `execute should calculate age at end of period`() {
+ // User born in May 2017, will be 8 at end of period (Aug 2025)
+ val youngUser = User().apply {
+ name = "Young"
+ firstName = "Person"
+ email = "young@example.com"
+ birthdate = LocalDate.of(2017, 5, 15)
+ sex = Sex.MALE
+ }
+
+ val membership = validateAndCreateMembership.execute(period, youngUser)
+
+ assertNotNull(membership)
+ assertEquals(branch, membership.branch) // Should still fit in branch
+ }
+
+ @Test
+ fun `execute should prefer most recent time restriction`() {
+ val firstRestriction = MembershipRestriction().apply {
+ this.period = period
+ this.alternativeStart = LocalDate.of(2024, 6, 1)
+ this.alternativePrice = 90.0
+ }
+ val secondRestriction = MembershipRestriction().apply {
+ this.period = period
+ this.alternativeStart = LocalDate.of(2024, 7, 1)
+ this.alternativePrice = 85.0
+ }
+ period.restrictions.addAll(listOf(firstRestriction, secondRestriction))
+
+ val membership = validateAndCreateMembership.execute(
+ period, user, at = LocalDate.of(2024, 7, 15)
+ )
+
+ assertEquals(85.0, membership.price) // Should use most recent restriction
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/organization/OrganizationServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/organization/OrganizationServiceIntegrationTest.kt
new file mode 100644
index 0000000..8481f80
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/organization/OrganizationServiceIntegrationTest.kt
@@ -0,0 +1,331 @@
+package be.sgl.backend.service.organization
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.dto.RepresentativeDTO
+import be.sgl.backend.entity.Address
+import be.sgl.backend.entity.organization.ContactMethod
+import be.sgl.backend.entity.organization.ContactMethodType
+import be.sgl.backend.entity.organization.Organization
+import be.sgl.backend.entity.organization.OrganizationType
+import be.sgl.backend.entity.setting.Setting
+import be.sgl.backend.entity.setting.SettingId
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.AddressRepository
+import be.sgl.backend.repository.OrganizationRepository
+import be.sgl.backend.repository.SettingRepository
+import be.sgl.backend.repository.user.UserRepository
+import be.sgl.backend.service.exception.OrganizationNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class OrganizationServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var organizationService: OrganizationService
+
+ @Autowired
+ private lateinit var organizationRepository: OrganizationRepository
+
+ @Autowired
+ private lateinit var addressRepository: AddressRepository
+
+ @Autowired
+ private lateinit var userRepository: UserRepository
+
+ @Autowired
+ private lateinit var settingRepository: SettingRepository
+
+ private lateinit var ownerOrg: Organization
+ private lateinit var certifierOrg: Organization
+ private lateinit var testAddress: Address
+
+ @BeforeEach
+ fun setup() {
+ organizationRepository.deleteAll()
+ addressRepository.deleteAll()
+ userRepository.deleteAll()
+ settingRepository.deleteAll()
+
+ testAddress = Address().apply {
+ street = "Test Street"
+ number = "123"
+ zipcode = "1000"
+ town = "Brussels"
+ country = "BE"
+ }
+ testAddress = addressRepository.save(testAddress)
+
+ ownerOrg = Organization().apply {
+ name = "Owner Organization"
+ type = OrganizationType.OWNER
+ kbo = "1234567890"
+ address = testAddress
+ description = "Owner description"
+ }
+ ownerOrg = organizationRepository.save(ownerOrg)
+
+ val certifierAddress = Address().apply {
+ street = "Certifier Street"
+ number = "456"
+ zipcode = "2000"
+ town = "Antwerp"
+ country = "BE"
+ }
+ addressRepository.save(certifierAddress)
+
+ certifierOrg = Organization().apply {
+ name = "Certifier Organization"
+ type = OrganizationType.CERTIFIER
+ kbo = "0987654321"
+ address = certifierAddress
+ description = "Certifier description"
+ }
+ certifierOrg = organizationRepository.save(certifierOrg)
+ }
+
+ @Test
+ fun `getOwner should return owner organization`() {
+ val owner = organizationService.getOwner()
+
+ assertNotNull(owner)
+ assertEquals("Owner Organization", owner.name)
+ assertEquals(OrganizationType.OWNER, owner.type)
+ }
+
+ @Test
+ fun `getCertifier should return certifier organization`() {
+ val certifier = organizationService.getCertifier()
+
+ assertNotNull(certifier)
+ assertEquals("Certifier Organization", certifier.name)
+ assertEquals(OrganizationType.CERTIFIER, certifier.type)
+ }
+
+ @Test
+ fun `organization should have address information`() {
+ val owner = organizationService.getOwner()
+
+ assertNotNull(owner.address)
+ assertEquals("Test Street", owner.address.street)
+ assertEquals("123", owner.address.number)
+ assertEquals("1000", owner.address.zipcode)
+ assertEquals("Brussels", owner.address.town)
+ }
+
+ @Test
+ fun `organization should include KBO number`() {
+ val owner = organizationService.getOwner()
+
+ assertEquals("1234567890", owner.kbo)
+ }
+
+ @Test
+ fun `organization should include description`() {
+ val owner = organizationService.getOwner()
+
+ assertEquals("Owner description", owner.description)
+ }
+
+ @Test
+ fun `organization getEmail should return email contact method`() {
+ val emailContact = ContactMethod(ownerOrg, ContactMethodType.EMAIL, "owner@example.com")
+ ownerOrg.contactMethods.add(emailContact)
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val retrieved = organizationRepository.findById(ownerOrg.id!!).get()
+ val email = retrieved.getEmail()
+
+ assertEquals("owner@example.com", email)
+ }
+
+ @Test
+ fun `organization getMobile should return mobile contact method`() {
+ val mobileContact = ContactMethod(ownerOrg, ContactMethodType.MOBILE, "0123456789")
+ ownerOrg.contactMethods.add(mobileContact)
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val retrieved = organizationRepository.findById(ownerOrg.id!!).get()
+ val mobile = retrieved.getMobile()
+
+ assertEquals("0123456789", mobile)
+ }
+
+ @Test
+ fun `organization should support multiple contact methods`() {
+ val emailContact = ContactMethod(ownerOrg, ContactMethodType.EMAIL, "owner@example.com")
+ val mobileContact = ContactMethod(ownerOrg, ContactMethodType.MOBILE, "0123456789")
+ val linkContact = ContactMethod(ownerOrg, ContactMethodType.LINK, "https://example.com")
+
+ ownerOrg.contactMethods.add(emailContact)
+ ownerOrg.contactMethods.add(mobileContact)
+ ownerOrg.contactMethods.add(linkContact)
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val retrieved = organizationRepository.findById(ownerOrg.id!!).get()
+
+ assertEquals(3, retrieved.contactMethods.size)
+ assertEquals("owner@example.com", retrieved.getEmail())
+ assertEquals("0123456789", retrieved.getMobile())
+ }
+
+ @Test
+ fun `getRepresentativeDTO should return representative settings`() {
+ val testUser = User().apply {
+ username = "representative"
+ firstName = "Rep"
+ name = "User"
+ email = "rep@example.com"
+ birthdate = LocalDate.of(1980, 1, 1)
+ }
+ userRepository.save(testUser)
+
+ settingRepository.save(Setting(SettingId.REPRESENTATIVE_USERNAME, "representative"))
+ settingRepository.save(Setting(SettingId.REPRESENTATIVE_TITLE, "Director"))
+ settingRepository.save(Setting(SettingId.REPRESENTATIVE_SIGNATURE, "signature.png"))
+ settingRepository.flush()
+
+ val representative = organizationService.getRepresentativeDTO()
+
+ assertEquals("representative", representative.username)
+ assertEquals("Director", representative.title)
+ assertEquals("signature.png", representative.signature)
+ }
+
+ @Test
+ fun `getRepresentativeDTO should return null values when not configured`() {
+ val representative = organizationService.getRepresentativeDTO()
+
+ assertNull(representative.username)
+ assertNull(representative.title)
+ assertNull(representative.signature)
+ }
+
+ @Test
+ fun `owner and certifier should be separate organizations`() {
+ val owner = organizationService.getOwner()
+ val certifier = organizationService.getCertifier()
+
+ assertNotEquals(owner.id, certifier.id)
+ assertNotEquals(owner.name, certifier.name)
+ }
+
+ @Test
+ fun `organization should have unique type`() {
+ // Attempting to create another OWNER should fail
+ val duplicateOwner = Organization().apply {
+ name = "Duplicate Owner"
+ type = OrganizationType.OWNER
+ address = testAddress
+ }
+
+ // The service checks for existing organization by type
+ // This would fail in saveOrganizationDTO with the check
+ }
+
+ @Test
+ fun `organization without image should return null image`() {
+ ownerOrg.image = null
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val owner = organizationService.getOwner()
+
+ assertNull(owner.image)
+ }
+
+ @Test
+ fun `organization with image should return image path`() {
+ ownerOrg.image = "organization-logo.png"
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val owner = organizationService.getOwner()
+
+ assertEquals("organization-logo.png", owner.image)
+ }
+
+ @Test
+ fun `organization address should be fully populated`() {
+ val owner = organizationService.getOwner()
+
+ assertNotNull(owner.address)
+ assertNotNull(owner.address.street)
+ assertNotNull(owner.address.number)
+ assertNotNull(owner.address.zipcode)
+ assertNotNull(owner.address.town)
+ assertNotNull(owner.address.country)
+ }
+
+ @Test
+ fun `organization KBO should be optional`() {
+ ownerOrg.kbo = null
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val owner = organizationService.getOwner()
+
+ assertNull(owner.kbo)
+ }
+
+ @Test
+ fun `organization description should be optional`() {
+ ownerOrg.description = null
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val owner = organizationService.getOwner()
+
+ assertNull(owner.description)
+ }
+
+ @Test
+ fun `organization description should support long text`() {
+ val longDescription = "A".repeat(500)
+ ownerOrg.description = longDescription
+ organizationRepository.save(ownerOrg)
+ organizationRepository.flush()
+
+ val owner = organizationService.getOwner()
+
+ assertEquals(longDescription, owner.description)
+ assertEquals(500, owner.description?.length)
+ }
+
+ @Test
+ fun `organization should track both types separately`() {
+ val allOrgs = organizationRepository.findAll()
+
+ assertTrue(allOrgs.any { it.type == OrganizationType.OWNER })
+ assertTrue(allOrgs.any { it.type == OrganizationType.CERTIFIER })
+ assertEquals(2, allOrgs.size)
+ }
+
+ @Test
+ fun `getEmail should return null when no email contact method`() {
+ val retrieved = organizationRepository.findById(ownerOrg.id!!).get()
+ val email = retrieved.getEmail()
+
+ assertNull(email)
+ }
+
+ @Test
+ fun `getMobile should return null when no mobile contact method`() {
+ val retrieved = organizationRepository.findById(ownerOrg.id!!).get()
+ val mobile = retrieved.getMobile()
+
+ assertNull(mobile)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/service/user/UserServiceIntegrationTest.kt b/src/test/kotlin/be/sgl/backend/service/user/UserServiceIntegrationTest.kt
new file mode 100644
index 0000000..dcd86f9
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/service/user/UserServiceIntegrationTest.kt
@@ -0,0 +1,289 @@
+package be.sgl.backend.service.user
+
+import be.sgl.backend.config.TestConfigurations
+import be.sgl.backend.entity.branch.Branch
+import be.sgl.backend.entity.branch.BranchStatus
+import be.sgl.backend.entity.user.Sex
+import be.sgl.backend.entity.user.StaffData
+import be.sgl.backend.entity.user.User
+import be.sgl.backend.repository.BranchRepository
+import be.sgl.backend.repository.user.UserRepository
+import be.sgl.backend.service.exception.UserNotFoundException
+import be.sgl.backend.util.IntegrationTest
+import jakarta.transaction.Transactional
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Import
+import java.time.LocalDate
+
+@IntegrationTest
+@Import(TestConfigurations::class)
+@Transactional
+class UserServiceIntegrationTest {
+
+ @Autowired
+ private lateinit var userService: UserService
+
+ @Autowired
+ private lateinit var userRepository: UserRepository
+
+ @Autowired
+ private lateinit var branchRepository: BranchRepository
+
+ private lateinit var testUser: User
+ private lateinit var testBranch: Branch
+
+ @BeforeEach
+ fun setup() {
+ userRepository.deleteAll()
+ branchRepository.deleteAll()
+
+ testBranch = Branch().apply {
+ name = "Test Branch"
+ email = "test@example.com"
+ minimumAge = 6
+ maximumAge = 12
+ sex = Sex.UNKNOWN
+ description = "Test"
+ law = "Law"
+ image = "test.jpg"
+ status = BranchStatus.ACTIVE
+ staffTitle = "Leader"
+ }
+ testBranch = branchRepository.save(testBranch)
+
+ testUser = User().apply {
+ username = "testuser"
+ firstName = "Test"
+ name = "User"
+ email = "testuser@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ sex = Sex.MALE
+ mobile = "0123456789"
+ hasReduction = false
+ hasHandicap = false
+ }
+ testUser = userRepository.save(testUser)
+ }
+
+ @Test
+ fun `getProfile should return user profile`() {
+ val profile = userService.getProfile(testUser.username!!)
+
+ assertNotNull(profile)
+ assertEquals(testUser.username, profile.username)
+ assertEquals(testUser.email, profile.email)
+ assertEquals(testUser.firstName, profile.firstName)
+ assertEquals(testUser.name, profile.name)
+ }
+
+ @Test
+ fun `getProfile should throw exception for non-existent user`() {
+ assertThrows(UserNotFoundException::class.java) {
+ userService.getProfile("nonexistent")
+ }
+ }
+
+ @Test
+ fun `getByQuery should find users by name`() {
+ val user2 = User().apply {
+ username = "anotheruser"
+ firstName = "Another"
+ name = "User"
+ email = "another@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+ userRepository.save(user2)
+
+ val user3 = User().apply {
+ username = "different"
+ firstName = "Different"
+ name = "Person"
+ email = "different@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+ userRepository.save(user3)
+ userRepository.flush()
+
+ val results = userService.getByQuery("User")
+
+ assertTrue(results.size >= 2)
+ assertTrue(results.any { it.name == "User" })
+ }
+
+ @Test
+ fun `getByQuery should find users by email`() {
+ val results = userService.getByQuery(testUser.email)
+
+ assertTrue(results.any { it.email == testUser.email })
+ }
+
+ @Test
+ fun `getByQuery should find users by first name`() {
+ val results = userService.getByQuery(testUser.firstName)
+
+ assertTrue(results.any { it.firstName == testUser.firstName })
+ }
+
+ @Test
+ fun `getByQuery should return empty list when no matches`() {
+ val results = userService.getByQuery("NonExistentQuery12345")
+
+ assertTrue(results.isEmpty())
+ }
+
+ @Test
+ fun `getStaffBranch should return branch when user is staff`() {
+ testUser.staffData = StaffData().apply {
+ user = testUser
+ branch = testBranch
+ title = "Leader"
+ }
+ userRepository.save(testUser)
+ userRepository.flush()
+
+ val staffBranch = userService.getStaffBranch(testUser.username!!)
+
+ assertNotNull(staffBranch)
+ assertEquals(testBranch.name, staffBranch?.name)
+ }
+
+ @Test
+ fun `getStaffBranch should return null when user is not staff`() {
+ val staffBranch = userService.getStaffBranch(testUser.username!!)
+
+ assertNull(staffBranch)
+ }
+
+ @Test
+ fun `user profile should include all personal information`() {
+ testUser.mobile = "0123456789"
+ testUser.nis = "12345678901"
+ testUser.accountNo = "BE12345678901234"
+ testUser.hasReduction = true
+ testUser.hasHandicap = false
+ userRepository.save(testUser)
+ userRepository.flush()
+
+ val profile = userService.getProfile(testUser.username!!)
+
+ assertEquals("0123456789", profile.mobile)
+ assertEquals("12345678901", profile.nis)
+ assertEquals("BE12345678901234", profile.accountNo)
+ assertTrue(profile.hasReduction)
+ assertFalse(profile.hasHandicap)
+ }
+
+ @Test
+ fun `getByQuery should be case insensitive`() {
+ val results1 = userService.getByQuery("TEST")
+ val results2 = userService.getByQuery("test")
+
+ assertTrue(results1.any { it.firstName.equals("Test", ignoreCase = true) })
+ assertTrue(results2.any { it.firstName.equals("Test", ignoreCase = true) })
+ }
+
+ @Test
+ fun `user profile should include birthdate and sex`() {
+ val profile = userService.getProfile(testUser.username!!)
+
+ assertEquals(LocalDate.of(2000, 1, 1), profile.birthdate)
+ assertEquals(Sex.MALE, profile.sex)
+ }
+
+ @Test
+ fun `getByQuery should support partial matching`() {
+ val results = userService.getByQuery("Test")
+
+ assertTrue(results.any { it.firstName.contains("Test", ignoreCase = true) })
+ }
+
+ @Test
+ fun `user with multiple properties should be searchable by any property`() {
+ val user = User().apply {
+ username = "searchable"
+ firstName = "Searchable"
+ name = "TestName"
+ email = "searchable@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+ userRepository.save(user)
+ userRepository.flush()
+
+ val byFirstName = userService.getByQuery("Searchable")
+ val byLastName = userService.getByQuery("TestName")
+ val byEmail = userService.getByQuery("searchable@")
+
+ assertTrue(byFirstName.any { it.username == "searchable" })
+ assertTrue(byLastName.any { it.username == "searchable" })
+ assertTrue(byEmail.any { it.username == "searchable" })
+ }
+
+ @Test
+ fun `user profile should include age deviation`() {
+ testUser.ageDeviation = 2
+ userRepository.save(testUser)
+ userRepository.flush()
+
+ val profile = userService.getProfile(testUser.username!!)
+
+ assertEquals(2, profile.ageDeviation)
+ }
+
+ @Test
+ fun `user profile should handle null optional fields`() {
+ val minimalUser = User().apply {
+ username = "minimal"
+ firstName = "Minimal"
+ name = "User"
+ email = "minimal@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+ userRepository.save(minimalUser)
+ userRepository.flush()
+
+ val profile = userService.getProfile(minimalUser.username!!)
+
+ assertNotNull(profile)
+ assertNull(profile.mobile)
+ assertNull(profile.nis)
+ assertNull(profile.accountNo)
+ assertNull(profile.image)
+ }
+
+ @Test
+ fun `getStaffBranch should handle user with multiple branch associations`() {
+ // User should only have one staff branch at a time
+ testUser.staffData = StaffData().apply {
+ user = testUser
+ branch = testBranch
+ title = "Leader"
+ }
+ userRepository.save(testUser)
+ userRepository.flush()
+
+ val staffBranch = userService.getStaffBranch(testUser.username!!)
+
+ assertNotNull(staffBranch)
+ assertEquals(testBranch.id, staffBranch?.id)
+ }
+
+ @Test
+ fun `user search should handle special characters`() {
+ val userWithSpecialChars = User().apply {
+ username = "special"
+ firstName = "Jean-Pierre"
+ name = "D'Angelo"
+ email = "special@example.com"
+ birthdate = LocalDate.of(2000, 1, 1)
+ }
+ userRepository.save(userWithSpecialChars)
+ userRepository.flush()
+
+ val results = userService.getByQuery("Jean")
+
+ assertTrue(results.any { it.firstName == "Jean-Pierre" })
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/util/ConditionalsTest.kt b/src/test/kotlin/be/sgl/backend/util/ConditionalsTest.kt
new file mode 100644
index 0000000..6095c87
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/util/ConditionalsTest.kt
@@ -0,0 +1,128 @@
+package be.sgl.backend.util
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito.*
+import org.springframework.context.annotation.ConditionContext
+import org.springframework.core.env.Environment
+import org.springframework.core.type.AnnotatedTypeMetadata
+
+class ConditionalsTest {
+
+ @Test
+ fun `WhenNotBlankCondition should match when property is not blank and not is false`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to false))
+ `when`(environment.getProperty("test.property")).thenReturn("some-value")
+
+ assertTrue(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should not match when property is blank and not is false`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to false))
+ `when`(environment.getProperty("test.property")).thenReturn("")
+
+ assertFalse(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should match when property is blank and not is true`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to true))
+ `when`(environment.getProperty("test.property")).thenReturn("")
+
+ assertTrue(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should not match when property is not blank and not is true`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to true))
+ `when`(environment.getProperty("test.property")).thenReturn("some-value")
+
+ assertFalse(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should not match when property is null and not is false`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to false))
+ `when`(environment.getProperty("test.property")).thenReturn(null)
+
+ assertFalse(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should match when property is null and not is true`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+ val environment = mock(Environment::class.java)
+
+ `when`(context.environment).thenReturn(environment)
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name))
+ .thenReturn(mapOf("value" to "test.property", "not" to true))
+ `when`(environment.getProperty("test.property")).thenReturn(null)
+
+ assertTrue(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `WhenNotBlankCondition should return false when annotation attributes are null`() {
+ val condition = WhenNotBlankCondition()
+ val context = mock(ConditionContext::class.java)
+ val metadata = mock(AnnotatedTypeMetadata::class.java)
+
+ `when`(metadata.getAnnotationAttributes(WhenNotBlank::class.java.name)).thenReturn(null)
+
+ assertFalse(condition.matches(context, metadata))
+ }
+
+ @Test
+ fun `ForInternalOrganization annotation should be present`() {
+ val annotation = ForInternalOrganization::class.java.getAnnotation(WhenNotBlank::class.java)
+ assertNotNull(annotation)
+ assertEquals("organization.external.id", annotation.value)
+ assertTrue(annotation.not)
+ }
+
+ @Test
+ fun `ForExternalOrganization annotation should be present`() {
+ val annotation = ForExternalOrganization::class.java.getAnnotation(WhenNotBlank::class.java)
+ assertNotNull(annotation)
+ assertEquals("organization.external.id", annotation.value)
+ assertFalse(annotation.not)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/util/TextUtilsTest.kt b/src/test/kotlin/be/sgl/backend/util/TextUtilsTest.kt
new file mode 100644
index 0000000..172ef98
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/util/TextUtilsTest.kt
@@ -0,0 +1,75 @@
+package be.sgl.backend.util
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.util.Base64
+
+class TextUtilsTest {
+
+ @Test
+ fun `nullIfBlank should return null for blank strings`() {
+ assertNull("".nullIfBlank())
+ assertNull(" ".nullIfBlank())
+ assertNull(null.nullIfBlank())
+ }
+
+ @Test
+ fun `nullIfBlank should return value for non-blank strings`() {
+ assertEquals("test", "test".nullIfBlank())
+ assertEquals("hello world", "hello world".nullIfBlank())
+ }
+
+ @Test
+ fun `base64Encoded should encode string correctly`() {
+ val input = "Hello World"
+ val expected = Base64.getEncoder().encodeToString(input.toByteArray())
+ assertEquals(expected, input.base64Encoded())
+ }
+
+ @Test
+ fun `base64Encoded should handle empty string`() {
+ val input = ""
+ val expected = Base64.getEncoder().encodeToString(input.toByteArray())
+ assertEquals(expected, input.base64Encoded())
+ }
+
+ @Test
+ fun `belgian date format for LocalDate should be dd-MM-yyyy`() {
+ val date = LocalDate.of(2023, 12, 25)
+ assertEquals("25/12/2023", date.belgian())
+ }
+
+ @Test
+ fun `belgian date format for LocalDateTime should be dd-MM-yyyy`() {
+ val dateTime = LocalDateTime.of(2023, 12, 25, 15, 30, 45)
+ assertEquals("25/12/2023", dateTime.belgian())
+ }
+
+ @Test
+ fun `pricePrecision should format double with 2 decimals`() {
+ assertEquals("10.50", 10.5.pricePrecision())
+ assertEquals("10.99", 10.99.pricePrecision())
+ assertEquals("10.00", 10.0.pricePrecision())
+ }
+
+ @Test
+ fun `pricePrecision should return null for null input`() {
+ val value: Double? = null
+ assertNull(value.pricePrecision())
+ }
+
+ @Test
+ fun `reducePrice should calculate reduced price correctly`() {
+ assertEquals(50.0, 100.0.reducePrice(2.0))
+ assertEquals(33.33, 100.0.reducePrice(3.0))
+ assertEquals(25.0, 50.0.reducePrice(2.0))
+ }
+
+ @Test
+ fun `reducePrice should round to 2 decimal places`() {
+ val result = 10.0.reducePrice(3.0)
+ assertEquals(3.33, result)
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/util/UrlUtilsTest.kt b/src/test/kotlin/be/sgl/backend/util/UrlUtilsTest.kt
new file mode 100644
index 0000000..3f9dc0e
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/util/UrlUtilsTest.kt
@@ -0,0 +1,90 @@
+package be.sgl.backend.util
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class UrlUtilsTest {
+
+ @Test
+ fun `appendRequestParameters should add single parameter to URL without query string`() {
+ val result = appendRequestParameters("https://example.com/api", "key" to "value")
+ assertEquals("https://example.com/api?key=value", result)
+ }
+
+ @Test
+ fun `appendRequestParameters should add multiple parameters to URL without query string`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "key1" to "value1",
+ "key2" to "value2"
+ )
+ assertEquals("https://example.com/api?key1=value1&key2=value2", result)
+ }
+
+ @Test
+ fun `appendRequestParameters should append to existing query string`() {
+ val result = appendRequestParameters(
+ "https://example.com/api?existing=param",
+ "key" to "value"
+ )
+ assertEquals("https://example.com/api?existing=param&key=value", result)
+ }
+
+ @Test
+ fun `appendRequestParameters should URL encode parameter names`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "key with spaces" to "value"
+ )
+ assertTrue(result.contains("key+with+spaces=value"))
+ }
+
+ @Test
+ fun `appendRequestParameters should URL encode parameter values`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "key" to "value with spaces"
+ )
+ assertTrue(result.contains("key=value+with+spaces"))
+ }
+
+ @Test
+ fun `appendRequestParameters should handle special characters`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "email" to "test@example.com"
+ )
+ assertTrue(result.contains("email=test%40example.com"))
+ }
+
+ @Test
+ fun `appendRequestParameters should handle null values`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "key" to null
+ )
+ assertEquals("https://example.com/api?key=null", result)
+ }
+
+ @Test
+ fun `appendRequestParameters should handle numeric values`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "count" to 42,
+ "price" to 19.99
+ )
+ assertTrue(result.contains("count=42"))
+ assertTrue(result.contains("price=19.99"))
+ }
+
+ @Test
+ fun `appendRequestParameters should handle boolean values`() {
+ val result = appendRequestParameters(
+ "https://example.com/api",
+ "active" to true,
+ "deleted" to false
+ )
+ assertTrue(result.contains("active=true"))
+ assertTrue(result.contains("deleted=false"))
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/util/ValidationUtilsTest.kt b/src/test/kotlin/be/sgl/backend/util/ValidationUtilsTest.kt
new file mode 100644
index 0000000..b8aa022
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/util/ValidationUtilsTest.kt
@@ -0,0 +1,226 @@
+package be.sgl.backend.util
+
+import jakarta.validation.Validation
+import jakarta.validation.Validator
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+class ValidationUtilsTest {
+
+ private lateinit var validator: Validator
+
+ @BeforeEach
+ fun setup() {
+ validator = Validation.buildDefaultValidatorFactory().validator
+ }
+
+ // PhoneNumber validation tests
+ @Test
+ fun `PhoneNumberValidator should accept valid 9 digit phone numbers`() {
+ val phoneValidator = PhoneNumberValidator()
+ assertTrue(phoneValidator.isValid("123456789", null))
+ assertTrue(phoneValidator.isValid("987654321", null))
+ }
+
+ @Test
+ fun `PhoneNumberValidator should accept valid 10 digit phone numbers`() {
+ val phoneValidator = PhoneNumberValidator()
+ assertTrue(phoneValidator.isValid("0123456789", null))
+ assertTrue(phoneValidator.isValid("0987654321", null))
+ }
+
+ @Test
+ fun `PhoneNumberValidator should reject invalid phone numbers`() {
+ val phoneValidator = PhoneNumberValidator()
+ assertFalse(phoneValidator.isValid("12345", null))
+ assertFalse(phoneValidator.isValid("12345678901", null))
+ assertFalse(phoneValidator.isValid("abcdefghi", null))
+ assertFalse(phoneValidator.isValid("123-456-789", null))
+ }
+
+ @Test
+ fun `PhoneNumberValidator should accept null or blank values`() {
+ val phoneValidator = PhoneNumberValidator()
+ assertTrue(phoneValidator.isValid(null, null))
+ assertTrue(phoneValidator.isValid("", null))
+ assertTrue(phoneValidator.isValid(" ", null))
+ }
+
+ // NIS validation tests
+ @Test
+ fun `NisValidator should accept valid NIS numbers`() {
+ val nisValidator = NisValidator()
+ // Valid NIS: 85073003328 (example)
+ assertTrue(nisValidator.isValid("85073003328", null))
+ }
+
+ @Test
+ fun `NisValidator should reject invalid NIS numbers`() {
+ val nisValidator = NisValidator()
+ assertFalse(nisValidator.isValid("12345678901", null))
+ assertFalse(nisValidator.isValid("00000000000", null))
+ }
+
+ @Test
+ fun `NisValidator should reject NIS with wrong length`() {
+ val nisValidator = NisValidator()
+ assertFalse(nisValidator.isValid("123456789", null))
+ assertFalse(nisValidator.isValid("123456789012", null))
+ }
+
+ @Test
+ fun `NisValidator should reject non-numeric NIS`() {
+ val nisValidator = NisValidator()
+ assertFalse(nisValidator.isValid("abcdefghijk", null))
+ assertFalse(nisValidator.isValid("123-456-789", null))
+ }
+
+ @Test
+ fun `NisValidator should accept null values`() {
+ val nisValidator = NisValidator()
+ assertTrue(nisValidator.isValid(null, null))
+ }
+
+ // CountryCode validation tests
+ @Test
+ fun `CountryCodeValidator should accept valid ISO country codes`() {
+ val countryValidator = CountryCodeValidator()
+ assertTrue(countryValidator.isValid("BE", null))
+ assertTrue(countryValidator.isValid("NL", null))
+ assertTrue(countryValidator.isValid("FR", null))
+ assertTrue(countryValidator.isValid("US", null))
+ assertTrue(countryValidator.isValid("be", null)) // lowercase should be accepted
+ }
+
+ @Test
+ fun `CountryCodeValidator should reject invalid country codes`() {
+ val countryValidator = CountryCodeValidator()
+ assertFalse(countryValidator.isValid("XX", null))
+ assertFalse(countryValidator.isValid("ZZZ", null))
+ assertFalse(countryValidator.isValid("123", null))
+ }
+
+ @Test
+ fun `CountryCodeValidator should accept null values`() {
+ val countryValidator = CountryCodeValidator()
+ assertTrue(countryValidator.isValid(null, null))
+ }
+
+ // KBO validation tests
+ @Test
+ fun `KboValidator should accept valid KBO numbers`() {
+ val kboValidator = KboValidator()
+ assertTrue(kboValidator.isValid("0123456789", null))
+ assertTrue(kboValidator.isValid("0987654321", null))
+ }
+
+ @Test
+ fun `KboValidator should reject KBO numbers not starting with 0`() {
+ val kboValidator = KboValidator()
+ assertFalse(kboValidator.isValid("1123456789", null))
+ assertFalse(kboValidator.isValid("9987654321", null))
+ }
+
+ @Test
+ fun `KboValidator should reject invalid KBO format`() {
+ val kboValidator = KboValidator()
+ assertFalse(kboValidator.isValid("012345678", null)) // too short
+ assertFalse(kboValidator.isValid("01234567890", null)) // too long
+ assertFalse(kboValidator.isValid("012345678a", null)) // contains letter
+ }
+
+ @Test
+ fun `KboValidator should accept null or blank values`() {
+ val kboValidator = KboValidator()
+ assertTrue(kboValidator.isValid(null, null))
+ assertTrue(kboValidator.isValid("", null))
+ assertTrue(kboValidator.isValid(" ", null))
+ }
+
+ // StartEndTime validation tests
+ @Test
+ fun `StartEndTimeValidator should accept valid time ranges`() {
+ val validator = StartEndTimeValidator()
+ val validRange = object : StartEndTime {
+ override val start = LocalDateTime.of(2023, 1, 1, 10, 0)
+ override val end = LocalDateTime.of(2023, 1, 1, 12, 0)
+ }
+ assertTrue(validator.isValid(validRange, null))
+ }
+
+ @Test
+ fun `StartEndTimeValidator should reject end before start`() {
+ val validator = StartEndTimeValidator()
+ val invalidRange = object : StartEndTime {
+ override val start = LocalDateTime.of(2023, 1, 1, 12, 0)
+ override val end = LocalDateTime.of(2023, 1, 1, 10, 0)
+ }
+ assertFalse(validator.isValid(invalidRange, null))
+ }
+
+ @Test
+ fun `StartEndTimeValidator should accept null values`() {
+ val validator = StartEndTimeValidator()
+ assertTrue(validator.isValid(null, null))
+ }
+
+ @Test
+ fun `StartEndTimeValidator should accept null start or end`() {
+ val validator = StartEndTimeValidator()
+ val nullStart = object : StartEndTime {
+ override val start: LocalDateTime? = null
+ override val end = LocalDateTime.of(2023, 1, 1, 12, 0)
+ }
+ val nullEnd = object : StartEndTime {
+ override val start = LocalDateTime.of(2023, 1, 1, 10, 0)
+ override val end: LocalDateTime? = null
+ }
+ assertTrue(validator.isValid(nullStart, null))
+ assertTrue(validator.isValid(nullEnd, null))
+ }
+
+ // StartEndDate validation tests
+ @Test
+ fun `StartEndDateValidator should accept valid date ranges`() {
+ val validator = StartEndDateValidator()
+ val validRange = object : StartEndDate {
+ override val start = LocalDate.of(2023, 1, 1)
+ override val end = LocalDate.of(2023, 12, 31)
+ }
+ assertTrue(validator.isValid(validRange, null))
+ }
+
+ @Test
+ fun `StartEndDateValidator should reject end before start`() {
+ val validator = StartEndDateValidator()
+ val invalidRange = object : StartEndDate {
+ override val start = LocalDate.of(2023, 12, 31)
+ override val end = LocalDate.of(2023, 1, 1)
+ }
+ assertFalse(validator.isValid(invalidRange, null))
+ }
+
+ @Test
+ fun `StartEndDateValidator should accept null values`() {
+ val validator = StartEndDateValidator()
+ assertTrue(validator.isValid(null, null))
+ }
+
+ @Test
+ fun `StartEndDateValidator should accept null start or end`() {
+ val validator = StartEndDateValidator()
+ val nullStart = object : StartEndDate {
+ override val start: LocalDate? = null
+ override val end = LocalDate.of(2023, 12, 31)
+ }
+ val nullEnd = object : StartEndDate {
+ override val start = LocalDate.of(2023, 1, 1)
+ override val end: LocalDate? = null
+ }
+ assertTrue(validator.isValid(nullStart, null))
+ assertTrue(validator.isValid(nullEnd, null))
+ }
+}
diff --git a/src/test/kotlin/be/sgl/backend/util/ZipUtilsTest.kt b/src/test/kotlin/be/sgl/backend/util/ZipUtilsTest.kt
new file mode 100644
index 0000000..125da67
--- /dev/null
+++ b/src/test/kotlin/be/sgl/backend/util/ZipUtilsTest.kt
@@ -0,0 +1,110 @@
+package be.sgl.backend.util
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayInputStream
+import java.util.zip.ZipInputStream
+
+class ZipUtilsTest {
+
+ @Test
+ fun `zipped should create a zip file from list of byte arrays`() {
+ val file1 = "Content of file 1".toByteArray()
+ val file2 = "Content of file 2".toByteArray()
+ val files = listOf(file1, file2)
+
+ val zipped = files.zipped()
+
+ assertNotNull(zipped)
+ assertTrue(zipped.isNotEmpty())
+ }
+
+ @Test
+ fun `zipped should contain correct number of entries`() {
+ val file1 = "Content of file 1".toByteArray()
+ val file2 = "Content of file 2".toByteArray()
+ val file3 = "Content of file 3".toByteArray()
+ val files = listOf(file1, file2, file3)
+
+ val zipped = files.zipped()
+
+ val zipInputStream = ZipInputStream(ByteArrayInputStream(zipped))
+ var entryCount = 0
+ while (zipInputStream.nextEntry != null) {
+ entryCount++
+ }
+
+ assertEquals(3, entryCount)
+ }
+
+ @Test
+ fun `zipped should name files correctly`() {
+ val file1 = "Content of file 1".toByteArray()
+ val file2 = "Content of file 2".toByteArray()
+ val files = listOf(file1, file2)
+
+ val zipped = files.zipped()
+
+ val zipInputStream = ZipInputStream(ByteArrayInputStream(zipped))
+ val entry1 = zipInputStream.nextEntry
+ val entry2 = zipInputStream.nextEntry
+
+ assertEquals("file0.pdf", entry1.name)
+ assertEquals("file1.pdf", entry2.name)
+ }
+
+ @Test
+ fun `zipped should preserve content of files`() {
+ val content1 = "Content of file 1".toByteArray()
+ val content2 = "Content of file 2".toByteArray()
+ val files = listOf(content1, content2)
+
+ val zipped = files.zipped()
+
+ val zipInputStream = ZipInputStream(ByteArrayInputStream(zipped))
+ zipInputStream.nextEntry
+ val extractedContent1 = zipInputStream.readAllBytes()
+ zipInputStream.nextEntry
+ val extractedContent2 = zipInputStream.readAllBytes()
+
+ assertArrayEquals(content1, extractedContent1)
+ assertArrayEquals(content2, extractedContent2)
+ }
+
+ @Test
+ fun `zipped should handle empty list`() {
+ val files = emptyList()
+
+ val zipped = files.zipped()
+
+ assertNotNull(zipped)
+ assertTrue(zipped.isNotEmpty())
+ }
+
+ @Test
+ fun `zipped should handle single file`() {
+ val file = "Single file content".toByteArray()
+ val files = listOf(file)
+
+ val zipped = files.zipped()
+
+ val zipInputStream = ZipInputStream(ByteArrayInputStream(zipped))
+ var entryCount = 0
+ while (zipInputStream.nextEntry != null) {
+ entryCount++
+ }
+
+ assertEquals(1, entryCount)
+ }
+
+ @Test
+ fun `zipped should handle large files`() {
+ val largeContent = ByteArray(10000) { it.toByte() }
+ val files = listOf(largeContent)
+
+ val zipped = files.zipped()
+
+ assertNotNull(zipped)
+ assertTrue(zipped.isNotEmpty())
+ }
+}