diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacade.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacade.java new file mode 100644 index 0000000000..5508cba239 --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacade.java @@ -0,0 +1,195 @@ +package uk.ac.cam.cl.dtg.isaac.api; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.Inject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.api.managers.BookmarksManager; +import uk.ac.cam.cl.dtg.isaac.dos.BookmarkDO; +import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentDTO; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentSummaryDTO; +import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO; +import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager; +import uk.ac.cam.cl.dtg.segue.auth.exceptions.NoUserLoggedInException; +import uk.ac.cam.cl.dtg.segue.dao.ILogManager; +import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException; +import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager; +import uk.ac.cam.cl.dtg.util.AbstractConfigLoader; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import java.util.Date; +import java.util.List; + +import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; + +/** + * Bookmarks Facade + * + * Responsible for handling requests related to user bookmarks. + */ +@Path("/bookmarks") +@Tag(name = "BookmarksFacade", description = "/bookmarks") +public class BookmarksFacade extends AbstractIsaacFacade { + private static final Logger log = LoggerFactory.getLogger(BookmarksFacade.class); + + private final UserAccountManager userManager; + private final GitContentManager contentManager; + private final BookmarksManager bookmarksManager; + + @Inject + public BookmarksFacade(final AbstractConfigLoader propertiesLoader, final ILogManager logManager, + final UserAccountManager userManager, final GitContentManager contentManager, + final BookmarksManager bookmarksManager) { + super(propertiesLoader, logManager); + + this.userManager = userManager; + this.contentManager = contentManager; + this.bookmarksManager = bookmarksManager; + } + + /** + * Gets a list of content bookmarked by the current user. + * + * @param request + * - so we can find the current user. + * @param contentType + * - the type of content to filter bookmarks by, or null to return all bookmarks. + * @return the list of content that the user has bookmarked. + */ + @GET + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get bookmarks for the current user.") + public final Response getCurrentUserBookmarks(@Context final HttpServletRequest request, + @QueryParam("content_type") final String contentType) { + RegisteredUserDTO user; + try { + user = userManager.getCurrentRegisteredUser(request); + } catch (final NoUserLoggedInException e) { + return SegueErrorResponse.getNotLoggedInResponse(); + } + + if (null != contentType && !contentType.isEmpty() + && !(contentType.equals("isaacQuestionPage") || contentType.equals("isaacConceptPage"))) { + log.warn("Invalid content type provided for bookmarks query: {}", contentType); + return new SegueErrorResponse(Status.BAD_REQUEST, "Only question and concept pages can be bookmarked!").toResponse(); + } + + List bookmarks = bookmarksManager.getAugmentedBookmarksForUser(user.getId(), contentType); + return Response.ok(bookmarks).build(); + } + + /** + * Adds a bookmark for the current user. + * + * @param request + * - so we can find the current user. + * @param contentId + * - the id of the content to bookmark. + */ + @POST + @Path("/{content_id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Add a bookmark for the current user.") + public final Response addCurrentUserBookmark(@Context final HttpServletRequest request, + @PathParam("content_id") final String contentId) { + RegisteredUserDTO user; + try { + user = userManager.getCurrentRegisteredUser(request); + } catch (final NoUserLoggedInException e) { + return SegueErrorResponse.getNotLoggedInResponse(); + } + + if (null == contentId || contentId.isEmpty()) { + return new SegueErrorResponse(Status.BAD_REQUEST, "Cannot create bookmark without content ID.").toResponse(); + } + + List currentBookmarks = bookmarksManager.getBookmarksForUser(user.getId(), null); + + if (currentBookmarks.size() >= MAXIMUM_BOOKMARKS) { + return new SegueErrorResponse(Status.BAD_REQUEST, "You already have the maximum number of bookmarks!.").toResponse(); + } + + if (currentBookmarks.stream().anyMatch(b -> b.contentId().equals(contentId))) { + return new SegueErrorResponse(Status.BAD_REQUEST, "You have already bookmarked this content.").toResponse(); + } + + String contentType; + try { + ContentDTO content = this.contentManager.getContentById(contentId); + contentType = content.getType(); + if (null == contentType || !(contentType.equals("isaacQuestionPage") || contentType.equals("isaacConceptPage"))) { + log.warn("Invalid content type provided for bookmarks query: {}", contentType); + return new SegueErrorResponse(Status.BAD_REQUEST, "Only question and concept pages can be bookmarked!").toResponse(); + } + + BookmarkDO bookmarkToAdd = new BookmarkDO(user.getId(), contentId, contentType, new Date()); + bookmarksManager.addBookmarkForUser(bookmarkToAdd); + + } catch (final ContentManagerException | NullPointerException e) { + log.warn("Failed to create bookmark, could not find content with ID: {}", contentId); + return new SegueErrorResponse(Status.NOT_FOUND, "Unable to find content to bookmark.").toResponse(); + } + + this.getLogManager().logEvent(user, request, IsaacServerLogType.ADD_BOOKMARK, + ImmutableMap.of(BOOKMARK_USER_ID_LOG_FIELDNAME, user.getId(), + BOOKMARK_CONTENT_ID_LOG_FIELDNAME, contentId, + BOOKMARK_CONTENT_TYPE_LOG_FIELDNAME, contentType)); + + return Response.ok().build(); + } + + /** + * Removes a bookmark for the current user. + * + * @param request + * - so we can find the current user. + * @param contentId + * - the id of the content to be removed from the user's bookmarks. + */ + @DELETE + @Path("/{content_id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Remove a bookmark for the current user.") + public final Response removeCurrentUserBookmark(@Context final HttpServletRequest request, + @PathParam("content_id") final String contentId) { + RegisteredUserDTO user; + try { + user = userManager.getCurrentRegisteredUser(request); + } catch (final NoUserLoggedInException e) { + return SegueErrorResponse.getNotLoggedInResponse(); + } + + if (null == contentId || contentId.isEmpty()) { + return new SegueErrorResponse(Status.BAD_REQUEST, "Cannot delete bookmark without content ID.").toResponse(); + } + + List currentBookmarks = bookmarksManager.getBookmarksForUser(user.getId(), null); + if (currentBookmarks.stream().noneMatch(b -> b.contentId().equals(contentId))) { + return new SegueErrorResponse(Status.BAD_REQUEST, "You have not bookmarked this content.").toResponse(); + } + + BookmarkDO bookmarkToRemove = new BookmarkDO(user.getId(), contentId, null, null); + bookmarksManager.removeBookmarkForUser(bookmarkToRemove); + + this.getLogManager().logEvent(user, request, IsaacServerLogType.DELETE_BOOKMARK, + ImmutableMap.of(BOOKMARK_USER_ID_LOG_FIELDNAME, user.getId(), + BOOKMARK_CONTENT_ID_LOG_FIELDNAME, contentId)); + + return Response.noContent().build(); + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java index aaec542028..8135e7aaae 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java @@ -133,6 +133,10 @@ public enum GameboardState { public static final String PAGE_ID_LOG_FIELDNAME = "pageId"; public static final String FRAGMENT_ID_LOG_FIELDNAME = "pageFragmentId"; public static final String DOCUMENT_PATH_LOG_FIELDNAME = "path"; + public static final String BOOKMARK_USER_ID_LOG_FIELDNAME = "userId"; + public static final String BOOKMARK_CONTENT_ID_LOG_FIELDNAME = "contentId"; + public static final String BOOKMARK_CONTENT_TYPE_LOG_FIELDNAME = "contentType"; + public static final Long DEFAULT_MISUSE_STATISTICS_LIMIT = 5L; @@ -146,8 +150,10 @@ public enum GameboardState { */ public enum IsaacServerLogType implements LogType { ADD_BOARD_TO_PROFILE, + ADD_BOOKMARK, CREATE_GAMEBOARD, DELETE_ASSIGNMENT, + DELETE_BOOKMARK, DELETE_QUIZ_ASSIGNMENT, DELETE_BOARD_FROM_PROFILE, DOWNLOAD_ASSIGNMENT_PROGRESS_CSV, @@ -233,6 +239,11 @@ public enum IsaacUserPreferences { */ public static final long QUIZ_VIEW_STUDENT_ANSWERS_RELEASE_TIMESTAMP = Date.UTC(123, Calendar.JUNE, 12, 0, 0, 0); // 12/06/2023 + /** + * Bookmarks + */ + public static final int MAXIMUM_BOOKMARKS = 100; + /** * Feedback messages */ diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java index 1bad01ce9c..bba638e41e 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java @@ -23,6 +23,7 @@ import org.jboss.resteasy.annotations.GZIP; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.api.managers.BookmarksManager; import uk.ac.cam.cl.dtg.isaac.api.managers.GameManager; import uk.ac.cam.cl.dtg.isaac.api.managers.URIManager; import uk.ac.cam.cl.dtg.isaac.api.managers.UserAttemptManager; @@ -109,6 +110,7 @@ public class PagesFacade extends AbstractIsaacFacade { private final GitContentManager contentManager; private final UserAttemptManager userAttemptManager; private final GameManager gameManager; + private final BookmarksManager bookmarksManager; /** * Creates an instance of the pages controller which provides the REST endpoints for accessing page content. @@ -131,13 +133,15 @@ public class PagesFacade extends AbstractIsaacFacade { * - So we can look up attempt information. * @param gameManager * - For looking up gameboard information. + * @param bookmarksManager + * - For looking up bookmark information. */ @Inject public PagesFacade(final ContentService api, final AbstractConfigLoader propertiesLoader, final ILogManager logManager, final MainMapper mapper, final GitContentManager contentManager, final UserAccountManager userManager, final URIManager uriManager, final QuestionManager questionManager, final GameManager gameManager, - final UserAttemptManager userAttemptManager) { + final UserAttemptManager userAttemptManager, final BookmarksManager bookmarksManager) { super(propertiesLoader, logManager); this.api = api; this.mapper = mapper; @@ -147,6 +151,7 @@ public PagesFacade(final ContentService api, final AbstractConfigLoader properti this.questionManager = questionManager; this.gameManager = gameManager; this.userAttemptManager = userAttemptManager; + this.bookmarksManager = bookmarksManager; } /** @@ -509,6 +514,10 @@ public final Response getQuestionList(@Context final HttpServletRequest httpServ } } + if (user instanceof RegisteredUserDTO registeredUser) { + summarizedResults = bookmarksManager.augmentContentSummaryListWithBookmarkInformation(registeredUser.getId(), summarizedResults); + } + if (limit < 0 || combinedResults.size() + summarizedResults.size() <= limit) { combinedResults.addAll(summarizedResults); nextSearchStartIndex += unfilteredSummarizedResults.size(); diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/BookmarksManager.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/BookmarksManager.java new file mode 100644 index 0000000000..2eefd8b5fd --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/BookmarksManager.java @@ -0,0 +1,145 @@ +package uk.ac.cam.cl.dtg.isaac.api.managers; + +import com.google.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.dao.IBookmarks; +import uk.ac.cam.cl.dtg.isaac.dos.BookmarkDO; +import uk.ac.cam.cl.dtg.isaac.dto.ResultsWrapper; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentDTO; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentSummaryDTO; +import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException; +import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager; +import uk.ac.cam.cl.dtg.util.mappers.MainMapper; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A class to augment content with bookmark information, and bookmarks with content information. + */ +public class BookmarksManager { + private static final Logger log = LoggerFactory.getLogger(BookmarksManager.class); + + private final IBookmarks bookmarksDbManager; + private final GitContentManager contentManager; + private final MainMapper mapper; + + /** + * Fully injected constructor. + * + * @param bookmarksDbManager the bookmarks database manager for retrieving bookmark information. + * @param contentManager the content manager for retrieving content information. + * @param mapper the mapper for mapping content to content summaries. + */ + @Inject + public BookmarksManager(final IBookmarks bookmarksDbManager, final GitContentManager contentManager, final MainMapper mapper) { + this.bookmarksDbManager = bookmarksDbManager; + this.contentManager = contentManager; + this.mapper = mapper; + } + + /** + * Augment a list of content summaries with bookmark information for a given user. + * + * @param userId the id of the user to augment the content summary list for. + * @param contentSummaries the content summary list to augment. + * @return the augmented content summary list. + */ + public List augmentContentSummaryListWithBookmarkInformation(final Long userId, + final List contentSummaries) { + List bookmarks = this.bookmarksDbManager.getBookmarksForUser(userId); + return augmentContentSummaryListWithBookmarkInformation(bookmarks, contentSummaries); + } + + /** + * Augment a list of content summaries with bookmark information. + * + * @param bookmarks the bookmarks to augment the content summaries with. + * @param contentSummaries the content summary list to augment. + * @return the augmented content summary list. + */ + public List augmentContentSummaryListWithBookmarkInformation(final List bookmarks, + final List contentSummaries) { + if (!bookmarks.isEmpty()) { + Map bookmarkMap = new HashMap<>(); + for (BookmarkDO bookmark : bookmarks) { + bookmarkMap.put(bookmark.contentId(), bookmark.created()); + } + + for (ContentSummaryDTO contentSummary : contentSummaries) { + Date created = bookmarkMap.get(contentSummary.getId()); + if (created != null) { + contentSummary.setBookmarked(created); + } + } + } + return contentSummaries; + } + + /** + * Map a list of bookmarks to a list of content summaries. + * + * @param bookmarks the list of bookmarks to map. + * @return the list of content summaries corresponding to the bookmarks. + */ + public List mapBookmarkListToContentSummaryList(final List bookmarks) { + List bookmarkIDs = bookmarks.stream().map(BookmarkDO::contentId).toList(); + ResultsWrapper content; + + try { + content = this.contentManager.getUnsafeCachedContentDTOsMatchingIds(bookmarkIDs, 0, bookmarkIDs.size()); + } catch (final ContentManagerException e) { + content = new ResultsWrapper<>(); + log.error("Unable to locate content for bookmark", e); + } + + List contentSummaries = content.getResults().stream().map(this.mapper::mapContentDTOtoContentSummaryDTO).toList(); + contentSummaries = augmentContentSummaryListWithBookmarkInformation(bookmarks, contentSummaries); + + return contentSummaries; + } + + /** + * Get a list of bookmarks associated with a user + * + * @param userId the ID of the user to return bookmarks for. + * @param contentType the type of content to filter bookmarks by, or null to return all bookmarks. + * @return the bookmarks associated with the user. + */ + public List getBookmarksForUser(final Long userId, final String contentType) { + return this.bookmarksDbManager.getBookmarksForUser(userId, contentType); + } + + /** + * Get a list of bookmarks associated with a user, augmented with content information + * + * @param userId the ID of the user to return bookmarks for. + * @param contentType the type of content to filter bookmarks by, or null to return all bookmarks. + * @return the bookmarks associated with the user, augmented with content information + */ + public List getAugmentedBookmarksForUser(final Long userId, final String contentType) { + List bookmarks = this.bookmarksDbManager.getBookmarksForUser(userId, contentType); + return this.mapBookmarkListToContentSummaryList(bookmarks); + } + + /** + * Save a bookmark to a user's account + * + * @param bookmark the bookmark to save + */ + public void addBookmarkForUser(final BookmarkDO bookmark) { + this.bookmarksDbManager.addBookmarkForUser(bookmark); + } + + /** + * Remove a bookmark from a user's account + * + * @param bookmark the bookmark to remove + */ + public void removeBookmarkForUser(final BookmarkDO bookmark) { + this.bookmarksDbManager.removeBookmarkForUser(bookmark); + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/configuration/IsaacApplicationRegister.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/configuration/IsaacApplicationRegister.java index d8f0faef54..a540be9203 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/configuration/IsaacApplicationRegister.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/configuration/IsaacApplicationRegister.java @@ -28,6 +28,7 @@ import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; import uk.ac.cam.cl.dtg.isaac.api.AssignmentFacade; +import uk.ac.cam.cl.dtg.isaac.api.BookmarksFacade; import uk.ac.cam.cl.dtg.isaac.api.EventsFacade; import uk.ac.cam.cl.dtg.isaac.api.GameboardsFacade; import uk.ac.cam.cl.dtg.isaac.api.IsaacController; @@ -121,6 +122,7 @@ public final Set getSingletons() { this.singletons.add(injector.getInstance(NotificationFacade.class)); this.singletons.add(injector.getInstance(EmailFacade.class)); this.singletons.add(injector.getInstance(QuizFacade.class)); + this.singletons.add(injector.getInstance(BookmarksFacade.class)); // initialise filters this.singletons.add(injector.getInstance(PerformanceMonitor.class)); diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/IBookmarks.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/IBookmarks.java new file mode 100644 index 0000000000..93f216a65f --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/IBookmarks.java @@ -0,0 +1,18 @@ +package uk.ac.cam.cl.dtg.isaac.dao; + +import uk.ac.cam.cl.dtg.isaac.dos.BookmarkDO; + +import java.util.List; + +/** + * Interface for managing and persisting user bookmarks. + */ +public interface IBookmarks { + List getBookmarksForUser(Long userId); + + List getBookmarksForUser(Long userId, String contentType); + + void addBookmarkForUser(BookmarkDO bookmark); + + void removeBookmarkForUser(BookmarkDO bookmark); +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/PgBookmarks.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/PgBookmarks.java new file mode 100644 index 0000000000..1963110e11 --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dao/PgBookmarks.java @@ -0,0 +1,109 @@ +package uk.ac.cam.cl.dtg.isaac.dao; + +import com.google.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.dos.BookmarkDO; +import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; +import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; + +/** + * Postgres implementation for managing and persisting bookmarks data. + */ +public class PgBookmarks implements IBookmarks { + private static final Logger log = LoggerFactory.getLogger(PgBookmarks.class); + + private final PostgresSqlDb database; + + /** + * PgBookmarks. + * + * @param database client for postgres. + */ + @Inject + public PgBookmarks(final PostgresSqlDb database) { + this.database = database; + } + + @Override + public List getBookmarksForUser(final Long userId) { + return this.getBookmarksForUser(userId, null); + } + + @Override + public List getBookmarksForUser(final Long userId, final String contentType) { + + String query = "SELECT content_id, created FROM user_bookmarks WHERE user_id = ?"; + + boolean filterByContentType = false; + if (null != contentType && !contentType.isEmpty()) { + query += " AND content_type = ?"; + filterByContentType = true; + } + + List bookmarks = new ArrayList<>(); + + try (Connection conn = database.getDatabaseConnection(); + PreparedStatement pst = conn.prepareStatement(query); + ) { + pst.setLong(1, userId); + + if (filterByContentType) { + pst.setString(2, contentType); + } + + ResultSet results = pst.executeQuery(); + while (results.next()) { + String contentId = results.getString("content_id"); + Timestamp created = results.getTimestamp("created"); + bookmarks.add(new BookmarkDO(userId, contentId, contentType, created)); + } + } catch (final SQLException e) { + log.error("Database error saving bookmark!", e); + } + return bookmarks; + } + + @Override + public void addBookmarkForUser(final BookmarkDO bookmark) { + + String query = "INSERT INTO user_bookmarks (user_id, content_id, content_type, created) VALUES (?, ?, ?, ?)"; + try (Connection conn = database.getDatabaseConnection(); + PreparedStatement pst = conn.prepareStatement(query); + ) { + pst.setLong(1, bookmark.userId()); + pst.setString(2, bookmark.contentId()); + pst.setString(3, bookmark.contentType()); + pst.setTimestamp(4, new Timestamp(bookmark.created().getTime())); + if (pst.executeUpdate() == 0) { + throw new SegueDatabaseException("Unable to save bookmark."); + } + } catch (final SQLException | SegueDatabaseException e) { + log.error("Database error saving bookmark!", e); + } + } + + @Override + public void removeBookmarkForUser(final BookmarkDO bookmark) { + String query = "DELETE FROM user_bookmarks WHERE user_id = ? AND content_id = ?"; + try (Connection conn = database.getDatabaseConnection(); + PreparedStatement pst = conn.prepareStatement(query); + ) { + pst.setLong(1, bookmark.userId()); + pst.setString(2, bookmark.contentId()); + if (pst.executeUpdate() == 0) { + throw new SegueDatabaseException("Unable to remove bookmark."); + } + } catch (final SQLException | SegueDatabaseException e) { + log.error("Database error saving bookmark!", e); + } + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/BookmarkDO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/BookmarkDO.java new file mode 100644 index 0000000000..435ceefa76 --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/BookmarkDO.java @@ -0,0 +1,8 @@ +package uk.ac.cam.cl.dtg.isaac.dos; + +import java.util.Date; + +/** + * DO representing a bookmarked piece of content. + */ +public record BookmarkDO(Long userId, String contentId, String contentType, Date created) {} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/ContentSummaryDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/ContentSummaryDTO.java index b1d7c957ce..1b8a9956ae 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/ContentSummaryDTO.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/ContentSummaryDTO.java @@ -20,6 +20,7 @@ import uk.ac.cam.cl.dtg.isaac.api.Constants.CompletionState; import uk.ac.cam.cl.dtg.isaac.dos.AudienceContext; +import java.util.Date; import java.util.List; /** @@ -37,6 +38,7 @@ public class ContentSummaryDTO { private List tags; private String url; private CompletionState state; + private Date bookmarked; private List questionPartIds; private String supersededBy; private Boolean deprecated; @@ -222,6 +224,25 @@ public void setState(final CompletionState state) { this.state = state; } + /** + * Gets the timestamp this content was bookmarked by the user. + * + * @return the timestamp this content was bookmarked, or null if not bookmarked + */ + public Date getBookmarked() { + return bookmarked; + } + + /** + * Sets the timestamp this content was bookmarked by the user. + * + * @param bookmarked + * the timestamp this content was bookmarked + */ + public void setBookmarked(final Date bookmarked) { + this.bookmarked = bookmarked; + } + /** * Gets a list of the question part IDs. * diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index b4cc4478f2..398b5fa9b6 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -46,10 +46,12 @@ import uk.ac.cam.cl.dtg.isaac.api.services.GroupChangedService; import uk.ac.cam.cl.dtg.isaac.dao.GameboardPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IAssignmentPersistenceManager; +import uk.ac.cam.cl.dtg.isaac.dao.IBookmarks; import uk.ac.cam.cl.dtg.isaac.dao.IQuizAssignmentPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IQuizAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IQuizQuestionAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgAssignmentPersistenceManager; +import uk.ac.cam.cl.dtg.isaac.dao.PgBookmarks; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizAssignmentPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizQuestionAttemptPersistenceManager; @@ -438,6 +440,8 @@ private void configureApplicationManagers() { bind(IUserStreaksManager.class).to(PgUserStreakManager.class); + bind(IBookmarks.class).to(PgBookmarks.class); + bind(IStatisticsManager.class).to(StatisticsManager.class); bind(ITransactionManager.class).to(PgTransactionManager.class); diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java index f904d22317..304fe42d78 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java @@ -257,6 +257,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "url", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "difficulty", ignore = true) DetailedQuizSummaryDTO mapToDetailedQuizSummaryDTO(IsaacQuizDTO source); @@ -265,6 +266,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "supersededBy", ignore = true) @Mapping(target = "summary", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "difficulty", ignore = true) @Mapping(target = "deprecated", ignore = true) @@ -273,6 +275,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "url", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "difficulty", ignore = true) ContentSummaryDTO mapSeguePageDTOtoContentSummaryDTO(SeguePageDTO source); @@ -312,6 +315,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "supersededBy", ignore = true) @Mapping(target = "summary", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "hiddenFromRoles", ignore = true) @Mapping(target = "difficulty", ignore = true) @@ -321,6 +325,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "url", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "difficulty", ignore = true) @Named("mapQuizDTOtoQuizSummaryDTO") @@ -330,6 +335,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "supersededBy", ignore = true) @Mapping(target = "summary", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "rubric", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "hiddenFromRoles", ignore = true) @@ -354,6 +360,7 @@ default T map(ContentDTO source, Class targetClass) { @Mapping(target = "summary", ignore = true) @Mapping(target = "subtitle", ignore = true) @Mapping(target = "state", ignore = true) + @Mapping(target = "bookmarked", ignore = true) @Mapping(target = "questionPartIds", ignore = true) @Mapping(target = "level", ignore = true) @Mapping(target = "difficulty", ignore = true) diff --git a/src/main/resources/db_scripts/migrations/2026-04-create-bookmarks-table.sql b/src/main/resources/db_scripts/migrations/2026-04-create-bookmarks-table.sql new file mode 100644 index 0000000000..48b81b7c92 --- /dev/null +++ b/src/main/resources/db_scripts/migrations/2026-04-create-bookmarks-table.sql @@ -0,0 +1,17 @@ +-- Table: public.user_bookmarks + +-- DROP TABLE public.user_bookmarks; + +CREATE TABLE public.user_bookmarks( + user_id integer NOT NULL, + content_id text NOT NULL, + content_type text NOT NULL, + created timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT user_bookmarks_pk PRIMARY KEY (user_id, content_id), + CONSTRAINT user_bookmarks_user_id_fk FOREIGN KEY (user_id) + REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE +); + +ALTER TABLE public.user_bookmarks + owner to rutherford; + diff --git a/src/main/resources/db_scripts/postgres-rutherford-create-script.sql b/src/main/resources/db_scripts/postgres-rutherford-create-script.sql index 2e64f9fd1b..db99928ea9 100644 --- a/src/main/resources/db_scripts/postgres-rutherford-create-script.sql +++ b/src/main/resources/db_scripts/postgres-rutherford-create-script.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 16.2 (Debian 16.2-1.pgdg120+2) --- Dumped by pg_dump version 16.2 (Debian 16.2-1.pgdg120+2) +-- Dumped from database version 18.3 +-- Dumped by pg_dump version 18.3 SET statement_timeout = 0; SET lock_timeout = 0; @@ -50,7 +50,6 @@ CREATE TABLE public.archived_users ( ALTER TABLE public.archived_users OWNER TO rutherford; - -- -- Name: assignments; Type: TABLE; Schema: public; Owner: rutherford -- @@ -501,7 +500,6 @@ CREATE TABLE public.scheduled_emails ( ALTER TABLE public.scheduled_emails OWNER TO rutherford; - -- -- Name: temporary_user_store; Type: TABLE; Schema: public; Owner: rutherford -- @@ -587,6 +585,20 @@ CREATE TABLE public.user_associations_tokens ( ALTER TABLE public.user_associations_tokens OWNER TO rutherford; +-- +-- Name: user_bookmarks; Type: TABLE; Schema: public; Owner: rutherford +-- + +CREATE TABLE public.user_bookmarks ( + user_id integer NOT NULL, + content_id text NOT NULL, + content_type text NOT NULL, + created timestamp with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public.user_bookmarks OWNER TO rutherford; + -- -- Name: user_credentials; Type: TABLE; Schema: public; Owner: rutherford -- @@ -1045,6 +1057,14 @@ ALTER TABLE ONLY public.user_associations ADD CONSTRAINT user_associations_composite_pkey PRIMARY KEY (user_id_granting_permission, user_id_receiving_permission); +-- +-- Name: user_bookmarks user_bookmarks_pk; Type: CONSTRAINT; Schema: public; Owner: rutherford +-- + +ALTER TABLE ONLY public.user_bookmarks + ADD CONSTRAINT user_bookmarks_pk PRIMARY KEY (user_id, content_id); + + -- -- Name: user_gameboards user_gameboard_composite_key; Type: CONSTRAINT; Schema: public; Owner: rutherford -- @@ -1435,6 +1455,14 @@ ALTER TABLE ONLY public.user_associations_tokens ADD CONSTRAINT token_owner_user_id FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: user_bookmarks user_bookmarks_user_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: rutherford +-- + +ALTER TABLE ONLY public.user_bookmarks + ADD CONSTRAINT user_bookmarks_user_id_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE; + + -- -- Name: user_associations user_granting_permission_fkey; Type: FK CONSTRAINT; Schema: public; Owner: rutherford -- diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java index c359ae840f..273c8ef934 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java @@ -16,6 +16,7 @@ import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.MountableFile; import uk.ac.cam.cl.dtg.isaac.api.managers.AssignmentManager; +import uk.ac.cam.cl.dtg.isaac.api.managers.BookmarksManager; import uk.ac.cam.cl.dtg.isaac.api.managers.EventBookingManager; import uk.ac.cam.cl.dtg.isaac.api.managers.EventsManager; import uk.ac.cam.cl.dtg.isaac.api.managers.FastTrackManger; @@ -32,10 +33,12 @@ import uk.ac.cam.cl.dtg.isaac.dao.EventBookingPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.GameboardPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IAssignmentPersistenceManager; +import uk.ac.cam.cl.dtg.isaac.dao.IBookmarks; import uk.ac.cam.cl.dtg.isaac.dao.IQuizAssignmentPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IQuizAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.IQuizQuestionAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgAssignmentPersistenceManager; +import uk.ac.cam.cl.dtg.isaac.dao.PgBookmarks; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizAssignmentPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizAttemptPersistenceManager; import uk.ac.cam.cl.dtg.isaac.dao.PgQuizQuestionAttemptPersistenceManager; @@ -146,6 +149,7 @@ public class AbstractIsaacIntegrationTest { protected static PgPasswordDataManager passwordDataManager; protected static UserAttemptManager userAttemptManager; protected static FastTrackManger fastTrackManger; + protected static BookmarksManager bookmarksManager; // Manager dependencies protected static IQuizAssignmentPersistenceManager quizAssignmentPersistenceManager; @@ -156,6 +160,7 @@ public class AbstractIsaacIntegrationTest { protected static QuizQuestionManager quizQuestionManager; protected static PgUsers pgUsers; protected static ContentSubclassMapper contentMapper; + protected static IBookmarks bookmarksDbManager; // Services protected static AssignmentService assignmentService; @@ -311,6 +316,8 @@ public static void setUpClass() throws Exception { quizQuestionManager = new QuizQuestionManager(questionManager, mainMapper, quizQuestionAttemptPersistenceManager, quizManager, quizAttemptManager); userAttemptManager = new UserAttemptManager(questionManager); fastTrackManger = new FastTrackManger(properties, contentManager, gameManager); + bookmarksDbManager = new PgBookmarks(postgresSqlDb); + bookmarksManager = new BookmarksManager(bookmarksDbManager, contentManager, mainMapper); misuseMonitor = new InMemoryMisuseMonitor(); misuseMonitor.registerHandler(GroupManagerLookupMisuseHandler.class.getSimpleName(), new GroupManagerLookupMisuseHandler(emailManager, properties)); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacadeIT.java new file mode 100644 index 0000000000..b3f7c0eebf --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/BookmarksFacadeIT.java @@ -0,0 +1,224 @@ +package uk.ac.cam.cl.dtg.isaac.api; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentSummaryDTO; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import java.sql.PreparedStatement; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easymock.EasyMock.replay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; + +public class BookmarksFacadeIT extends IsaacIntegrationTest { + + private BookmarksFacade bookmarksFacade; + + @BeforeEach + public void setUp() { + this.bookmarksFacade = new BookmarksFacade(properties, logManager, userAccountManager, contentManager, bookmarksManager); + } + + @AfterEach + public void tearDown() throws Exception { + // Restore ALICE_STUDENT bookmarks to original state + PreparedStatement pstDelete = postgresSqlDb.getDatabaseConnection().prepareStatement( + "DELETE FROM user_bookmarks WHERE user_id = ? AND content_id = ?"); + pstDelete.setLong(1, ITConstants.ALICE_STUDENT_ID); + pstDelete.setString(2, ITConstants.FUZZY_MATCH_TEST_PAGE_ID); + pstDelete.executeUpdate(); + + PreparedStatement pstInsert = postgresSqlDb.getDatabaseConnection().prepareStatement( + "INSERT INTO user_bookmarks (user_id, content_id, content_type, created) VALUES (?, ?, ?, NOW()) ON CONFLICT DO NOTHING"); + pstInsert.setLong(1, ITConstants.ALICE_STUDENT_ID); + pstInsert.setString(2, ITConstants.SEARCH_TEST_CONCEPT_ID); + pstInsert.setString(3, CONCEPT_TYPE); + pstInsert.executeUpdate(); + } + + @Test + public void getCurrentUserBookmarks_searchWithoutContentType_returnsAllBookmarks() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest bookmarksRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(bookmarksRequest); + + // Act: make request + Response bookmarksResponse = bookmarksFacade.getCurrentUserBookmarks(bookmarksRequest, null); + + // Assert: check response is OK and contains expected bookmarks + assertEquals(Response.Status.OK.getStatusCode(), bookmarksResponse.getStatus()); + + List responseBody = (List) bookmarksResponse.getEntity(); + List actualBookmarkIds = responseBody.stream().map(ContentSummaryDTO::getId).toList(); + + List expectedBookmarkIds = List.of(ITConstants.REGRESSION_TEST_PAGE_ID, ITConstants.ASSIGNMENT_TEST_PAGE_ID, + ITConstants.SEARCH_TEST_CONCEPT_ID); + + assertThat(actualBookmarkIds).containsExactlyInAnyOrderElementsOf(expectedBookmarkIds); + } + + @Test + public void getCurrentUserBookmarks_searchWithContentType_returnsFilteredBookmarks() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest bookmarksRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(bookmarksRequest); + + // Act: make request + Response bookmarksResponse = bookmarksFacade.getCurrentUserBookmarks(bookmarksRequest, QUESTION_TYPE); + + // Assert: check status code is OK and contains expected bookmarks + assertEquals(Response.Status.OK.getStatusCode(), bookmarksResponse.getStatus()); + + List responseBody = (List) bookmarksResponse.getEntity(); + List actualBookmarkIds = responseBody.stream().map(ContentSummaryDTO::getId).toList(); + + List expectedBookmarkIds = List.of(ITConstants.REGRESSION_TEST_PAGE_ID, ITConstants.ASSIGNMENT_TEST_PAGE_ID); + + assertThat(actualBookmarkIds).containsExactlyInAnyOrderElementsOf(expectedBookmarkIds); + } + + @Test + public void getCurrentUserBookmarks_noLoggedInUser_returnsError() { + // Arrange: create request without valid cookie + HttpServletRequest bookmarksRequest = createRequestWithCookies(new Cookie[]{}); + replay(bookmarksRequest); + + // Act: make request + Response bookmarksResponse = bookmarksFacade.getCurrentUserBookmarks(bookmarksRequest, QUESTION_TYPE); + + // Assert: check status code is unauthorised + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), bookmarksResponse.getStatus()); + } + + @Test + public void getCurrentUserBookmarks_invalidContentType_returnsError() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest bookmarksRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(bookmarksRequest); + + // Act: make request + Response bookmarksResponse = bookmarksFacade.getCurrentUserBookmarks(bookmarksRequest, EVENT_TYPE); + + // Assert: check status code is bad request + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), bookmarksResponse.getStatus()); + } + + @Test + public void addCurrentUserBookmark_validContent_returnsOK() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest addBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(addBookmarkRequest); + + // Act: make request + Response addBookmarkResponse = bookmarksFacade.addCurrentUserBookmark(addBookmarkRequest, ITConstants.FUZZY_MATCH_TEST_PAGE_ID); + + // Assert: check status code is OK + assertEquals(Response.Status.OK.getStatusCode(), addBookmarkResponse.getStatus()); + } + + @Test + public void addCurrentUserBookmark_noLoggedInUser_returnsError() { + // Arrange: create request without valid cookie + HttpServletRequest addBookmarkRequest = createRequestWithCookies(new Cookie[]{}); + replay(addBookmarkRequest); + + // Act: make request + Response addBookmarkResponse = bookmarksFacade.addCurrentUserBookmark(addBookmarkRequest, ITConstants.FUZZY_MATCH_TEST_PAGE_ID); + + // Assert: check status code is unauthorised + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), addBookmarkResponse.getStatus()); + } + + @Test + public void addCurrentUserBookmark_notFoundContent_returnsError() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest addBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(addBookmarkRequest); + + // Act: make request + Response addBookmarkResponse = bookmarksFacade.addCurrentUserBookmark(addBookmarkRequest, "not_a_real_id"); + + // Assert: check status code is not found + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), addBookmarkResponse.getStatus()); + } + + @Test + public void addCurrentUserBookmark_duplicateContent_returnsError() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest addBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(addBookmarkRequest); + + // Act: make request + Response addBookmarkResponse = bookmarksFacade.addCurrentUserBookmark(addBookmarkRequest, ITConstants.REGRESSION_TEST_PAGE_ID); + + // Assert: check status code is bad request + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), addBookmarkResponse.getStatus()); + } + + @Test + public void addCurrentUserBookmark_invalidContentType_returnsError() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest addBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(addBookmarkRequest); + + // Act: make request + Response addBookmarkResponse = bookmarksFacade.addCurrentUserBookmark(addBookmarkRequest, ITConstants.QUIZ_TEST_QUIZ_ID); + + // Assert: check status code is bad request + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), addBookmarkResponse.getStatus()); + } + + @Test + public void deleteCurrentUserBookmark_bookmarkedContent_returnsOK() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest deleteBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(deleteBookmarkRequest); + + // Act: make request + Response deleteBookmarkResponse = bookmarksFacade.removeCurrentUserBookmark(deleteBookmarkRequest, ITConstants.SEARCH_TEST_CONCEPT_ID); + + // Assert: check status code is no content + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), deleteBookmarkResponse.getStatus()); + } + + @Test + public void deleteCurrentUserBookmark_noLoggedInUser_returnsError() throws Exception { + // Arrange: create request without valid cookie + HttpServletRequest deleteBookmarkRequest = createRequestWithCookies(new Cookie[]{}); + replay(deleteBookmarkRequest); + + // Act: make request + Response deleteBookmarksResponse = bookmarksFacade.removeCurrentUserBookmark(deleteBookmarkRequest, ITConstants.FUZZY_MATCH_TEST_PAGE_ID); + + // Assert: check status code is unauthorised + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), deleteBookmarksResponse.getStatus()); + } + + @Test + public void deleteCurrentUserBookmark_notBookmarkedContent_returnsError() throws Exception { + // Arrange: log in, create request + LoginResult login = loginAs(httpSession, ITConstants.ALICE_STUDENT_EMAIL, ITConstants.ALICE_STUDENT_PASSWORD); + HttpServletRequest deleteBookmarkRequest = createRequestWithCookies(new Cookie[]{login.cookie}); + replay(deleteBookmarkRequest); + + // Act: make request + Response deleteBookmarkResponse = bookmarksFacade.removeCurrentUserBookmark(deleteBookmarkRequest, ITConstants.SEARCH_TEST_SUPERSEDED_BY_ID); + + // Assert: check status code is bad request + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), deleteBookmarkResponse.getStatus()); + } +} diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacadeIT.java index 9ccd2d1096..7d6875f30b 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacadeIT.java @@ -43,7 +43,7 @@ public class PagesFacadeIT extends IsaacIntegrationTest{ public void setUp() { this.pagesFacade = new PagesFacade(new ContentService(contentManager), properties, logManager, mainMapper, contentManager, userAccountManager, new URIManager(properties), questionManager, - gameManager, userAttemptManager); + gameManager, userAttemptManager, bookmarksManager); } @Test diff --git a/src/test/resources/test-postgres-rutherford-data-dump.sql b/src/test/resources/test-postgres-rutherford-data-dump.sql index a584261afa..89168fce70 100644 --- a/src/test/resources/test-postgres-rutherford-data-dump.sql +++ b/src/test/resources/test-postgres-rutherford-data-dump.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 16.8 (Debian 16.8-1.pgdg120+1) --- Dumped by pg_dump version 16.8 (Debian 16.8-1.pgdg120+1) +-- Dumped from database version 16.3 (Debian 16.3-1.pgdg120+1) +-- Dumped by pg_dump version 16.3 (Debian 16.3-1.pgdg120+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -259,6 +259,19 @@ UAZFNZ 18 7 \. +-- +-- Data for Name: user_bookmarks; Type: TABLE DATA; Schema: public; Owner: rutherford +-- + +COPY public.user_bookmarks (user_id, content_id, content_type, created) FROM stdin; +7 _regression_test_ isaacQuestionPage 2026-04-20 14:35:06.72+00 +7 _assignment_test isaacQuestionPage 2025-03-11 14:35:42.634+00 +7 33935571-5a6c-4d42-a243-b5c01d4293e6 isaacConceptPage 2024-07-16 14:38:24.678+00 +9 33935571-5a6c-4d42-a243-b5c01d4293e6 isaacConceptPage 2025-10-27 14:38:43.858+00 +8 supersedes isaacQuestionPage 2025-08-13 14:38:35.17+00 +\. + + -- -- Data for Name: user_credentials; Type: TABLE DATA; Schema: public; Owner: rutherford --