From ebe45755066273114f63aaa54ca07c18fe435d1d Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 13 Jul 2026 23:04:03 +0300 Subject: [PATCH] (BE) Support VIDEO_60_PERCENT_WATCHED event type #855 Adds the VIDEO_60_PERCENT_WATCHED client log event: /log validation (logged-in user, payload checks, watch threshold) with per-user/per-video deduplication --- .../uk/ac/cam/cl/dtg/isaac/api/Constants.java | 1 + .../cam/cl/dtg/segue/api/LogEventFacade.java | 128 +++++++++++- .../ac/cam/cl/dtg/segue/dao/ILogManager.java | 14 ++ .../segue/dao/LogManagerEventPublisher.java | 8 + .../ac/cam/cl/dtg/segue/dao/PgLogManager.java | 44 +++- .../cl/dtg/segue/api/LogEventFacadeTest.java | 195 ++++++++++++++++++ .../cam/cl/dtg/segue/dao/PgLogManagerIT.java | 90 ++++++++ 7 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 src/test/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacadeTest.java create mode 100644 src/test/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManagerIT.java 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 335f12c139..2d9fd47cac 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 @@ -149,6 +149,7 @@ public enum IsaacClientLogType implements LogType { VIDEO_PLAY, VIDEO_PAUSE, VIDEO_ENDED, + VIDEO_60_PERCENT_WATCHED, VIEW_SUPERSEDED_BY_QUESTION, CLONE_GAMEBOARD, VIEW_HINT, diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacade.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacade.java index 6760b5bc1b..06ce24f93a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacade.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacade.java @@ -37,6 +37,7 @@ import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.api.Constants.IsaacClientLogType; import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse; import uk.ac.cam.cl.dtg.isaac.dto.users.AbstractSegueUserDTO; import uk.ac.cam.cl.dtg.isaac.dto.users.AnonymousUserDTO; @@ -58,6 +59,13 @@ public class LogEventFacade extends AbstractSegueFacade { private static final Logger log = LoggerFactory.getLogger(LogEventFacade.class); + private static final String VIDEO_ENGAGEMENT_EVENT_TYPE = IsaacClientLogType.VIDEO_60_PERCENT_WATCHED.name(); + private static final String VIDEO_ID_FIELDNAME = "videoId"; + private static final String VIDEO_DURATION_SECONDS_FIELDNAME = "videoDurationSeconds"; + private static final String WATCHED_SECONDS_FIELDNAME = "watchedSeconds"; + private static final String WATCH_PERCENT_FIELDNAME = "watchPercent"; + private static final double VIDEO_WATCH_THRESHOLD = 0.6; + private final IMisuseMonitor misuseMonitor; private final UserAccountManager userManager; @@ -92,7 +100,10 @@ public LogEventFacade(final PropertiesLoader properties, final ILogManager logMa description = "The 'type' field must be provided and must not be a reserved value.") public Response postLog(@Context final HttpServletRequest httpRequest, final Map eventJSON) { if (null == eventJSON || eventJSON.get(TYPE_FIELDNAME) == null) { - log.error("Error during log operation, no event type specified. Event: " + sanitiseExternalLogValue(eventJSON)); + if (log.isErrorEnabled()) { + log.error("Error during log operation, no event type specified. Event: {}", + sanitiseExternalLogValue(eventJSON)); + } return new SegueErrorResponse(Status.BAD_REQUEST, "Unable to record log message as the log has no " + TYPE_FIELDNAME + " property.").toResponse(); } @@ -107,21 +118,19 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map // Temporarily log log event types which are not included in our accepted list of client log types. // After a few weeks we should fail on the case where it is an unknown type. - if (!ISAAC_CLIENT_LOG_TYPES.contains(eventType)) { - log.error(String.format("Warning: Log Event '%s' is not included in ISAAC_CLIENT_LOG_TYPES", - sanitiseExternalLogValue(eventType))); + if (!ISAAC_CLIENT_LOG_TYPES.contains(eventType) && log.isErrorEnabled()) { + log.error("Warning: Log Event '{}' is not included in ISAAC_CLIENT_LOG_TYPES", + sanitiseExternalLogValue(eventType)); } try { // implement arbitrary log size limit. AbstractSegueUserDTO currentUser = userManager.getCurrentUser(httpRequest); - String uid; - if (currentUser instanceof AnonymousUserDTO) { - uid = ((AnonymousUserDTO) currentUser).getSessionId(); - } else { - uid = ((RegisteredUserDTO) currentUser).getId().toString(); - } + String uid = currentUser instanceof AnonymousUserDTO anonymousUser + ? anonymousUser.getSessionId() + : ((RegisteredUserDTO) currentUser).getId().toString(); + // Rate-limit every request (including video engagement events) before doing any further per-event work. try { misuseMonitor.notifyEvent(uid, LogEventMisuseHandler.class.getSimpleName(), httpRequest.getContentLength()); } catch (SegueResourceMisuseException e) { @@ -133,6 +142,14 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map httpRequest.getContentLength())); } + // The video engagement event requires a logged-in user, a validated payload and is deduplicated per video. + if (VIDEO_ENGAGEMENT_EVENT_TYPE.equals(eventType)) { + Response earlyVideoEventResponse = handleVideoEngagementEvent(eventJSON, currentUser, uid); + if (earlyVideoEventResponse != null) { + return earlyVideoEventResponse; + } + } + // remove the type information as we don't need it. eventJSON.remove(TYPE_FIELDNAME); @@ -147,4 +164,95 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map return error.toResponse(); } } + + /** + * Apply the extra rules for a video engagement event: the user must be logged in, the payload must be valid and + * the event is deduplicated per user and video. + * + * @param eventJSON the client supplied event details. + * @param currentUser the user making the request. + * @param uid the identifier used to attribute log events to the current user. + * @return an early {@link Response} (error, or a successful no-op for a duplicate), or null if the event should be + * logged as normal. + * @throws SegueDatabaseException if the deduplication lookup fails. + */ + private Response handleVideoEngagementEvent(final Map eventJSON, + final AbstractSegueUserDTO currentUser, final String uid) throws SegueDatabaseException { + if (!(currentUser instanceof RegisteredUserDTO)) { + return new SegueErrorResponse(Status.UNAUTHORIZED, + "You must be logged in to record a " + VIDEO_ENGAGEMENT_EVENT_TYPE + " event.").toResponse(); + } + + SegueErrorResponse validationError = validateVideoEngagementEvent(eventJSON); + if (validationError != null) { + // validateVideoEngagementEvent already warn-logs the specific reason. + return validationError.toResponse(); + } + + String videoId = (String) eventJSON.get(VIDEO_ID_FIELDNAME); + if (this.getLogManager().userHasLoggedEventWithDetail(uid, VIDEO_ENGAGEMENT_EVENT_TYPE, + VIDEO_ID_FIELDNAME, videoId)) { + // Already recorded for this user + video; treat as a successful no-op so the client need not special-case it. + return Response.ok().cacheControl(getCacheControl(NEVER_CACHE_WITHOUT_ETAG_CHECK, false)).build(); + } + + return null; + } + + private SegueErrorResponse validateVideoEngagementEvent(final Map eventJSON) { + Object videoIdValue = eventJSON.get(VIDEO_ID_FIELDNAME); + if (!(videoIdValue instanceof String videoId) || videoId.isBlank()) { + return videoEventValidationError("", "missing or empty " + VIDEO_ID_FIELDNAME); + } + + Double videoDurationSeconds = getNumericField(eventJSON, VIDEO_DURATION_SECONDS_FIELDNAME); + if (videoDurationSeconds == null || videoDurationSeconds <= 0) { + return videoEventValidationError(videoId, VIDEO_DURATION_SECONDS_FIELDNAME + " must be greater than 0"); + } + + Double watchedSeconds = getNumericField(eventJSON, WATCHED_SECONDS_FIELDNAME); + if (watchedSeconds == null || watchedSeconds < 0) { + return videoEventValidationError(videoId, WATCHED_SECONDS_FIELDNAME + " must be greater than or equal to 0"); + } + + Double watchPercent = getNumericField(eventJSON, WATCH_PERCENT_FIELDNAME); + if (watchPercent == null || watchPercent < VIDEO_WATCH_THRESHOLD) { + return videoEventValidationError(videoId, + WATCH_PERCENT_FIELDNAME + " must be greater than or equal to " + VIDEO_WATCH_THRESHOLD); + } + + return null; + } + + /** + * Build a bad-request response for an invalid video engagement event and log a sanitised warning for observability. + * + * @param videoId the videoId the event referred to (or a placeholder if absent). + * @param reason a human-readable explanation of the validation failure. + * @return a {@link SegueErrorResponse} with {@link Status#BAD_REQUEST}. + */ + private SegueErrorResponse videoEventValidationError(final String videoId, final String reason) { + if (log.isWarnEnabled()) { + log.warn("Rejecting {} event (videoId: {}): {}", VIDEO_ENGAGEMENT_EVENT_TYPE, + sanitiseExternalLogValue(videoId), reason); + } + return new SegueErrorResponse(Status.BAD_REQUEST, + "Invalid " + VIDEO_ENGAGEMENT_EVENT_TYPE + " event: " + reason); + } + + /** + * Read a numeric field from a client supplied event payload. JSON numbers are deserialized as {@link Integer} or + * {@link Double}, so this normalises any {@link Number} to a {@link Double}. + * + * @param eventJSON the client supplied event details. + * @param fieldName the field to read. + * @return the value as a Double, or null if the field is absent or not a number. + */ + private static Double getNumericField(final Map eventJSON, final String fieldName) { + Object value = eventJSON.get(fieldName); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/ILogManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/ILogManager.java index 0c9b790da6..54b99878ec 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/ILogManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/ILogManager.java @@ -154,4 +154,18 @@ Map> getLogCountByDate(Collection eventType * @throws SegueDatabaseException if there is a problem contacting the underlying database */ Set getAllEventTypes() throws SegueDatabaseException; + + /** + * Checks whether a given registered user has already logged an event of a particular type containing a specific + * value at a top-level key in its event_details JSONB. Used to deduplicate idempotent client events. + * + * @param userId the registered user id to check for. + * @param eventType the event type to match. + * @param detailKey the top-level key within event_details to match against. + * @param detailValue the value the event_details key must equal. + * @return true if at least one matching event already exists, false otherwise. + * @throws SegueDatabaseException if there is a problem contacting the underlying database + */ + boolean userHasLoggedEventWithDetail(String userId, String eventType, String detailKey, String detailValue) + throws SegueDatabaseException; } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/LogManagerEventPublisher.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/LogManagerEventPublisher.java index 5602a3770d..6bbc622b95 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/LogManagerEventPublisher.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/LogManagerEventPublisher.java @@ -177,4 +177,12 @@ public Set getAllEventTypes() throws SegueDatabaseException { return this.logManager.getAllEventTypes(); } + + @Override + public boolean userHasLoggedEventWithDetail(final String userId, final String eventType, final String detailKey, + final String detailValue) throws SegueDatabaseException { + + return this.logManager.userHasLoggedEventWithDetail(userId, eventType, detailKey, detailValue); + + } } \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManager.java index 6d114d2d8c..ff70653d3f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManager.java @@ -173,6 +173,27 @@ public Collection getLogsByType(final String type, final Instant fromD return this.getLogsByUserAndType(type, fromDate, toDate, usersIdsList); } + @Override + public boolean userHasLoggedEventWithDetail(final String userId, final String eventType, final String detailKey, + final String detailValue) throws SegueDatabaseException { + String query = "SELECT 1 FROM logged_events WHERE user_id = ? AND event_type = ?" + + " AND event_details->>? = ? LIMIT 1;"; + try (Connection conn = database.getDatabaseConnection(); + PreparedStatement pst = conn.prepareStatement(query) + ) { + pst.setString(FIELD_USER_HAS_LOGGED_EVENT_USER_ID, userId); + pst.setString(FIELD_USER_HAS_LOGGED_EVENT_EVENT_TYPE, eventType); + pst.setString(FIELD_USER_HAS_LOGGED_EVENT_DETAIL_KEY, detailKey); + pst.setString(FIELD_USER_HAS_LOGGED_EVENT_DETAIL_VALUE, detailValue); + + try (ResultSet results = pst.executeQuery()) { + return results.next(); + } + } catch (SQLException e) { + throw new SegueDatabaseException("Postgres exception: Unable to check for existing log event", e); + } + } + @Override public Long getLogCountByType(final String type) throws SegueDatabaseException { String query = "SELECT COUNT(*) AS TOTAL FROM logged_events WHERE event_type = ?"; @@ -212,7 +233,7 @@ public Map> getLogCountByDate(final Collection()); + result.put(typeOfInterest, new HashMap<>()); } for (Entry le : rs.entrySet()) { @@ -330,13 +351,10 @@ private Map getLogsCountByMonthFilteredByUserAndType(final String StringBuilder queryToBuild = new StringBuilder(); queryToBuild.append("WITH filtered_logs AS (SELECT * FROM logged_events WHERE event_type=?"); if (userIds != null && !userIds.isEmpty()) { - StringBuilder inParams = new StringBuilder(); - inParams.append("?"); - for (int i = 1; i < userIds.size(); i++) { - inParams.append(",?"); - } + String inParams = "?" + + ",?".repeat(userIds.size() - 1); - queryToBuild.append(String.format(" AND user_id IN (%s)", inParams.toString())); + queryToBuild.append(String.format(" AND user_id IN (%s)", inParams)); } queryToBuild.append(") "); @@ -411,11 +429,9 @@ private Collection getLogsByUserAndType(final String type, final Insta if (userIds != null && !userIds.isEmpty()) { StringBuilder inParams = new StringBuilder(); inParams.append("?"); - for (int i = 1; i < userIds.size(); i++) { - inParams.append(",?"); - } + inParams.append(",?".repeat(userIds.size() - 1)); - query += String.format(" AND user_id IN (%s)", inParams.toString()); + query += String.format(" AND user_id IN (%s)", inParams); } @@ -559,6 +575,12 @@ private LogEvent buildLogEvent(final String userId, final String anonymousUserId // getLogCountByType private static final int FIELD_GET_LOG_COUNT_EVENT_TYPE = 1; + // userHasLoggedEventWithDetail + private static final int FIELD_USER_HAS_LOGGED_EVENT_USER_ID = 1; + private static final int FIELD_USER_HAS_LOGGED_EVENT_EVENT_TYPE = 2; + private static final int FIELD_USER_HAS_LOGGED_EVENT_DETAIL_KEY = 3; + private static final int FIELD_USER_HAS_LOGGED_EVENT_DETAIL_VALUE = 4; + // getLastLogDateForAllUsers private static final int FIELD_GET_LOG_DATE_EVENT_TYPE = 1; diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacadeTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacadeTest.java new file mode 100644 index 0000000000..d2d4e3b871 --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacadeTest.java @@ -0,0 +1,195 @@ +/** + * Copyright 2026 Isaac Computer Science + *
+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + *
+ * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.ac.cam.cl.dtg.segue.api; + +import static org.easymock.EasyMock.anyInt; +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.anyString; +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uk.ac.cam.cl.dtg.isaac.dto.users.AnonymousUserDTO; +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.api.monitors.IMisuseMonitor; +import uk.ac.cam.cl.dtg.segue.dao.ILogManager; +import uk.ac.cam.cl.dtg.util.PropertiesLoader; + +/** + * Unit tests for the {@link LogEventFacade}, focusing on the VIDEO_60_PERCENT_WATCHED event handling + * (logged-in requirement, payload validation and per-user/per-video deduplication). + */ +class LogEventFacadeTest { + + private static final String VIDEO_EVENT_TYPE = "VIDEO_60_PERCENT_WATCHED"; + private static final long REGISTERED_USER_ID = 5L; + private static final String REGISTERED_USER_ID_STRING = "5"; + private static final String VIDEO_ID = "abc123"; + + private PropertiesLoader properties; + private ILogManager logManager; + private IMisuseMonitor misuseMonitor; + private UserAccountManager userManager; + private HttpServletRequest request; + private LogEventFacade facade; + + @BeforeEach + void setUp() { + this.properties = createMock(PropertiesLoader.class); + this.logManager = createMock(ILogManager.class); + this.misuseMonitor = createMock(IMisuseMonitor.class); + this.userManager = createMock(UserAccountManager.class); + this.request = createMock(HttpServletRequest.class); + replay(this.properties); + this.facade = new LogEventFacade(this.properties, this.logManager, this.misuseMonitor, this.userManager); + } + + private static RegisteredUserDTO registeredUser() { + RegisteredUserDTO user = new RegisteredUserDTO(); + user.setId(REGISTERED_USER_ID); + return user; + } + + private static Map validVideoEventPayload() { + Map payload = new HashMap<>(); + payload.put("type", VIDEO_EVENT_TYPE); + payload.put("videoId", VIDEO_ID); + payload.put("videoUrl", "https://www.youtube.com/watch?v=abc123"); + payload.put("pageId", "some_page"); + payload.put("videoDurationSeconds", 120); + payload.put("watchedSeconds", 80); + payload.put("watchPercent", 0.67); + return payload; + } + + @Test + void postLog_validVideoEventForNewUserVideo_isPersistedAndReturnsOk() throws Exception { + expect(userManager.getCurrentUser(request)).andReturn(registeredUser()).anyTimes(); + expect(request.getContentLength()).andReturn(256).anyTimes(); + misuseMonitor.notifyEvent(eq(REGISTERED_USER_ID_STRING), anyString(), anyInt()); + expectLastCall(); + expect(logManager.userHasLoggedEventWithDetail(REGISTERED_USER_ID_STRING, VIDEO_EVENT_TYPE, "videoId", VIDEO_ID)) + .andReturn(false); + logManager.logExternalEvent(anyObject(), eq(request), eq(VIDEO_EVENT_TYPE), anyObject()); + expectLastCall(); + replay(userManager, request, misuseMonitor, logManager); + + try (Response response = facade.postLog(request, validVideoEventPayload())) { + assertEquals(Status.OK.getStatusCode(), response.getStatus()); + } + verify(userManager, misuseMonitor, logManager); + } + + @Test + void postLog_videoEventFromAnonymousUser_returnsUnauthorized() throws Exception { + expect(userManager.getCurrentUser(request)).andReturn(new AnonymousUserDTO("anon-session")).anyTimes(); + expect(request.getContentLength()).andReturn(256).anyTimes(); + // Rate limiting still applies to anonymous requests, but no event should be persisted. + misuseMonitor.notifyEvent(eq("anon-session"), anyString(), anyInt()); + expectLastCall(); + replay(userManager, request, misuseMonitor, logManager); + + try (Response response = facade.postLog(request, validVideoEventPayload())) { + assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } + // The log manager must not be touched for a rejected anonymous request. + verify(misuseMonitor, logManager); + } + + @Test + void postLog_videoEventWithMissingVideoId_returnsBadRequest() throws Exception { + Map payload = validVideoEventPayload(); + payload.remove("videoId"); + assertVideoEventRejectedAsBadRequest(payload); + } + + @Test + void postLog_videoEventWithBlankVideoId_returnsBadRequest() throws Exception { + Map payload = validVideoEventPayload(); + payload.put("videoId", " "); + assertVideoEventRejectedAsBadRequest(payload); + } + + @Test + void postLog_videoEventWithNonPositiveDuration_returnsBadRequest() throws Exception { + Map payload = validVideoEventPayload(); + payload.put("videoDurationSeconds", 0); + assertVideoEventRejectedAsBadRequest(payload); + } + + @Test + void postLog_videoEventWithNegativeWatchedSeconds_returnsBadRequest() throws Exception { + Map payload = validVideoEventPayload(); + payload.put("watchedSeconds", -1); + assertVideoEventRejectedAsBadRequest(payload); + } + + @Test + void postLog_videoEventBelowWatchThreshold_returnsBadRequest() throws Exception { + Map payload = validVideoEventPayload(); + payload.put("watchPercent", 0.59); + assertVideoEventRejectedAsBadRequest(payload); + } + + @Test + void postLog_duplicateVideoEventForUserAndVideo_isSkippedButStillCountsTowardMisuse() throws Exception { + expect(userManager.getCurrentUser(request)).andReturn(registeredUser()).anyTimes(); + expect(request.getContentLength()).andReturn(256).anyTimes(); + // A duplicate request must still be rate-limited so it cannot be used to bypass the misuse monitor. + misuseMonitor.notifyEvent(eq(REGISTERED_USER_ID_STRING), anyString(), anyInt()); + expectLastCall(); + expect(logManager.userHasLoggedEventWithDetail(REGISTERED_USER_ID_STRING, VIDEO_EVENT_TYPE, "videoId", VIDEO_ID)) + .andReturn(true); + // logExternalEvent must NOT be called when the event is a duplicate. + replay(userManager, request, misuseMonitor, logManager); + + try (Response response = facade.postLog(request, validVideoEventPayload())) { + assertEquals(Status.OK.getStatusCode(), response.getStatus()); + } + // verify() confirms notifyEvent was invoked (duplicate counted) and logExternalEvent was not. + verify(misuseMonitor, logManager); + } + + /** + * Helper asserting that an invalid video event payload is rejected with 400 before any dedup/persist work happens. + */ + private void assertVideoEventRejectedAsBadRequest(final Map payload) throws Exception { + expect(userManager.getCurrentUser(request)).andReturn(registeredUser()).anyTimes(); + expect(request.getContentLength()).andReturn(256).anyTimes(); + // Rate limiting runs first; validation then fails before any dedup/persistence work hits the log manager. + misuseMonitor.notifyEvent(eq(REGISTERED_USER_ID_STRING), anyString(), anyInt()); + expectLastCall(); + replay(userManager, request, misuseMonitor, logManager); + + try (Response response = facade.postLog(request, payload)) { + assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } + verify(misuseMonitor, logManager); + } +} diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManagerIT.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManagerIT.java new file mode 100644 index 0000000000..7786290fb9 --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManagerIT.java @@ -0,0 +1,90 @@ +package uk.ac.cam.cl.dtg.segue.dao; + +import static org.easymock.EasyMock.createNiceMock; +import static org.easymock.EasyMock.replay; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static uk.ac.cam.cl.dtg.isaac.api.ITConstants.TEST_STUDENT_ID; +import static uk.ac.cam.cl.dtg.isaac.api.ITConstants.TEST_TEACHER_ID; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uk.ac.cam.cl.dtg.isaac.api.IsaacIntegrationTest; +import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO; + +/** + * Integration tests for {@link PgLogManager#userHasLoggedEventWithDetail}, exercising the JSONB lookup that backs + * VIDEO_60_PERCENT_WATCHED deduplication against a real Postgres instance. + */ +class PgLogManagerIT extends IsaacIntegrationTest { + + private static final String VIDEO_EVENT_TYPE = "VIDEO_60_PERCENT_WATCHED"; + private static final String VIDEO_ID_FIELD = "videoId"; + + private PgLogManager pgLogManager; + + @BeforeEach + public void setUp() { + // A real PgLogManager wired to the test Postgres container, with logging enabled so events are persisted. + pgLogManager = new PgLogManager(postgresSqlDb, new ObjectMapper(), true); + } + + private static RegisteredUserDTO userWithId(final long id) { + RegisteredUserDTO user = new RegisteredUserDTO(); + user.setId(id); + return user; + } + + private void persistVideoEvent(final long userId, final String videoId) { + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + replay(request); + Map eventDetails = new HashMap<>(); + eventDetails.put(VIDEO_ID_FIELD, videoId); + eventDetails.put("videoUrl", "https://www.youtube.com/watch?v=" + videoId); + eventDetails.put("videoDurationSeconds", 120); + eventDetails.put("watchedSeconds", 80); + eventDetails.put("watchPercent", 0.67); + pgLogManager.logExternalEvent(userWithId(userId), request, VIDEO_EVENT_TYPE, eventDetails); + } + + @Test + void userHasLoggedEventWithDetail_matchesPersistedUserAndVideo() throws Exception { + String videoId = "it-video-match-1"; + persistVideoEvent(TEST_STUDENT_ID, videoId); + + assertTrue(pgLogManager.userHasLoggedEventWithDetail( + String.valueOf(TEST_STUDENT_ID), VIDEO_EVENT_TYPE, VIDEO_ID_FIELD, videoId)); + } + + @Test + void userHasLoggedEventWithDetail_doesNotMatchDifferentVideoId() throws Exception { + String videoId = "it-video-distinct-2"; + persistVideoEvent(TEST_STUDENT_ID, videoId); + + assertFalse(pgLogManager.userHasLoggedEventWithDetail( + String.valueOf(TEST_STUDENT_ID), VIDEO_EVENT_TYPE, VIDEO_ID_FIELD, "some-other-video")); + } + + @Test + void userHasLoggedEventWithDetail_doesNotMatchDifferentUser() throws Exception { + String videoId = "it-video-user-scoped-3"; + persistVideoEvent(TEST_STUDENT_ID, videoId); + + // The same video watched by the student must not count as watched for the teacher. + assertFalse(pgLogManager.userHasLoggedEventWithDetail( + String.valueOf(TEST_TEACHER_ID), VIDEO_EVENT_TYPE, VIDEO_ID_FIELD, videoId)); + } + + @Test + void userHasLoggedEventWithDetail_doesNotMatchDifferentEventType() throws Exception { + String videoId = "it-video-type-scoped-4"; + persistVideoEvent(TEST_STUDENT_ID, videoId); + + assertFalse(pgLogManager.userHasLoggedEventWithDetail( + String.valueOf(TEST_STUDENT_ID), "VIDEO_PLAY", VIDEO_ID_FIELD, videoId)); + } +} \ No newline at end of file