diff --git a/package-lock.json b/package-lock.json index c7db927..d17091c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "eslint": "9", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", + "flatted": "^3.4.2", "globals": "^15.14.0", "prettier": "3.6.2", "tailwindcss": "4", @@ -4929,7 +4930,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4939,7 +4939,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -5635,7 +5635,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -6144,9 +6143,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8489,7 +8488,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { diff --git a/package.json b/package.json index 8a09914..09e1355 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "eslint": "9", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.18", + "flatted": "^3.4.2", "globals": "^15.14.0", "prettier": "3.6.2", "tailwindcss": "4", diff --git a/server/build.gradle b/server/build.gradle index 52b5213..3d3c084 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -79,7 +79,12 @@ dependencies { // Test Dependencies testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/server/src/test/java/de/tum/cit/memo/AbstractIntegrationTest.java b/server/src/test/java/de/tum/cit/memo/AbstractIntegrationTest.java new file mode 100644 index 0000000..bd750c3 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/AbstractIntegrationTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.memo; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class AbstractIntegrationTest { + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + TestPostgresContainer.registerProperties(registry); + } +} diff --git a/server/src/test/java/de/tum/cit/memo/TestPostgresContainer.java b/server/src/test/java/de/tum/cit/memo/TestPostgresContainer.java new file mode 100644 index 0000000..1e6e530 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/TestPostgresContainer.java @@ -0,0 +1,27 @@ +package de.tum.cit.memo; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.PostgreSQLContainer; + +@SuppressWarnings("resource") +public final class TestPostgresContainer { + + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("memo_test") + .withUsername("test") + .withPassword("test"); + + static { + POSTGRES.start(); + } + + private TestPostgresContainer() { + } + + public static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/AbstractRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/AbstractRepositoryTest.java new file mode 100644 index 0000000..d6f5328 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/AbstractRepositoryTest.java @@ -0,0 +1,24 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.TestPostgresContainer; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class AbstractRepositoryTest { + + @Autowired + protected EntityManager entityManager; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + TestPostgresContainer.registerProperties(registry); + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipRepositoryTest.java new file mode 100644 index 0000000..27ce41e --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipRepositoryTest.java @@ -0,0 +1,535 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.Competency; +import de.tum.cit.memo.entity.CompetencyRelationship; +import de.tum.cit.memo.entity.CompetencyRelationshipVote; +import de.tum.cit.memo.entity.User; +import de.tum.cit.memo.enums.RelationshipType; +import de.tum.cit.memo.enums.UserRole; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM competency_relationships", + "DELETE FROM competencies", + "DELETE FROM users" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class CompetencyRelationshipRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private CompetencyRelationshipRepository relationshipRepository; + + @Autowired + private CompetencyRelationshipVoteRepository voteRepository; + + @Autowired + private CompetencyRepository competencyRepository; + + @Autowired + private UserRepository userRepository; + + private Competency c1; + private Competency c2; + private Competency c3; + private String userId; + + @BeforeEach + void setUp() { + c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .build()); + c2 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 2") + .build()); + c3 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 3") + .build()); + userId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Test User") + .email("test@example.com") + .role(UserRole.USER) + .build()).getId(); + } + + private CompetencyRelationship createRelationship(String originId, String destinationId) { + return CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(originId) + .destinationId(destinationId) + .voteAssumes(0) + .voteExtends(0) + .voteMatches(0) + .voteUnrelated(0) + .entropy(0.0) + .totalVotes(0) + .build(); + } + + private CompetencyRelationshipVote createVote(String relationshipId, String votingUserId, RelationshipType type) { + return CompetencyRelationshipVote.builder() + .id(IdGenerator.generateCuid()) + .relationshipId(relationshipId) + .userId(votingUserId) + .relationshipType(type) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist relationship with all vote count fields") + void shouldPersistRelationshipWithVoteCounts() { + CompetencyRelationship relationship = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(c1.getId()) + .destinationId(c2.getId()) + .voteAssumes(3) + .voteExtends(1) + .voteMatches(0) + .voteUnrelated(2) + .entropy(1.5) + .totalVotes(6) + .build(); + + CompetencyRelationship saved = relationshipRepository.saveAndFlush(relationship); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getOriginId()).isEqualTo(c1.getId()); + assertThat(saved.getDestinationId()).isEqualTo(c2.getId()); + assertThat(saved.getVoteAssumes()).isEqualTo(3); + assertThat(saved.getVoteExtends()).isEqualTo(1); + assertThat(saved.getVoteMatches()).isEqualTo(0); + assertThat(saved.getVoteUnrelated()).isEqualTo(2); + assertThat(saved.getTotalVotes()).isEqualTo(6); + assertThat(saved.getEntropy()).isEqualTo(1.5); + } + + @Test + @DisplayName("should generate createdAt and updatedAt timestamps automatically") + void shouldGenerateTimestamps() { + CompetencyRelationship relationship = createRelationship(c1.getId(), c2.getId()); + + CompetencyRelationship saved = relationshipRepository.saveAndFlush(relationship); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("should enforce unique constraint on origin and destination") + void shouldEnforceUniqueOriginDestination() { + relationshipRepository.saveAndFlush(createRelationship(c1.getId(), c2.getId())); + + CompetencyRelationship duplicate = createRelationship(c1.getId(), c2.getId()); + + assertThatThrownBy(() -> relationshipRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("should allow same competencies in reversed direction") + void shouldAllowReversedDirection() { + relationshipRepository.saveAndFlush(createRelationship(c1.getId(), c2.getId())); + CompetencyRelationship reversed = createRelationship(c2.getId(), c1.getId()); + + CompetencyRelationship saved = relationshipRepository.saveAndFlush(reversed); + + assertThat(saved.getId()).isNotNull(); + } + + @Test + @DisplayName("should correctly compute entropy via recalculateEntropy") + void shouldComputeEntropyViaRecalculateEntropy() { + CompetencyRelationship relationship = createRelationship(c1.getId(), c2.getId()); + relationship.setVoteAssumes(1); + relationship.setVoteExtends(1); + relationship.setVoteMatches(1); + relationship.setVoteUnrelated(1); + relationship.recalculateEntropy(); + + CompetencyRelationship saved = relationshipRepository.saveAndFlush(relationship); + entityManager.refresh(saved); + + assertThat(saved.getTotalVotes()).isEqualTo(4); + assertThat(saved.getEntropy()).isGreaterThan(1.9); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing relationship by id") + void shouldFindExistingRelationship() { + CompetencyRelationship saved = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + Optional found = relationshipRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getOriginId()).isEqualTo(c1.getId()); + assertThat(found.get().getDestinationId()).isEqualTo(c2.getId()); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = relationshipRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findByOriginIdAndDestinationId") + class FindByOriginIdAndDestinationId { + + @Test + @DisplayName("should find relationship by origin and destination") + void shouldFindByOriginAndDestination() { + relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + Optional found = + relationshipRepository.findByOriginIdAndDestinationId(c1.getId(), c2.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getOriginId()).isEqualTo(c1.getId()); + assertThat(found.get().getDestinationId()).isEqualTo(c2.getId()); + } + + @Test + @DisplayName("should return empty when no matching relationship exists") + void shouldReturnEmptyWhenNotFound() { + Optional found = + relationshipRepository.findByOriginIdAndDestinationId(c1.getId(), c2.getId()); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("should return empty when direction is reversed") + void shouldReturnEmptyForReversedDirection() { + relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + Optional found = + relationshipRepository.findByOriginIdAndDestinationId(c2.getId(), c1.getId()); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("existsByOriginIdAndDestinationId") + class ExistsByOriginIdAndDestinationId { + + @Test + @DisplayName("should return true when relationship exists") + void shouldReturnTrueWhenExists() { + relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + boolean exists = relationshipRepository.existsByOriginIdAndDestinationId(c1.getId(), c2.getId()); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("should return false when relationship does not exist") + void shouldReturnFalseWhenNotExists() { + boolean exists = relationshipRepository.existsByOriginIdAndDestinationId(c1.getId(), c2.getId()); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("should return false when direction is reversed") + void shouldReturnFalseForReversedDirection() { + relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + boolean exists = relationshipRepository.existsByOriginIdAndDestinationId(c2.getId(), c1.getId()); + + assertThat(exists).isFalse(); + } + } + + @Nested + @DisplayName("findHighEntropyRelationshipsExcludingUser") + class FindHighEntropyRelationshipsExcludingUser { + + @Test + @DisplayName("should return relationships within vote range and entropy threshold") + void shouldReturnRelationshipsMatchingCriteria() { + CompetencyRelationship rel = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(c1.getId()) + .destinationId(c2.getId()) + .voteAssumes(2) + .voteExtends(2) + .voteMatches(0) + .voteUnrelated(0) + .totalVotes(4) + .entropy(1.0) + .build(); + relationshipRepository.saveAndFlush(rel); + + List result = relationshipRepository + .findHighEntropyRelationshipsExcludingUser(userId, 1, 10, 0.5, List.of(), PageRequest.of(0, 10)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(rel.getId()); + } + + @Test + @DisplayName("should exclude relationships where user has already voted") + void shouldExcludeVotedRelationships() { + CompetencyRelationship rel = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(c1.getId()) + .destinationId(c2.getId()) + .voteAssumes(2) + .voteExtends(2) + .voteMatches(0) + .voteUnrelated(0) + .totalVotes(4) + .entropy(1.0) + .build(); + relationshipRepository.saveAndFlush(rel); + voteRepository.saveAndFlush(createVote(rel.getId(), userId, RelationshipType.ASSUMES)); + + List result = relationshipRepository + .findHighEntropyRelationshipsExcludingUser(userId, 1, 10, 0.5, List.of(), PageRequest.of(0, 10)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should exclude relationships in skippedIds") + void shouldExcludeSkippedIds() { + CompetencyRelationship rel = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(c1.getId()) + .destinationId(c2.getId()) + .voteAssumes(2) + .voteExtends(2) + .voteMatches(0) + .voteUnrelated(0) + .totalVotes(4) + .entropy(1.0) + .build(); + relationshipRepository.saveAndFlush(rel); + + List result = relationshipRepository + .findHighEntropyRelationshipsExcludingUser(userId, 1, 10, 0.5, List.of(rel.getId()), PageRequest.of(0, 10)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should exclude relationships below entropy threshold") + void shouldExcludeLowEntropyRelationships() { + CompetencyRelationship rel = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(c1.getId()) + .destinationId(c2.getId()) + .voteAssumes(4) + .voteExtends(0) + .voteMatches(0) + .voteUnrelated(0) + .totalVotes(4) + .entropy(0.0) + .build(); + relationshipRepository.saveAndFlush(rel); + + List result = relationshipRepository + .findHighEntropyRelationshipsExcludingUser(userId, 1, 10, 0.5, List.of(), PageRequest.of(0, 10)); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findIntraPoolRelationships") + class FindIntraPoolRelationships { + + @Test + @DisplayName("should return relationships where both endpoints are in the pool") + void shouldReturnIntraPoolRelationships() { + CompetencyRelationship intra = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + relationshipRepository.save(createRelationship(c1.getId(), c3.getId())); + + List result = + relationshipRepository.findIntraPoolRelationships(List.of(c1.getId(), c2.getId())); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(intra.getId()); + } + + @Test + @DisplayName("should exclude relationships with endpoints outside the pool") + void shouldExcludeCrossPoolRelationships() { + relationshipRepository.save(createRelationship(c1.getId(), c3.getId())); + + List result = + relationshipRepository.findIntraPoolRelationships(List.of(c1.getId(), c2.getId())); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should return empty when pool has no internal relationships") + void shouldReturnEmptyForEmptyPool() { + List result = + relationshipRepository.findIntraPoolRelationships(List.of(c1.getId())); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findUnvotedByUserAndNotSkipped") + class FindUnvotedByUserAndNotSkipped { + + @Test + @DisplayName("should return relationships not yet voted by user") + void shouldReturnUnvotedRelationships() { + CompetencyRelationship rel = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + List result = relationshipRepository + .findUnvotedByUserAndNotSkipped(userId, List.of(), PageRequest.of(0, 10)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(rel.getId()); + } + + @Test + @DisplayName("should exclude relationships already voted by user") + void shouldExcludeVotedRelationships() { + CompetencyRelationship rel = relationshipRepository.saveAndFlush(createRelationship(c1.getId(), c2.getId())); + voteRepository.saveAndFlush(createVote(rel.getId(), userId, RelationshipType.MATCHES)); + + List result = relationshipRepository + .findUnvotedByUserAndNotSkipped(userId, List.of(), PageRequest.of(0, 10)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should exclude relationships in skippedIds") + void shouldExcludeSkippedIds() { + CompetencyRelationship rel = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + List result = relationshipRepository + .findUnvotedByUserAndNotSkipped(userId, List.of(rel.getId()), PageRequest.of(0, 10)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should include relationships voted by a different user") + void shouldIncludeRelationshipsVotedByOtherUser() { + String otherUserId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Other User") + .email("other@example.com") + .role(UserRole.USER) + .build()).getId(); + CompetencyRelationship rel = relationshipRepository.saveAndFlush(createRelationship(c1.getId(), c2.getId())); + voteRepository.saveAndFlush(createVote(rel.getId(), otherUserId, RelationshipType.EXTENDS)); + + List result = relationshipRepository + .findUnvotedByUserAndNotSkipped(userId, List.of(), PageRequest.of(0, 10)); + + assertThat(result).hasSize(1); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved relationships") + void shouldReturnAllRelationships() { + relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + relationshipRepository.save(createRelationship(c2.getId(), c3.getId())); + + List all = relationshipRepository.findAll(); + + assertThat(all).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no relationships exist") + void shouldReturnEmptyListWhenNoRelationships() { + List all = relationshipRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing relationship by id") + void shouldDeleteExistingRelationship() { + CompetencyRelationship saved = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())); + + relationshipRepository.deleteById(saved.getId()); + + assertThat(relationshipRepository.findById(saved.getId())).isEmpty(); + } + + @Test + @DisplayName("should cascade delete when origin competency is deleted") + void shouldCascadeDeleteWhenOriginCompetencyDeleted() { + String savedId = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())).getId(); + entityManager.flush(); + entityManager.clear(); + + competencyRepository.deleteById(c1.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(relationshipRepository.findById(savedId)).isEmpty(); + } + + @Test + @DisplayName("should cascade delete when destination competency is deleted") + void shouldCascadeDeleteWhenDestinationCompetencyDeleted() { + String savedId = relationshipRepository.save(createRelationship(c1.getId(), c2.getId())).getId(); + entityManager.flush(); + entityManager.clear(); + + competencyRepository.deleteById(c2.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(relationshipRepository.findById(savedId)).isEmpty(); + } + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepositoryTest.java new file mode 100644 index 0000000..3e0e394 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepositoryTest.java @@ -0,0 +1,343 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.Competency; +import de.tum.cit.memo.entity.CompetencyRelationship; +import de.tum.cit.memo.entity.CompetencyRelationshipVote; +import de.tum.cit.memo.entity.User; +import de.tum.cit.memo.enums.RelationshipType; +import de.tum.cit.memo.enums.UserRole; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.jdbc.Sql; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM competency_relationships", + "DELETE FROM competencies", + "DELETE FROM users" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class CompetencyRelationshipVoteRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private CompetencyRelationshipVoteRepository voteRepository; + + @Autowired + private CompetencyRelationshipRepository relationshipRepository; + + @Autowired + private CompetencyRepository competencyRepository; + + @Autowired + private UserRepository userRepository; + + private CompetencyRelationship relationship; + private String userId; + + @BeforeEach + void setUp() { + Competency origin = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Origin Competency") + .build()); + Competency destination = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Destination Competency") + .build()); + relationship = relationshipRepository.save(CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(origin.getId()) + .destinationId(destination.getId()) + .voteAssumes(0) + .voteExtends(0) + .voteMatches(0) + .voteUnrelated(0) + .entropy(0.0) + .totalVotes(0) + .build()); + userId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Test User") + .email("test@example.com") + .role(UserRole.USER) + .build()).getId(); + } + + private CompetencyRelationshipVote createVote(String relationshipId, String votingUserId, RelationshipType type) { + return CompetencyRelationshipVote.builder() + .id(IdGenerator.generateCuid()) + .relationshipId(relationshipId) + .userId(votingUserId) + .relationshipType(type) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist vote with all fields") + void shouldPersistVote() { + CompetencyRelationshipVote vote = createVote(relationship.getId(), userId, RelationshipType.ASSUMES); + + CompetencyRelationshipVote saved = voteRepository.saveAndFlush(vote); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getRelationshipId()).isEqualTo(relationship.getId()); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getRelationshipType()).isEqualTo(RelationshipType.ASSUMES); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("should generate createdAt timestamp automatically") + void shouldGenerateCreatedAtTimestamp() { + CompetencyRelationshipVote vote = createVote(relationship.getId(), userId, RelationshipType.EXTENDS); + + CompetencyRelationshipVote saved = voteRepository.saveAndFlush(vote); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("should enforce unique constraint on relationship and user") + void shouldEnforceUniqueRelationshipAndUser() { + voteRepository.saveAndFlush(createVote(relationship.getId(), userId, RelationshipType.MATCHES)); + + CompetencyRelationshipVote duplicate = createVote(relationship.getId(), userId, RelationshipType.EXTENDS); + + assertThatThrownBy(() -> voteRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("should allow same user to vote on different relationships") + void shouldAllowSameUserOnDifferentRelationships() { + Competency c3 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Third Competency") + .build()); + CompetencyRelationship rel2 = relationshipRepository.save(CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(relationship.getOriginId()) + .destinationId(c3.getId()) + .voteAssumes(0) + .voteExtends(0) + .voteMatches(0) + .voteUnrelated(0) + .entropy(0.0) + .totalVotes(0) + .build()); + + voteRepository.saveAndFlush(createVote(relationship.getId(), userId, RelationshipType.ASSUMES)); + CompetencyRelationshipVote second = voteRepository.saveAndFlush(createVote(rel2.getId(), userId, RelationshipType.EXTENDS)); + + assertThat(second.getId()).isNotNull(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing vote by id") + void shouldFindExistingVote() { + CompetencyRelationshipVote saved = voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.MATCHES)); + + Optional found = voteRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getRelationshipType()).isEqualTo(RelationshipType.MATCHES); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = voteRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("existsByRelationshipIdAndUserId") + class ExistsByRelationshipIdAndUserId { + + @Test + @DisplayName("should return true when vote exists") + void shouldReturnTrueWhenVoteExists() { + voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.ASSUMES)); + + boolean exists = voteRepository.existsByRelationshipIdAndUserId(relationship.getId(), userId); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("should return false when vote does not exist") + void shouldReturnFalseWhenVoteDoesNotExist() { + boolean exists = voteRepository.existsByRelationshipIdAndUserId(relationship.getId(), userId); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("should return false for different user") + void shouldReturnFalseForDifferentUser() { + String otherId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Other User") + .email("other@example.com") + .role(UserRole.USER) + .build()).getId(); + voteRepository.save(createVote(relationship.getId(), otherId, RelationshipType.ASSUMES)); + + boolean exists = voteRepository.existsByRelationshipIdAndUserId(relationship.getId(), userId); + + assertThat(exists).isFalse(); + } + } + + @Nested + @DisplayName("findByRelationshipIdAndUserId") + class FindByRelationshipIdAndUserId { + + @Test + @DisplayName("should find vote by relationship id and user id") + void shouldFindVoteByRelationshipAndUser() { + voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.UNRELATED)); + + Optional found = + voteRepository.findByRelationshipIdAndUserId(relationship.getId(), userId); + + assertThat(found).isPresent(); + assertThat(found.get().getRelationshipType()).isEqualTo(RelationshipType.UNRELATED); + } + + @Test + @DisplayName("should return empty when no matching vote exists") + void shouldReturnEmptyWhenNotFound() { + Optional found = + voteRepository.findByRelationshipIdAndUserId(relationship.getId(), userId); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findDailyVoteCountsWithPreWindowTotal") + class FindDailyVoteCountsWithPreWindowTotal { + + @Test + @DisplayName("should return daily count row and sentinel null-date row") + void shouldReturnDailyCountAndSentinelRow() { + voteRepository.saveAndFlush(createVote(relationship.getId(), userId, RelationshipType.ASSUMES)); + + Instant since = Instant.now().minus(1, ChronoUnit.HOURS); + List counts = voteRepository.findDailyVoteCountsWithPreWindowTotal(userId, since); + + assertThat(counts).isNotEmpty(); + + List dateCounts = counts.stream() + .filter(r -> r.getVoteDate() != null) + .toList(); + assertThat(dateCounts).hasSize(1); + assertThat(dateCounts.get(0).getVoteCount()).isEqualTo(1L); + + List sentinelRows = counts.stream() + .filter(r -> r.getVoteDate() == null) + .toList(); + assertThat(sentinelRows).hasSize(1); + assertThat(sentinelRows.get(0).getVoteCount()).isEqualTo(0L); + } + + @Test + @DisplayName("should return zero in date row when no votes in window") + void shouldReturnZeroWhenNoVotesInWindow() { + Instant since = Instant.now().plus(1, ChronoUnit.HOURS); + List counts = voteRepository.findDailyVoteCountsWithPreWindowTotal(userId, since); + + List dateCounts = counts.stream() + .filter(r -> r.getVoteDate() != null) + .toList(); + assertThat(dateCounts).isEmpty(); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved votes") + void shouldReturnAllVotes() { + String otherId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Other User") + .email("other@example.com") + .role(UserRole.USER) + .build()).getId(); + voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.ASSUMES)); + voteRepository.save(createVote(relationship.getId(), otherId, RelationshipType.EXTENDS)); + + List all = voteRepository.findAll(); + + assertThat(all).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no votes exist") + void shouldReturnEmptyListWhenNoVotes() { + List all = voteRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing vote by id") + void shouldDeleteExistingVote() { + CompetencyRelationshipVote saved = voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.MATCHES)); + + voteRepository.deleteById(saved.getId()); + + assertThat(voteRepository.findById(saved.getId())).isEmpty(); + } + + @Test + @DisplayName("should cascade delete when relationship is deleted") + void shouldCascadeDeleteWhenRelationshipDeleted() { + String voteId = voteRepository.save(createVote(relationship.getId(), userId, RelationshipType.ASSUMES)).getId(); + entityManager.flush(); + entityManager.clear(); + + relationshipRepository.deleteById(relationship.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(voteRepository.findById(voteId)).isEmpty(); + } + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/CompetencyRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRepositoryTest.java new file mode 100644 index 0000000..240da85 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/CompetencyRepositoryTest.java @@ -0,0 +1,415 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.Competency; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM competency_relationships", + "DELETE FROM competencies" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class CompetencyRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private CompetencyRepository competencyRepository; + + private Competency createCompetency(String title, String description) { + return Competency.builder() + .id(IdGenerator.generateCuid()) + .title(title) + .description(description) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist competency with all fields") + void shouldPersistCompetency() { + Competency competency = createCompetency("Java Programming", "Learn Java fundamentals"); + + Competency saved = competencyRepository.saveAndFlush(competency); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getTitle()).isEqualTo("Java Programming"); + assertThat(saved.getDescription()).isEqualTo("Learn Java fundamentals"); + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getDegree()).isZero(); + } + + @Test + @DisplayName("should persist competency without description") + void shouldPersistCompetencyWithoutDescription() { + Competency competency = createCompetency("Basic Competency", null); + + Competency saved = competencyRepository.saveAndFlush(competency); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getTitle()).isEqualTo("Basic Competency"); + assertThat(saved.getDescription()).isNull(); + } + + @Test + @DisplayName("should generate createdAt timestamp automatically") + void shouldGenerateCreatedAtTimestamp() { + Competency competency = createCompetency("Test Competency", "Description"); + + Competency saved = competencyRepository.saveAndFlush(competency); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing competency by id") + void shouldFindExistingCompetency() { + Competency competency = createCompetency("Test Competency", "Test Description"); + Competency saved = competencyRepository.save(competency); + + Optional found = competencyRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getTitle()).isEqualTo("Test Competency"); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = competencyRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findRandomCompetencies") + class FindRandomCompetencies { + + @Test + @DisplayName("should return requested number of random competencies") + void shouldReturnRequestedCount() { + for (int i = 0; i < 10; i++) { + competencyRepository.save(createCompetency("Competency " + i, "Description " + i)); + } + + List random = competencyRepository.findRandomCompetencies(5); + + assertThat(random).hasSize(5); + } + + @Test + @DisplayName("should return all competencies when count exceeds total") + void shouldReturnAllWhenCountExceedsTotal() { + competencyRepository.save(createCompetency("Competency 1", "Description 1")); + competencyRepository.save(createCompetency("Competency 2", "Description 2")); + + List random = competencyRepository.findRandomCompetencies(10); + + assertThat(random).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no competencies exist") + void shouldReturnEmptyWhenNoCompetencies() { + List random = competencyRepository.findRandomCompetencies(5); + + assertThat(random).isEmpty(); + } + } + + @Nested + @DisplayName("findRandomCompetencyIds") + class FindRandomCompetencyIds { + + @Test + @DisplayName("should return requested number of random competency ids") + void shouldReturnRequestedCount() { + for (int i = 0; i < 10; i++) { + competencyRepository.save(createCompetency("Competency " + i, "Description " + i)); + } + + List ids = competencyRepository.findRandomCompetencyIds(5); + + assertThat(ids).hasSize(5); + } + + @Test + @DisplayName("should return all ids when count exceeds total") + void shouldReturnAllWhenCountExceedsTotal() { + competencyRepository.save(createCompetency("Competency 1", "Description 1")); + competencyRepository.save(createCompetency("Competency 2", "Description 2")); + + List ids = competencyRepository.findRandomCompetencyIds(10); + + assertThat(ids).hasSize(2); + } + + @Test + @DisplayName("should return strings not competency objects") + void shouldReturnStrings() { + Competency saved = competencyRepository.save(createCompetency("Test", "Desc")); + + List ids = competencyRepository.findRandomCompetencyIds(1); + + assertThat(ids).hasSize(1); + assertThat(ids.get(0)).isEqualTo(saved.getId()); + } + + @Test + @DisplayName("should return empty list when no competencies exist") + void shouldReturnEmptyWhenNoCompetencies() { + List ids = competencyRepository.findRandomCompetencyIds(5); + + assertThat(ids).isEmpty(); + } + } + + @Nested + @DisplayName("findIdsByDegreeAsc") + class FindIdsByDegreeAsc { + + @Test + @DisplayName("should return ids ordered by degree ascending") + void shouldReturnIdsOrderedByDegreeAsc() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("High Degree") + .degree(5) + .build()); + Competency c2 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Low Degree") + .degree(1) + .build()); + Competency c3 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Mid Degree") + .degree(3) + .build()); + entityManager.flush(); + + List ids = competencyRepository.findIdsByDegreeAsc(PageRequest.of(0, 3)); + + assertThat(ids).containsExactly(c2.getId(), c3.getId(), c1.getId()); + } + + @Test + @DisplayName("should respect pageable limit") + void shouldRespectPageableLimit() { + for (int i = 0; i < 5; i++) { + competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency " + i) + .degree(i) + .build()); + } + entityManager.flush(); + + List ids = competencyRepository.findIdsByDegreeAsc(PageRequest.of(0, 2)); + + assertThat(ids).hasSize(2); + } + + @Test + @DisplayName("should return empty when no competencies exist") + void shouldReturnEmptyWhenNoCompetencies() { + List ids = competencyRepository.findIdsByDegreeAsc(PageRequest.of(0, 10)); + + assertThat(ids).isEmpty(); + } + } + + @Nested + @DisplayName("incrementDegree") + class IncrementDegree { + + @Test + @DisplayName("should increment degree for given ids") + void shouldIncrementDegree() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .degree(0) + .build()); + Competency c2 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 2") + .degree(2) + .build()); + entityManager.flush(); + + competencyRepository.incrementDegree(List.of(c1.getId(), c2.getId())); + + Competency updated1 = competencyRepository.findById(c1.getId()).orElseThrow(); + Competency updated2 = competencyRepository.findById(c2.getId()).orElseThrow(); + assertThat(updated1.getDegree()).isEqualTo(1); + assertThat(updated2.getDegree()).isEqualTo(3); + } + + @Test + @DisplayName("should only increment specified ids") + void shouldOnlyIncrementSpecifiedIds() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .degree(0) + .build()); + Competency c2 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 2") + .degree(0) + .build()); + entityManager.flush(); + + competencyRepository.incrementDegree(List.of(c1.getId())); + + Competency updated1 = competencyRepository.findById(c1.getId()).orElseThrow(); + Competency updated2 = competencyRepository.findById(c2.getId()).orElseThrow(); + assertThat(updated1.getDegree()).isEqualTo(1); + assertThat(updated2.getDegree()).isZero(); + } + } + + @Nested + @DisplayName("decrementDegree") + class DecrementDegree { + + @Test + @DisplayName("should decrement degree for given ids") + void shouldDecrementDegree() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .degree(3) + .build()); + entityManager.flush(); + + competencyRepository.decrementDegree(List.of(c1.getId())); + + Competency updated = competencyRepository.findById(c1.getId()).orElseThrow(); + assertThat(updated.getDegree()).isEqualTo(2); + } + + @Test + @DisplayName("should not go below zero (floor at 0)") + void shouldFloorAtZero() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .degree(0) + .build()); + entityManager.flush(); + + competencyRepository.decrementDegree(List.of(c1.getId())); + + Competency updated = competencyRepository.findById(c1.getId()).orElseThrow(); + assertThat(updated.getDegree()).isZero(); + } + + @Test + @DisplayName("should only decrement specified ids") + void shouldOnlyDecrementSpecifiedIds() { + Competency c1 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 1") + .degree(5) + .build()); + Competency c2 = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Competency 2") + .degree(5) + .build()); + entityManager.flush(); + + competencyRepository.decrementDegree(List.of(c1.getId())); + + Competency updated1 = competencyRepository.findById(c1.getId()).orElseThrow(); + Competency updated2 = competencyRepository.findById(c2.getId()).orElseThrow(); + assertThat(updated1.getDegree()).isEqualTo(4); + assertThat(updated2.getDegree()).isEqualTo(5); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved competencies") + void shouldReturnAllCompetencies() { + competencyRepository.save(createCompetency("Competency 1", "Description 1")); + competencyRepository.save(createCompetency("Competency 2", "Description 2")); + competencyRepository.save(createCompetency("Competency 3", "Description 3")); + + List all = competencyRepository.findAll(); + + assertThat(all).hasSize(3); + } + + @Test + @DisplayName("should return empty list when no competencies exist") + void shouldReturnEmptyListWhenNoCompetencies() { + List all = competencyRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing competency") + void shouldDeleteExistingCompetency() { + Competency competency = createCompetency("To Delete", "Will be deleted"); + Competency saved = competencyRepository.save(competency); + + competencyRepository.deleteById(saved.getId()); + + assertThat(competencyRepository.findById(saved.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("update") + class Update { + + @Test + @DisplayName("should update competency fields") + void shouldUpdateCompetencyFields() { + Competency competency = createCompetency("Original Title", "Original Description"); + Competency saved = competencyRepository.save(competency); + + saved.setTitle("Updated Title"); + saved.setDescription("Updated Description"); + Competency updated = competencyRepository.save(saved); + + assertThat(updated.getTitle()).isEqualTo("Updated Title"); + assertThat(updated.getDescription()).isEqualTo("Updated Description"); + assertThat(updated.getId()).isEqualTo(saved.getId()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/CompetencyResourceLinkRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/CompetencyResourceLinkRepositoryTest.java new file mode 100644 index 0000000..7bc288c --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/CompetencyResourceLinkRepositoryTest.java @@ -0,0 +1,218 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.Competency; +import de.tum.cit.memo.entity.CompetencyResourceLink; +import de.tum.cit.memo.entity.LearningResource; +import de.tum.cit.memo.entity.User; +import de.tum.cit.memo.enums.ResourceMatchType; +import de.tum.cit.memo.enums.UserRole; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM competency_relationships", + "DELETE FROM competencies", + "DELETE FROM learning_resources", + "DELETE FROM users" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class CompetencyResourceLinkRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private CompetencyResourceLinkRepository resourceLinkRepository; + + @Autowired + private CompetencyRepository competencyRepository; + + @Autowired + private LearningResourceRepository learningResourceRepository; + + @Autowired + private UserRepository userRepository; + + private Competency competency; + private LearningResource resource; + private String userId; + + @BeforeEach + void setUp() { + competency = competencyRepository.save(Competency.builder() + .id(IdGenerator.generateCuid()) + .title("Test Competency") + .build()); + resource = learningResourceRepository.save(LearningResource.builder() + .id(IdGenerator.generateCuid()) + .title("Test Resource") + .url("https://example.com") + .build()); + userId = userRepository.save(User.builder() + .id(IdGenerator.generateCuid()) + .name("Test User") + .email("test@example.com") + .role(UserRole.USER) + .build()).getId(); + } + + private CompetencyResourceLink createLink(ResourceMatchType matchType) { + return CompetencyResourceLink.builder() + .id(IdGenerator.generateCuid()) + .competencyId(competency.getId()) + .resourceId(resource.getId()) + .userId(userId) + .matchType(matchType) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist resource link with all fields") + void shouldPersistResourceLink() { + CompetencyResourceLink link = createLink(ResourceMatchType.GOOD_FIT); + + CompetencyResourceLink saved = resourceLinkRepository.saveAndFlush(link); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCompetencyId()).isEqualTo(competency.getId()); + assertThat(saved.getResourceId()).isEqualTo(resource.getId()); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getMatchType()).isEqualTo(ResourceMatchType.GOOD_FIT); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("should generate createdAt timestamp automatically") + void shouldGenerateCreatedAtTimestamp() { + CompetencyResourceLink link = createLink(ResourceMatchType.WEAK); + + CompetencyResourceLink saved = resourceLinkRepository.saveAndFlush(link); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing resource link by id") + void shouldFindExistingResourceLink() { + CompetencyResourceLink saved = resourceLinkRepository.save(createLink(ResourceMatchType.PERFECT_MATCH)); + + Optional found = resourceLinkRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getMatchType()).isEqualTo(ResourceMatchType.PERFECT_MATCH); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = resourceLinkRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved resource links") + void shouldReturnAllResourceLinks() { + resourceLinkRepository.save(createLink(ResourceMatchType.UNRELATED)); + resourceLinkRepository.save(createLink(ResourceMatchType.WEAK)); + resourceLinkRepository.save(createLink(ResourceMatchType.GOOD_FIT)); + + List all = resourceLinkRepository.findAll(); + + assertThat(all).hasSize(3); + } + + @Test + @DisplayName("should return empty list when no resource links exist") + void shouldReturnEmptyListWhenNoResourceLinks() { + List all = resourceLinkRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing resource link") + void shouldDeleteExistingResourceLink() { + CompetencyResourceLink saved = resourceLinkRepository.save(createLink(ResourceMatchType.WEAK)); + + resourceLinkRepository.deleteById(saved.getId()); + + assertThat(resourceLinkRepository.findById(saved.getId())).isEmpty(); + } + + @Test + @DisplayName("should cascade delete when competency is deleted") + void shouldCascadeDeleteWhenCompetencyDeleted() { + String savedId = resourceLinkRepository.save(createLink(ResourceMatchType.GOOD_FIT)).getId(); + entityManager.flush(); + entityManager.clear(); + + competencyRepository.deleteById(competency.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(resourceLinkRepository.findById(savedId)).isEmpty(); + } + + @Test + @DisplayName("should cascade delete when learning resource is deleted") + void shouldCascadeDeleteWhenLearningResourceDeleted() { + String savedId = resourceLinkRepository.save(createLink(ResourceMatchType.GOOD_FIT)).getId(); + entityManager.flush(); + entityManager.clear(); + + learningResourceRepository.deleteById(resource.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(resourceLinkRepository.findById(savedId)).isEmpty(); + } + } + + @Nested + @DisplayName("update") + class Update { + + @Test + @DisplayName("should update resource link match type") + void shouldUpdateMatchType() { + CompetencyResourceLink saved = resourceLinkRepository.save(createLink(ResourceMatchType.UNRELATED)); + + saved.setMatchType(ResourceMatchType.PERFECT_MATCH); + CompetencyResourceLink updated = resourceLinkRepository.save(saved); + + assertThat(updated.getMatchType()).isEqualTo(ResourceMatchType.PERFECT_MATCH); + assertThat(updated.getId()).isEqualTo(saved.getId()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/LearningResourceRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/LearningResourceRepositoryTest.java new file mode 100644 index 0000000..97a582c --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/LearningResourceRepositoryTest.java @@ -0,0 +1,235 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.LearningResource; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM learning_resources" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class LearningResourceRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private LearningResourceRepository learningResourceRepository; + + private LearningResource createResource(String title, String url) { + return LearningResource.builder() + .id(IdGenerator.generateCuid()) + .title(title) + .url(url) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist learning resource with all fields") + void shouldPersistLearningResource() { + LearningResource resource = createResource("Spring Boot Guide", "https://spring.io/guides"); + + LearningResource saved = learningResourceRepository.saveAndFlush(resource); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getTitle()).isEqualTo("Spring Boot Guide"); + assertThat(saved.getUrl()).isEqualTo("https://spring.io/guides"); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("should generate createdAt timestamp automatically") + void shouldGenerateCreatedAtTimestamp() { + LearningResource resource = createResource("Test Resource", "https://example.com"); + + LearningResource saved = learningResourceRepository.saveAndFlush(resource); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing resource by id") + void shouldFindExistingResource() { + LearningResource resource = createResource("Test Resource", "https://example.com"); + LearningResource saved = learningResourceRepository.save(resource); + + Optional found = learningResourceRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getTitle()).isEqualTo("Test Resource"); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = learningResourceRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findByUrl") + class FindByUrl { + + @Test + @DisplayName("should find resource by exact url match") + void shouldFindByExactUrl() { + LearningResource resource = createResource("Spring Docs", "https://docs.spring.io"); + learningResourceRepository.save(resource); + + Optional found = learningResourceRepository.findByUrl("https://docs.spring.io"); + + assertThat(found).isPresent(); + assertThat(found.get().getTitle()).isEqualTo("Spring Docs"); + } + + @Test + @DisplayName("should return empty for non-existent url") + void shouldReturnEmptyForNonExistentUrl() { + Optional found = learningResourceRepository.findByUrl("https://nonexistent.com"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("existsByUrl") + class ExistsByUrl { + + @Test + @DisplayName("should return true when url exists") + void shouldReturnTrueWhenUrlExists() { + LearningResource resource = createResource("Test", "https://exists.com"); + learningResourceRepository.save(resource); + + boolean exists = learningResourceRepository.existsByUrl("https://exists.com"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("should return false when url does not exist") + void shouldReturnFalseWhenUrlDoesNotExist() { + boolean exists = learningResourceRepository.existsByUrl("https://nonexistent.com"); + + assertThat(exists).isFalse(); + } + } + + @Nested + @DisplayName("findRandomLearningResources") + class FindRandomLearningResources { + + @Test + @DisplayName("should return requested number of random resources") + void shouldReturnRequestedCount() { + for (int i = 0; i < 10; i++) { + learningResourceRepository.save(createResource("Resource " + i, "https://example.com/" + i)); + } + + List random = learningResourceRepository.findRandomLearningResources(5); + + assertThat(random).hasSize(5); + } + + @Test + @DisplayName("should return all resources when count exceeds total") + void shouldReturnAllWhenCountExceedsTotal() { + learningResourceRepository.save(createResource("Resource 1", "https://example.com/1")); + learningResourceRepository.save(createResource("Resource 2", "https://example.com/2")); + + List random = learningResourceRepository.findRandomLearningResources(10); + + assertThat(random).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no resources exist") + void shouldReturnEmptyWhenNoResources() { + List random = learningResourceRepository.findRandomLearningResources(5); + + assertThat(random).isEmpty(); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved resources") + void shouldReturnAllResources() { + learningResourceRepository.save(createResource("Resource 1", "https://example.com/1")); + learningResourceRepository.save(createResource("Resource 2", "https://example.com/2")); + learningResourceRepository.save(createResource("Resource 3", "https://example.com/3")); + + List all = learningResourceRepository.findAll(); + + assertThat(all).hasSize(3); + } + + @Test + @DisplayName("should return empty list when no resources exist") + void shouldReturnEmptyListWhenNoResources() { + List all = learningResourceRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing resource") + void shouldDeleteExistingResource() { + LearningResource resource = createResource("To Delete", "https://delete.me"); + LearningResource saved = learningResourceRepository.save(resource); + + learningResourceRepository.deleteById(saved.getId()); + + assertThat(learningResourceRepository.findById(saved.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("update") + class Update { + + @Test + @DisplayName("should update resource fields") + void shouldUpdateResourceFields() { + LearningResource resource = createResource("Original Title", "https://original.com"); + LearningResource saved = learningResourceRepository.save(resource); + + saved.setTitle("Updated Title"); + saved.setUrl("https://updated.com"); + LearningResource updated = learningResourceRepository.save(saved); + + assertThat(updated.getTitle()).isEqualTo("Updated Title"); + assertThat(updated.getUrl()).isEqualTo("https://updated.com"); + assertThat(updated.getId()).isEqualTo(saved.getId()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/memo/repository/UserRepositoryTest.java b/server/src/test/java/de/tum/cit/memo/repository/UserRepositoryTest.java new file mode 100644 index 0000000..d6b6bc3 --- /dev/null +++ b/server/src/test/java/de/tum/cit/memo/repository/UserRepositoryTest.java @@ -0,0 +1,240 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.User; +import de.tum.cit.memo.enums.UserRole; +import de.tum.cit.memo.util.IdGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Sql(statements = { + "DELETE FROM competency_relationships_votes", + "DELETE FROM competency_resource_links", + "DELETE FROM competency_relationships", + "DELETE FROM users" +}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SuppressWarnings("null") +class UserRepositoryTest extends AbstractRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private User createUser(String name, String email, UserRole role) { + return User.builder() + .id(IdGenerator.generateCuid()) + .name(name) + .email(email) + .role(role) + .build(); + } + + @Nested + @DisplayName("save") + class Save { + + @Test + @DisplayName("should persist user with all fields") + void shouldPersistUser() { + User user = createUser("John Doe", "john@example.com", UserRole.USER); + + User saved = userRepository.saveAndFlush(user); + entityManager.refresh(saved); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getName()).isEqualTo("John Doe"); + assertThat(saved.getEmail()).isEqualTo("john@example.com"); + assertThat(saved.getRole()).isEqualTo(UserRole.USER); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("should persist user with ADMIN role") + void shouldPersistAdminUser() { + User user = createUser("Admin User", "admin@example.com", UserRole.ADMIN); + + User saved = userRepository.save(user); + + assertThat(saved.getRole()).isEqualTo(UserRole.ADMIN); + } + + @Test + @DisplayName("should enforce unique email constraint") + void shouldEnforceUniqueEmail() { + User user1 = createUser("User One", "same@example.com", UserRole.USER); + userRepository.saveAndFlush(user1); + + User user2 = createUser("User Two", "same@example.com", UserRole.USER); + + assertThatThrownBy(() -> userRepository.saveAndFlush(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("should generate createdAt timestamp automatically") + void shouldGenerateCreatedAtTimestamp() { + User user = createUser("Test User", "test@example.com", UserRole.USER); + + User saved = userRepository.saveAndFlush(user); + entityManager.refresh(saved); + + assertThat(saved.getCreatedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("should find existing user by id") + void shouldFindExistingUser() { + User user = createUser("Test User", "test@example.com", UserRole.USER); + User saved = userRepository.save(user); + + Optional found = userRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Test User"); + } + + @Test + @DisplayName("should return empty for non-existent id") + void shouldReturnEmptyForNonExistentId() { + Optional found = userRepository.findById("non-existent-id"); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findByEmail") + class FindByEmail { + + @Test + @DisplayName("should find user by exact email match") + void shouldFindByExactEmail() { + User user = createUser("Jane Doe", "jane@example.com", UserRole.USER); + userRepository.save(user); + + Optional found = userRepository.findByEmail("jane@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Jane Doe"); + } + + @Test + @DisplayName("should return empty for non-existent email") + void shouldReturnEmptyForNonExistentEmail() { + Optional found = userRepository.findByEmail("nonexistent@example.com"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("should be case-sensitive for email lookup") + void shouldBeCaseSensitive() { + User user = createUser("Test User", "Test@Example.com", UserRole.USER); + userRepository.save(user); + + Optional foundLower = userRepository.findByEmail("test@example.com"); + Optional foundExact = userRepository.findByEmail("Test@Example.com"); + + assertThat(foundLower).isEmpty(); + assertThat(foundExact).isPresent(); + } + } + + @Nested + @DisplayName("existsByEmail") + class ExistsByEmail { + + @Test + @DisplayName("should return true when email exists") + void shouldReturnTrueWhenEmailExists() { + User user = createUser("Test User", "exists@example.com", UserRole.USER); + userRepository.save(user); + + boolean exists = userRepository.existsByEmail("exists@example.com"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("should return false when email does not exist") + void shouldReturnFalseWhenEmailDoesNotExist() { + boolean exists = userRepository.existsByEmail("nonexistent@example.com"); + + assertThat(exists).isFalse(); + } + } + + @Nested + @DisplayName("findAll") + class FindAll { + + @Test + @DisplayName("should return all saved users") + void shouldReturnAllUsers() { + userRepository.save(createUser("User 1", "user1@example.com", UserRole.USER)); + userRepository.save(createUser("User 2", "user2@example.com", UserRole.USER)); + userRepository.save(createUser("Admin", "admin@example.com", UserRole.ADMIN)); + + List all = userRepository.findAll(); + + assertThat(all).hasSize(3); + } + + @Test + @DisplayName("should return empty list when no users exist") + void shouldReturnEmptyListWhenNoUsers() { + List all = userRepository.findAll(); + + assertThat(all).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("should delete existing user") + void shouldDeleteExistingUser() { + User user = createUser("To Delete", "delete@example.com", UserRole.USER); + User saved = userRepository.save(user); + + userRepository.deleteById(saved.getId()); + + assertThat(userRepository.findById(saved.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("update") + class Update { + + @Test + @DisplayName("should update user fields") + void shouldUpdateUserFields() { + User user = createUser("Original Name", "original@example.com", UserRole.USER); + User saved = userRepository.save(user); + + saved.setName("Updated Name"); + saved.setRole(UserRole.ADMIN); + User updated = userRepository.save(saved); + + assertThat(updated.getName()).isEqualTo("Updated Name"); + assertThat(updated.getRole()).isEqualTo(UserRole.ADMIN); + assertThat(updated.getId()).isEqualTo(saved.getId()); + } + } +} diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml new file mode 100644 index 0000000..bcda5aa --- /dev/null +++ b/server/src/test/resources/application-test.yml @@ -0,0 +1,24 @@ +spring: + jpa: + hibernate: + ddl-auto: validate + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration + +logging: + level: + root: WARN + de.tum.cit.memo: DEBUG + org.testcontainers: INFO