From 7459544218e6b0a6c11a1b8d3f4f10d98e53019b Mon Sep 17 00:00:00 2001 From: David Date: Thu, 19 Mar 2026 17:50:28 +0100 Subject: [PATCH 1/4] Test to check if the Creator is able to asign TAG to its own contents but not to contents from another creators --- .../udl/eps/softarch/demo/domain/Content.java | 19 ++- .../cat/udl/eps/softarch/demo/domain/Tag.java | 4 +- .../demo/steps/TagOwnershipStepDefs.java | 108 ++++++++++++++++++ src/test/resources/features/TagCreate.feature | 6 - src/test/resources/features/TagDelete.feature | 6 - .../resources/features/TagDuplicate.feature | 6 - .../resources/features/TagEmptyName.feature | 6 - .../resources/features/TagManagement.feature | 39 +++++++ 8 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java delete mode 100644 src/test/resources/features/TagCreate.feature delete mode 100644 src/test/resources/features/TagDelete.feature delete mode 100644 src/test/resources/features/TagDuplicate.feature delete mode 100644 src/test/resources/features/TagEmptyName.feature create mode 100644 src/test/resources/features/TagManagement.feature diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java index 2b5fbbfb..3f815f98 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java @@ -4,16 +4,9 @@ import com.fasterxml.jackson.annotation.JsonIdentityReference; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; //import jakarta.persistence.JoinColumn; //import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import lombok.Data; //import lombok.EqualsAndHashCode; /* @@ -36,10 +29,13 @@ public class Content { @Column(nullable = false) private Project project;*/ - /*@ManyToOne + @ManyToMany + private List tags; + + @ManyToOne + @JoinColumn(nullable = false) @JsonIdentityReference(alwaysAsId = true) - @Column(nullable = false) - private User user;*/ + private User user; @OneToMany(mappedBy = "content") @JsonIdentityReference(alwaysAsId = true) @@ -63,5 +59,4 @@ public class Content { @Enumerated(EnumType.STRING) @Column(nullable = false) private Visibility visibility; - } \ No newline at end of file diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java index 7fa8f913..675d3ca3 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Tag.java @@ -1,5 +1,6 @@ package cat.udl.eps.softarch.demo.domain; +import com.fasterxml.jackson.annotation.JsonIdentityReference; import jakarta.persistence.*; import lombok.Data; @@ -22,7 +23,4 @@ public class Tag { private ZonedDateTime created; private ZonedDateTime modified; - - //@ManyToMany(mappedBy = "tags") - //private Set contentSet = new HashSet<>(); } diff --git a/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java b/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java new file mode 100644 index 00000000..921df7c4 --- /dev/null +++ b/src/test/java/cat/udl/eps/softarch/demo/steps/TagOwnershipStepDefs.java @@ -0,0 +1,108 @@ +package cat.udl.eps.softarch.demo.steps; + +import cat.udl.eps.softarch.demo.domain.Tag; +import cat.udl.eps.softarch.demo.domain.Content; +import cat.udl.eps.softarch.demo.domain.User; +import cat.udl.eps.softarch.demo.domain.Visibility; +import cat.udl.eps.softarch.demo.repository.TagRepository; +import cat.udl.eps.softarch.demo.repository.ContentRepository; +import cat.udl.eps.softarch.demo.repository.UserRepository; + +import io.cucumber.java.en.*; +import jakarta.transaction.Transactional; + +import java.time.ZonedDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +public class TagOwnershipStepDefs { + + private final StepDefs stepDefs; + private final TagRepository tagRepository; + private final ContentRepository contentRepository; + private final UserRepository userRepository; + + private Tag tag; + private Content content; + + public TagOwnershipStepDefs(StepDefs stepDefs, + TagRepository tagRepository, + ContentRepository contentRepository, + UserRepository userRepository) { + this.stepDefs = stepDefs; + this.tagRepository = tagRepository; + this.contentRepository = contentRepository; + this.userRepository = userRepository; + } + + @Given("there is a content {string} created by {string}") + public void thereIsAContentCreatedBy(String contentName, String username) { + + User user = userRepository.findById(username) + .orElseThrow(() -> new RuntimeException("User not found")); + + content = new Content(); + content.setName(contentName); + content.setUser(user); + content.setCreatedAt(ZonedDateTime.now()); + content.setModifiedAt(ZonedDateTime.now()); + content.setVisibility(Visibility.PUBLIC); + + content = contentRepository.save(content); + } + + @When("I assign the tag {string} to content {string}") + public void iAssignTagToContent(String tagName, String contentName) throws Exception { + + Tag tagEntity = tagRepository.findByName(tagName) + .orElseThrow(() -> new RuntimeException("Tag not found")); + + Content contentEntity = contentRepository.findById(content.getContentId()) + .orElseThrow(() -> new RuntimeException("Content not found")); + + String authenticatedUsername = "creator1"; + + if (!contentEntity.getUser().getUsername().equals(authenticatedUsername)) { + stepDefs.result = stepDefs.mockMvc.perform( + post("/contents/" + contentEntity.getContentId() + "/tags") + .contentType("text/uri-list") + .content("/tags/" + tagEntity.getId()) + .with(user(authenticatedUsername)) + ).andDo(print()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden()); + return; + } + + stepDefs.result = stepDefs.mockMvc.perform( + post("/contents/" + contentEntity.getContentId() + "/tags") + .contentType("text/uri-list") + .content("/tags/" + tagEntity.getId()) + .with(user(authenticatedUsername)) + ).andDo(print()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNoContent()); + } + + @Then("The response status is {int}") + public void theResponseStatusIs(int status) throws Exception { + stepDefs.result.andExpect( + org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().is(status) + ); + } + + @Then("content {string} should contain tag {string}") + @Transactional + public void contentShouldContainTag(String contentName, String tagName) { + + Content updatedContent = contentRepository.findById(content.getContentId()) + .orElseThrow(() -> new RuntimeException("Content not found")); + + boolean exists = updatedContent.getTags() + .stream() + .anyMatch(t -> t.getName().equals(tagName)); + + assertTrue(exists, "Tag not assigned correctly to content"); + } +} \ No newline at end of file diff --git a/src/test/resources/features/TagCreate.feature b/src/test/resources/features/TagCreate.feature deleted file mode 100644 index 8180d31a..00000000 --- a/src/test/resources/features/TagCreate.feature +++ /dev/null @@ -1,6 +0,0 @@ -Feature: Tag management - - Scenario: Create a new tag - Given there are no tags in the system - When I create a tag with name "Futurist" and description "Content related to the future" - Then Then the tag is created successfully \ No newline at end of file diff --git a/src/test/resources/features/TagDelete.feature b/src/test/resources/features/TagDelete.feature deleted file mode 100644 index 9737bdc3..00000000 --- a/src/test/resources/features/TagDelete.feature +++ /dev/null @@ -1,6 +0,0 @@ -Feature: Tag management - - Scenario: Delete an existing tag - Given there is a tag with name "Futurist" - When I delete the tag with name "Futurist" - Then the tag should not exist in the system \ No newline at end of file diff --git a/src/test/resources/features/TagDuplicate.feature b/src/test/resources/features/TagDuplicate.feature deleted file mode 100644 index 6e11ede7..00000000 --- a/src/test/resources/features/TagDuplicate.feature +++ /dev/null @@ -1,6 +0,0 @@ -Feature: Tag management - -Scenario: Create a duplicated tag -Given there is already a tag with name "Futurist" - When I create a tag with name "Futurist" and description "Content related to the future" -Then the tag should not be stored in the system \ No newline at end of file diff --git a/src/test/resources/features/TagEmptyName.feature b/src/test/resources/features/TagEmptyName.feature deleted file mode 100644 index 74ad0c46..00000000 --- a/src/test/resources/features/TagEmptyName.feature +++ /dev/null @@ -1,6 +0,0 @@ -Feature: Tag management - -Scenario: Create a tag without name -Given there are no tags in the system -When I try to create a tag without a name -Then the tag should not be stored in the system \ No newline at end of file diff --git a/src/test/resources/features/TagManagement.feature b/src/test/resources/features/TagManagement.feature new file mode 100644 index 00000000..35e4a759 --- /dev/null +++ b/src/test/resources/features/TagManagement.feature @@ -0,0 +1,39 @@ +Feature: Tag management + + Scenario: Create a new tag + Given there are no tags in the system + When I create a tag with name "Futurist" and description "Content related to the future" + Then Then the tag is created successfully + + Scenario: Delete an existing tag + Given there is a tag with name "Futurist" + When I delete the tag with name "Futurist" + Then the tag should not exist in the system + + Scenario: Create a duplicated tag + Given there is already a tag with name "Futurist" + When I create a tag with name "Futurist" and description "Content related to the future" + Then the tag should not be stored in the system + + Scenario: Create a tag without name + Given there are no tags in the system + When I try to create a tag without a name + Then the tag should not be stored in the system + + Scenario: Creator assigns tag to their own content + Given There is a registered user with username "creator1" and password "pass" and email "c1@test.com" + And I can login with username "creator1" and password "pass" + And there is a tag with name "Futurist" + And there is a content "Post1" created by "creator1" + When I assign the tag "Futurist" to content "Post1" + Then The response status is 204 + And content "Post1" should contain tag "Futurist" + + Scenario: Creator cannot assign tag to another user's content + Given There is a registered user with username "creator1" and password "pass" and email "c1@test.com" + And There is a registered user with username "creator2" and password "pass" and email "c2@test.com" + And I can login with username "creator1" and password "pass" + And there is a tag with name "Futurist" + And there is a content "Post1" created by "creator2" + When I assign the tag "Futurist" to content "Post1" + Then The response status is 403 From 8a9efa0f31ff5518e404f57d9e85be4978d68945 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Apr 2026 17:58:37 +0200 Subject: [PATCH 2/4] Fixing some bugs with client side --- .../java/cat/udl/eps/softarch/demo/repository/TagRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java index 38a0a75c..2e72a960 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/TagRepository.java @@ -12,4 +12,5 @@ public interface TagRepository extends CrudRepository { boolean existsById(Long id); boolean existsByName(String name); Optional findByName(String name); + Optional findById(Long id); } From a67d659753d5114ba394e6b66ab5b6537a9c77f8 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 23 Apr 2026 17:51:05 +0200 Subject: [PATCH 3/4] Fixing some bugs with client side --- .../udl/eps/softarch/demo/domain/Content.java | 24 +++++++------------ .../demo/repository/ContentRepository.java | 1 + 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java index ffda1ea5..edde7ada 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Content.java @@ -4,16 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIdentityReference; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; +import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; @@ -38,13 +29,16 @@ public class Content { private Project project;*/ @ManyToMany + @JoinTable( + name = "content_tags", + joinColumns = @JoinColumn(name = "content_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) private List tags; @ManyToOne - @JoinColumn(nullable = false) - @JsonIdentityReference(alwaysAsId = true) - @Column(nullable = false) - private User user;*/ + @JoinColumn(name = "user_id") + private User user; @NotBlank(message = "Name cannot be empty") @Column(unique = true, nullable = false) @@ -57,10 +51,8 @@ public class Content { @Column(length = 100) private String description; - @Column(nullable = false, updatable = false) private ZonedDateTime createdAt; - @Column(nullable = false, updatable = false) private ZonedDateTime modifiedAt; @Enumerated(EnumType.STRING) diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java index da77a057..4e354176 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java @@ -19,6 +19,7 @@ public interface ContentRepository extends CrudRepository findAll(); + List findByTags_Id(Long tagId); //List findByProjectId(Long projectId); From ef24b23b7d882c50e6f4821dc26bc3d50ae7fdf4 Mon Sep 17 00:00:00 2001 From: arp137 <154314080+arp137@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:21:46 +0200 Subject: [PATCH 4/4] New endpoints in the server for the tag feature's --- .../demo/controller/ContentTagController.java | 144 ++++++++++++++++++ .../demo/repository/ContentRepository.java | 21 ++- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java diff --git a/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java b/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java new file mode 100644 index 00000000..c419ecf8 --- /dev/null +++ b/src/main/java/cat/udl/eps/softarch/demo/controller/ContentTagController.java @@ -0,0 +1,144 @@ +package cat.udl.eps.softarch.demo.controller; + +import cat.udl.eps.softarch.demo.domain.Content; +import cat.udl.eps.softarch.demo.domain.Tag; +import cat.udl.eps.softarch.demo.repository.ContentRepository; +import cat.udl.eps.softarch.demo.repository.TagRepository; +import jakarta.transaction.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@RestController +public class ContentTagController { + + private final ContentRepository contentRepository; + private final TagRepository tagRepository; + + public ContentTagController(ContentRepository contentRepository, TagRepository tagRepository) { + this.contentRepository = contentRepository; + this.tagRepository = tagRepository; + } + + @PutMapping(value = "/contents/{contentId}/tags", consumes = "text/uri-list") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity assignTags(@PathVariable Long contentId, @RequestBody String uriList) { + List tagIds = parseTagIds(uriList); + + Content content = contentRepository.findByContentIdWithTagsForUpdate(contentId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + if (content.getTags() == null) { + content.setTags(new ArrayList<>()); + } + + for (Long tagId : tagIds) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!content.getTags().contains(tag)) { + content.getTags().add(tag); + } + } + + contentRepository.save(content); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/contents/{contentId}/tags/{tagId}") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity removeTag(@PathVariable Long contentId, @PathVariable Long tagId) { + Content content = contentRepository.findByContentIdWithTagsForUpdate(contentId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + if (content.getTags() != null) { + content.getTags().removeIf(tag -> tagId.equals(tag.getId())); + contentRepository.save(content); + } + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/tags/{tagId}/delete") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity deleteTag(@PathVariable Long tagId) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + List taggedContents = contentRepository.findByTags_Id(tagId); + for (Content content : taggedContents) { + if (content.getTags() != null) { + content.getTags().removeIf(existingTag -> tagId.equals(existingTag.getId())); + } + } + contentRepository.saveAll(taggedContents); + tagRepository.delete(tag); + + return ResponseEntity.noContent().build(); + } + + @GetMapping({"/tags/{tagId}/available-contents", "/tags/{tagId}/avaiable-contents"}) + public ResponseEntity> getAvailableContents(@PathVariable Long tagId) { + tagRepository.findById(tagId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + return ResponseEntity.ok(contentRepository.findAvailableForTagId(tagId)); + } + + private List parseTagIds(String uriList) { + if (uriList == null || uriList.trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + List tagIds = Arrays.stream(uriList.split("\\R")) + .map(String::trim) + .filter(line -> !line.isEmpty()) + .filter(line -> !line.startsWith("#")) + .map(this::parseTagId) + .toList(); + + if (tagIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + return tagIds; + } + + private Long parseTagId(String tagUri) { + try { + URI uri = URI.create(tagUri); + String path = uri.getPath(); + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Tag URI has no path"); + } + + String[] segments = Arrays.stream(path.split("/")) + .filter(segment -> !segment.isBlank()) + .toArray(String[]::new); + if (segments.length < 2 || !"tags".equals(segments[segments.length - 2])) { + throw new IllegalArgumentException("Tag URI must point to /tags/{id}"); + } + + String id = segments[segments.length - 1]; + return Long.valueOf(id); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Malformed tag URI", ex); + } + } +} diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java index 4e354176..dc2db0fb 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/ContentRepository.java @@ -2,8 +2,13 @@ import cat.udl.eps.softarch.demo.domain.Content; //import cat.udl.eps.softarch.demo.domain.Visibility; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; import org.springframework.data.rest.core.annotation.RepositoryRestResource; import java.util.Optional; @@ -21,7 +26,21 @@ public interface ContentRepository extends CrudRepository findAll(); List findByTags_Id(Long tagId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = "tags") + @Query("select c from Content c where c.contentId = :contentId") + Optional findByContentIdWithTagsForUpdate(@Param("contentId") Long contentId); + + @Query(""" + select distinct c from Content c + where c.contentId not in ( + select tagged.contentId from Content tagged join tagged.tags t + where t.id = :tagId + ) + """) + List findAvailableForTagId(@Param("tagId") Long tagId); + //List findByProjectId(Long projectId); //List findByProjectIdAndVisibility(Long projectId, Visibility visibility); -} \ No newline at end of file +}