diff --git a/src/main/java/org/sagebionetworks/bridge/config/SpringConfig.java b/src/main/java/org/sagebionetworks/bridge/config/SpringConfig.java index 78b749fdc..86c21b7bc 100644 --- a/src/main/java/org/sagebionetworks/bridge/config/SpringConfig.java +++ b/src/main/java/org/sagebionetworks/bridge/config/SpringConfig.java @@ -136,6 +136,7 @@ import org.sagebionetworks.bridge.models.schedules2.adherence.AdherenceRecord; import org.sagebionetworks.bridge.models.schedules2.adherence.weekly.WeeklyAdherenceReport; import org.sagebionetworks.bridge.models.schedules2.timelines.TimelineMetadata; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Demographic; import org.sagebionetworks.bridge.models.studies.DemographicUser; import org.sagebionetworks.bridge.models.studies.DemographicValue; @@ -668,6 +669,7 @@ public SessionFactory hibernateSessionFactory(TagEventListener listener) { metadataSources.addAnnotatedClass(Demographic.class); metadataSources.addAnnotatedClass(DemographicUser.class); metadataSources.addAnnotatedClass(DemographicValue.class); + metadataSources.addAnnotatedClass(Alert.class); SessionFactory factory = metadataSources.buildMetadata().buildSessionFactory(); diff --git a/src/main/java/org/sagebionetworks/bridge/dao/AlertDao.java b/src/main/java/org/sagebionetworks/bridge/dao/AlertDao.java new file mode 100644 index 000000000..ff4022817 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/dao/AlertDao.java @@ -0,0 +1,69 @@ +package org.sagebionetworks.bridge.dao; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; + +public interface AlertDao { + /** + * Creates an alert. + */ + void createAlert(Alert alert); + + /** + * Deletes a specific alert. + */ + void deleteAlert(Alert alert); + + /** + * Fetches a single alert with filters. + */ + Optional getAlert(String studyId, String appId, String userId, AlertCategory category); + + /** + * Fetches a single alert by id. + */ + Optional getAlertById(String alertId); + + /** + * Fetches alerts for a study. + */ + PagedResourceList getAlerts(String appId, String studyId, int offsetBy, int pageSize, + Set alertCategories); + + /** + * Batch deletes alerts given a list of IDs of alerts to delete. + */ + void deleteAlerts(List alertIds); + + /** + * Deletes all alerts for all users in a study. + */ + void deleteAlertsForStudy(String appId, String studyId); + + /** + * Deletes all alerts for a specific user in an app. + */ + void deleteAlertsForUserInApp(String appId, String userId); + + /** + * Deletes all alerts for a specific user in a study. + */ + void deleteAlertsForUserInStudy(String appId, String studyId, String userId); + + /** + * Fetches a list of alert categories and the number of alerts within that + * category for a study. + */ + AlertCategoriesAndCounts getAlertCategoriesAndCounts(String appId, String studyId); + + /** + * Marks all specified alerts as read or unread, as specified. + */ + void setAlertsReadState(List alertIds, boolean read); +} diff --git a/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDao.java b/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDao.java new file mode 100644 index 000000000..c25d0358a --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDao.java @@ -0,0 +1,137 @@ +package org.sagebionetworks.bridge.hibernate; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import javax.annotation.Resource; + +import org.sagebionetworks.bridge.dao.AlertDao; +import org.sagebionetworks.bridge.hibernate.QueryBuilder.WhereClauseBuilder; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.SearchTermPredicate; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertCategoryAndCount; +import org.springframework.stereotype.Component; + +@Component +public class HibernateAlertDao implements AlertDao { + private HibernateHelper hibernateHelper; + + @Resource(name = "mysqlHibernateHelper") + public final void setHibernateHelper(HibernateHelper hibernateHelper) { + this.hibernateHelper = hibernateHelper; + } + + @Override + public void createAlert(Alert alert) { + hibernateHelper.create(alert); + } + + @Override + public void deleteAlert(Alert alert) { + hibernateHelper.deleteById(Alert.class, alert.getId()); + } + + @Override + public Optional getAlert(String studyId, String appId, String userId, AlertCategory category) { + QueryBuilder builder = new QueryBuilder(); + builder.append("FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.studyId = :studyId", "studyId", studyId); + where.append("a.appId = :appId", "appId", appId); + where.append("a.userId = :userId", "userId", userId); + where.append("a.category = :category", "category", category); + return hibernateHelper.queryGetOne(builder.getQuery(), builder.getParameters(), Alert.class); + } + + @Override + public Optional getAlertById(String alertId) { + return Optional.ofNullable(hibernateHelper.getById(Alert.class, alertId)); + } + + @Override + public PagedResourceList getAlerts(String appId, String studyId, int offsetBy, int pageSize, + Set alertCategories) { + QueryBuilder builder = new QueryBuilder(); + builder.append("FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.appId = :appId", "appId", appId); + where.append("a.studyId = :studyId", "studyId", studyId); + where.append("a.category in (:alertCategories)", "alertCategories", alertCategories); + builder.append("ORDER BY createdOn DESC"); + int count = hibernateHelper.queryCount("SELECT COUNT(*) " + builder.getQuery(), builder.getParameters()); + List alerts = hibernateHelper.queryGet(builder.getQuery(), builder.getParameters(), offsetBy, pageSize, + Alert.class); + return new PagedResourceList<>(alerts, count, true) + .withRequestParam("offsetBy", offsetBy) + .withRequestParam("pageSize", pageSize); + } + + @Override + public void deleteAlerts(List alertIds) { + QueryBuilder builder = new QueryBuilder(); + builder.append("DELETE FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.id in (:alertIds)", "alertIds", alertIds); + hibernateHelper.query(builder.getQuery(), builder.getParameters()); + } + + @Override + public void deleteAlertsForStudy(String appId, String studyId) { + QueryBuilder builder = new QueryBuilder(); + builder.append("DELETE FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.appId = :appId", "appId", appId); + where.append("a.studyId = :studyId", "studyId", studyId); + hibernateHelper.query(builder.getQuery(), builder.getParameters()); + } + + @Override + public void deleteAlertsForUserInApp(String appId, String userId) { + QueryBuilder builder = new QueryBuilder(); + builder.append("DELETE FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.appId = :appId", "appId", appId); + where.append("a.userId = :userId", "userId", userId); + hibernateHelper.query(builder.getQuery(), builder.getParameters()); + } + + @Override + public void deleteAlertsForUserInStudy(String appId, String studyId, String userId) { + QueryBuilder builder = new QueryBuilder(); + builder.append("DELETE FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.appId = :appId", "appId", appId); + where.append("a.studyId = :studyId", "studyId", studyId); + where.append("a.userId = :userId", "userId", userId); + hibernateHelper.query(builder.getQuery(), builder.getParameters()); + } + + @Override + public AlertCategoriesAndCounts getAlertCategoriesAndCounts(String appId, String studyId) { + QueryBuilder builder = new QueryBuilder(); + builder.append("SELECT NEW " + AlertCategoryAndCount.class.getName() + "(category, COUNT(*) as count)"); + builder.append("FROM Alert a"); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.appId = :appId", "appId", appId); + where.append("a.studyId = :studyId", "studyId", studyId); + builder.append("GROUP BY category"); + builder.append("ORDER BY category"); + List alertCategoriesAndCounts = hibernateHelper.queryGet(builder.getQuery(), + builder.getParameters(), null, null, AlertCategoryAndCount.class); + return new AlertCategoriesAndCounts(alertCategoriesAndCounts); + } + + @Override + public void setAlertsReadState(List alertIds, boolean read) { + QueryBuilder builder = new QueryBuilder(); + builder.append("UPDATE Alert a"); + builder.append("SET a.read = :read", "read", read); + WhereClauseBuilder where = builder.startWhere(SearchTermPredicate.AND); + where.append("a.id in (:alertIds)", "alertIds", alertIds); + hibernateHelper.query(builder.getQuery(), builder.getParameters()); + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateHelper.java b/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateHelper.java index bfa165ee6..0a705b5e0 100644 --- a/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateHelper.java +++ b/src/main/java/org/sagebionetworks/bridge/hibernate/HibernateHelper.java @@ -205,7 +205,7 @@ public int nativeQueryUpdate(String queryString, Map parameters) } /** - * Execute SQL query with no return value, like a batch delete. + * Execute HQL query with no return value, like a batch delete. */ public void query(String queryString, Map parameters) { executeWithExceptionHandling(null, session -> { diff --git a/src/main/java/org/sagebionetworks/bridge/models/activities/ActivityEventObjectType.java b/src/main/java/org/sagebionetworks/bridge/models/activities/ActivityEventObjectType.java index 7f134dea3..d6ca0c4a2 100644 --- a/src/main/java/org/sagebionetworks/bridge/models/activities/ActivityEventObjectType.java +++ b/src/main/java/org/sagebionetworks/bridge/models/activities/ActivityEventObjectType.java @@ -80,7 +80,7 @@ public String getEventId(String objectId, ActivityEventType eventType, String an */ TIMELINE_RETRIEVED(IMMUTABLE) { public String getEventId(String objectId, ActivityEventType eventType, String answerValue) { - return this.name().toLowerCase(); + return TIMELINE_RETRIEVED_ID; } }, /** @@ -180,9 +180,11 @@ public String getEventId(String objectId, ActivityEventType eventType, String an } public abstract String getEventId(String objectId, ActivityEventType eventType, String answerValue); + + public static final String TIMELINE_RETRIEVED_ID = TIMELINE_RETRIEVED.name().toLowerCase(); private final ActivityEventUpdateType updateType; - + public ActivityEventUpdateType getUpdateType() { return updateType; } diff --git a/src/main/java/org/sagebionetworks/bridge/models/studies/Alert.java b/src/main/java/org/sagebionetworks/bridge/models/studies/Alert.java new file mode 100644 index 000000000..0657c50fc --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/models/studies/Alert.java @@ -0,0 +1,185 @@ +package org.sagebionetworks.bridge.models.studies; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Transient; + +import org.joda.time.DateTime; +import org.sagebionetworks.bridge.hibernate.DateTimeToLongAttributeConverter; +import org.sagebionetworks.bridge.hibernate.JsonNodeAttributeConverter; +import org.sagebionetworks.bridge.json.BridgeObjectMapper; +import org.sagebionetworks.bridge.json.BridgeTypeName; +import org.sagebionetworks.bridge.models.BridgeEntity; +import org.sagebionetworks.bridge.models.accounts.AccountRef; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; + +@Entity +@Table(name = "Alerts") +@BridgeTypeName("Alert") +public class Alert implements BridgeEntity { + @Id + private String id; + @Convert(converter = DateTimeToLongAttributeConverter.class) + private DateTime createdOn; + @JsonIgnore + private String studyId; + @JsonIgnore + private String appId; + @JsonIgnore + private String userId; + @Transient + private AccountRef participant; + @Enumerated(EnumType.STRING) + private AlertCategory category; + @Convert(converter = JsonNodeAttributeConverter.class) + private JsonNode data; + // whether the alert has been marked as read (viewed) + // "read" is reserved in SQL + @Column(name = "isRead") + private boolean read; + + public enum AlertCategory { + NEW_ENROLLMENT, + TIMELINE_ACCESSED, + LOW_ADHERENCE, + UPCOMING_STUDY_BURST, + STUDY_BURST_CHANGE + } + + public Alert() { + } + + public Alert(String id, DateTime createdOn, String studyId, String appId, String userId, AccountRef participant, + AlertCategory category, JsonNode data, boolean read) { + this.id = id; + this.createdOn = createdOn; + this.studyId = studyId; + this.appId = appId; + this.userId = userId; + this.participant = participant; + this.category = category; + this.data = data; + this.read = read; + } + + public static Alert newEnrollment(String studyId, String appId, String userId) { + return new Alert(null, null, studyId, appId, userId, null, AlertCategory.NEW_ENROLLMENT, + BridgeObjectMapper.get().nullNode(), false); + } + + public static Alert timelineAccessed(String studyId, String appId, String userId) { + return new Alert(null, null, studyId, appId, userId, null, AlertCategory.TIMELINE_ACCESSED, + BridgeObjectMapper.get().nullNode(), false); + } + + public static Alert lowAdherence(String studyId, String appId, String userId, double adherenceThreshold) { + return new Alert(null, null, studyId, appId, userId, null, AlertCategory.LOW_ADHERENCE, + BridgeObjectMapper.get().valueToTree(new LowAdherenceAlertData(adherenceThreshold)), false); + } + + public static Alert studyBurstChange(String studyId, String appId, String userId) { + return new Alert(null, null, studyId, appId, userId, null, AlertCategory.STUDY_BURST_CHANGE, + BridgeObjectMapper.get().nullNode(), false); + } + + public static class LowAdherenceAlertData { + double adherenceThreshold; + + public LowAdherenceAlertData(double adherenceThreshold) { + this.adherenceThreshold = adherenceThreshold; + } + + public double getAdherenceThreshold() { + return adherenceThreshold; + } + + public void setAdherenceThreshold(double adherenceThreshold) { + this.adherenceThreshold = adherenceThreshold; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public DateTime getCreatedOn() { + return createdOn; + } + + public void setCreatedOn(DateTime createdOn) { + this.createdOn = createdOn; + } + + public String getStudyId() { + return studyId; + } + + public void setStudyId(String studyId) { + this.studyId = studyId; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public AccountRef getParticipant() { + return participant; + } + + public void setParticipant(AccountRef participant) { + this.participant = participant; + } + + public AlertCategory getCategory() { + return category; + } + + public void setCategory(AlertCategory category) { + this.category = category; + } + + public JsonNode getData() { + return data; + } + + public void setData(JsonNode data) { + this.data = data; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } + + @Override + public String toString() { + return "Alert [id=" + id + ", createdOn=" + createdOn + ", studyId=" + studyId + ", appId=" + appId + + ", userId=" + userId + ", category=" + category + ", data=" + data + "]"; + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoriesAndCounts.java b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoriesAndCounts.java new file mode 100644 index 000000000..abb333443 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoriesAndCounts.java @@ -0,0 +1,34 @@ +package org.sagebionetworks.bridge.models.studies; + +import java.util.List; + +import org.sagebionetworks.bridge.json.BridgeTypeName; +import org.sagebionetworks.bridge.models.BridgeEntity; + +/** + * A list of alert categories and the number of alerts within those categories + * for a particular study. + * + * We use list of objects containing category and count instead of map of category to count + * - so that we can control the order in JSON (alphabetical by category) + * - so that we can use category as an enum in the SDK + */ +@BridgeTypeName("AlertCategoriesAndCounts") +public class AlertCategoriesAndCounts implements BridgeEntity { + private List alertCategoriesAndCounts; + + public AlertCategoriesAndCounts() { + } + + public AlertCategoriesAndCounts(List alertCategoriesAndCounts) { + this.alertCategoriesAndCounts = alertCategoriesAndCounts; + } + + public List getAlertCategoriesAndCounts() { + return alertCategoriesAndCounts; + } + + public void setAlertCategoriesAndCounts(List alertCategoriesAndCounts) { + this.alertCategoriesAndCounts = alertCategoriesAndCounts; + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoryAndCount.java b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoryAndCount.java new file mode 100644 index 000000000..e9a996abd --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertCategoryAndCount.java @@ -0,0 +1,39 @@ +package org.sagebionetworks.bridge.models.studies; + +import org.sagebionetworks.bridge.json.BridgeTypeName; +import org.sagebionetworks.bridge.models.BridgeEntity; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; + +/** + * Represents an alert category and the number of alerts within that category + * for a particular study. Used as the result from a database query. + */ +@BridgeTypeName("AlertCategoryAndCount") +public class AlertCategoryAndCount implements BridgeEntity { + private AlertCategory category; + private long count; + + public AlertCategoryAndCount() { + } + + public AlertCategoryAndCount(AlertCategory category, long count) { + this.category = category; + this.count = count; + } + + public AlertCategory getCategory() { + return category; + } + + public void setCategory(AlertCategory category) { + this.category = category; + } + + public long getCount() { + return count; + } + + public void setCount(long count) { + this.count = count; + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/models/studies/AlertFilter.java b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertFilter.java new file mode 100644 index 000000000..5b4a02618 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertFilter.java @@ -0,0 +1,31 @@ +package org.sagebionetworks.bridge.models.studies; + +import java.util.Set; + +import org.sagebionetworks.bridge.json.BridgeTypeName; +import org.sagebionetworks.bridge.models.BridgeEntity; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; + +/** + * Used to filter alerts. A set of alert categories can be provided which will + * be used to filter a call for fetching alerts. + */ +@BridgeTypeName("AlertFilter") +public class AlertFilter implements BridgeEntity { + private Set alertCategories; + + public AlertFilter() { + } + + public AlertFilter(Set alertCategories) { + this.alertCategories = alertCategories; + } + + public Set getAlertCategories() { + return alertCategories; + } + + public void setAlertCategories(Set alertCategories) { + this.alertCategories = alertCategories; + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/models/studies/AlertIdCollection.java b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertIdCollection.java new file mode 100644 index 000000000..fa250ad86 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/models/studies/AlertIdCollection.java @@ -0,0 +1,30 @@ +package org.sagebionetworks.bridge.models.studies; + +import java.util.List; + +import org.sagebionetworks.bridge.json.BridgeTypeName; +import org.sagebionetworks.bridge.models.BridgeEntity; + +/** + * Used for batch operations on alerts; contains a list of alert IDs for batch + * operations. + */ +@BridgeTypeName("AlertIdCollection") +public class AlertIdCollection implements BridgeEntity { + private List alertIds; + + public AlertIdCollection() { + } + + public AlertIdCollection(List alertIds) { + this.alertIds = alertIds; + } + + public List getAlertIds() { + return alertIds; + } + + public void setAlertIds(List alertIds) { + this.alertIds = alertIds; + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/services/AdherenceService.java b/src/main/java/org/sagebionetworks/bridge/services/AdherenceService.java index 0ea5d3787..7b22c0297 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/AdherenceService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/AdherenceService.java @@ -73,6 +73,7 @@ import org.sagebionetworks.bridge.models.schedules2.timelines.MetadataContainer; import org.sagebionetworks.bridge.models.schedules2.timelines.SessionState; import org.sagebionetworks.bridge.models.schedules2.timelines.TimelineMetadata; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Study; import org.sagebionetworks.bridge.validators.AdherenceRecordsSearchValidator; import org.sagebionetworks.bridge.validators.AdherenceReportSearchValidator; @@ -91,6 +92,8 @@ public class AdherenceService { private AdherenceRecordDao recordDao; private AdherenceReportDao reportDao; + + private AlertService alertService; private StudyService studyService; @@ -109,7 +112,12 @@ final void setAdherenceRecordDao(AdherenceRecordDao recordDao) { final void setAdherenceReportDao(AdherenceReportDao reportDao) { this.reportDao = reportDao; } - + + @Autowired + final void setAlertService(AlertService alertService) { + this.alertService = alertService; + } + @Autowired final void setStudyService(StudyService studyService) { this.studyService = studyService; @@ -410,12 +418,27 @@ public WeeklyAdherenceReport getWeeklyAdherenceReport(String appId, String study report.setClientTimeZone(zoneId); WeeklyAdherenceReport weeklyReport = deriveWeeklyAdherenceFromStudyReportWeek(studyId, account, report); - + watch.stop(); LOG.info("Weekly adherence report took " + watch.elapsed(TimeUnit.MILLISECONDS) + "ms"); return weeklyReport; } + public WeeklyAdherenceReport getWeeklyAdherenceReportForWorker(String appId, String studyId, Account account) { + WeeklyAdherenceReport weeklyReport = getWeeklyAdherenceReport(appId, studyId, account); + + // trigger alert for low weekly adherence + Study study = studyService.getStudy(appId, studyId, true); + if (weeklyReport.getWeeklyAdherencePercent() != null + && study.getAdherenceThresholdPercentage() != null + && weeklyReport.getWeeklyAdherencePercent() <= study.getAdherenceThresholdPercentage()) { + alertService.createAlert( + Alert.lowAdherence(studyId, appId, account.getId(), study.getAdherenceThresholdPercentage())); + } + + return weeklyReport; + } + protected WeeklyAdherenceReport deriveWeeklyAdherenceFromStudyReportWeek(String studyId, Account account, StudyAdherenceReport report) { @@ -471,7 +494,7 @@ public PagedResourceList getWeeklyAdherenceReports(String .withRequestParam(PagedResourceList.PAGE_SIZE, search.getPageSize()); } - private T generateReport(String appId, String studyId, String userId, + protected T generateReport(String appId, String studyId, String userId, DateTime createdOn, String clientTimeZone, BiFunction func) { AdherenceState.Builder builder = new AdherenceState.Builder(); builder.withNow(createdOn); @@ -526,4 +549,4 @@ public AdherenceStatistics getAdherenceStatistics(String appId, String studyId, } return reportDao.getAdherenceStatistics(appId, studyId, adherenceThreshold); } -} \ No newline at end of file +} diff --git a/src/main/java/org/sagebionetworks/bridge/services/AlertService.java b/src/main/java/org/sagebionetworks/bridge/services/AlertService.java new file mode 100644 index 000000000..392834bc9 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/services/AlertService.java @@ -0,0 +1,222 @@ +package org.sagebionetworks.bridge.services; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.sagebionetworks.bridge.BridgeUtils; +import org.sagebionetworks.bridge.dao.AlertDao; +import org.sagebionetworks.bridge.exceptions.EntityNotFoundException; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.accounts.Account; +import org.sagebionetworks.bridge.models.accounts.AccountId; +import org.sagebionetworks.bridge.models.accounts.AccountRef; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; +import org.sagebionetworks.bridge.time.DateUtils; +import org.sagebionetworks.bridge.validators.AlertFilterValidator; +import org.sagebionetworks.bridge.validators.AlertIdCollectionValidator; +import org.sagebionetworks.bridge.validators.Validate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class AlertService { + private AlertDao alertDao; + private AccountService accountService; + + @Autowired + public final void setAlertDao(AlertDao alertDao) { + this.alertDao = alertDao; + } + + @Autowired + public final void setAccountService(AccountService accountService) { + this.accountService = accountService; + } + + /** + * Creates an alert. + * + * This is for INTERNAL USE ONLY. There is no validation. + * + * @param alert The alert to create. + */ + public void createAlert(Alert alert) { + checkNotNull(alert.getStudyId()); + checkNotNull(alert.getAppId()); + checkNotNull(alert.getUserId()); + checkNotNull(alert.getCategory()); + + Optional existingAlert = alertDao.getAlert(alert.getStudyId(), alert.getAppId(), alert.getUserId(), + alert.getCategory()); + if (existingAlert.isPresent()) { + // alert already exists: overwrite + alertDao.deleteAlert(existingAlert.get()); + } + alert.setId(generateGuid()); + alert.setCreatedOn(DateUtils.getCurrentDateTime()); + alertDao.createAlert(alert); + } + + /** + * Fetches alerts for a study. + */ + public PagedResourceList getAlerts(String appId, String studyId, int offsetBy, int pageSize, + AlertFilter alertFilter) { + checkNotNull(appId); + checkNotNull(studyId); + checkNotNull(alertFilter); + + Validate.entityThrowingException(AlertFilterValidator.INSTANCE, alertFilter); + + // if no filters applied, get all alerts + if (alertFilter.getAlertCategories().isEmpty()) { + alertFilter.setAlertCategories( + Arrays.stream(AlertCategory.values()).collect(Collectors.toSet())); + } + + PagedResourceList alerts = alertDao.getAlerts(appId, studyId, offsetBy, pageSize, + alertFilter.getAlertCategories()); + // alerts are only stored with the userId; we need to insert the AccountRef so + // alerts can be displayed with external id or other data + for (Alert alert : alerts.getItems()) { + injectAccountRef(alert); + } + return alerts; + } + + /** + * Batch deletes alerts given a list of IDs of alerts to delete. + */ + public void deleteAlerts(String appId, String studyId, AlertIdCollection alertsToDelete) + throws EntityNotFoundException { + checkNotNull(appId); + checkNotNull(studyId); + checkNotNull(alertsToDelete); + + Validate.entityThrowingException(AlertIdCollectionValidator.INSTANCE, alertsToDelete); + + // don't modify alerts outside study + verifyAlertsInStudy(appId, studyId, alertsToDelete); + + alertDao.deleteAlerts(alertsToDelete.getAlertIds()); + } + + /** + * Deletes all alerts for all users in a study. + */ + public void deleteAlertsForStudy(String appId, String studyId) { + checkNotNull(appId); + checkNotNull(studyId); + + alertDao.deleteAlertsForStudy(appId, studyId); + } + + /** + * Deletes all alerts for a specific user in an app. + */ + public void deleteAlertsForUserInApp(String appId, String userId) { + checkNotNull(appId); + checkNotNull(userId); + + alertDao.deleteAlertsForUserInApp(appId, userId); + } + + /** + * Deletes all alerts for a specific user in a study. + */ + public void deleteAlertsForUserInStudy(String appId, String studyId, String userId) { + checkNotNull(appId); + checkNotNull(studyId); + checkNotNull(userId); + + alertDao.deleteAlertsForUserInStudy(appId, studyId, userId); + } + + /** + * Marks an alert as read. + */ + public void markAlertsRead(String appId, String studyId, AlertIdCollection alertsToMarkRead) + throws EntityNotFoundException { + setAlertsReadState(appId, studyId, alertsToMarkRead, true); + } + + /** + * Marks an alert as unread. + */ + public void markAlertsUnread(String appId, String studyId, AlertIdCollection alertsToMarkUnread) + throws EntityNotFoundException { + setAlertsReadState(appId, studyId, alertsToMarkUnread, false); + } + + /** + * Marks an alert as read or unread. + */ + private void setAlertsReadState(String appId, String studyId, AlertIdCollection alertIds, boolean read) + throws EntityNotFoundException { + checkNotNull(appId); + checkNotNull(studyId); + checkNotNull(alertIds); + + Validate.entityThrowingException(AlertIdCollectionValidator.INSTANCE, alertIds); + + // don't modify alerts outside study + verifyAlertsInStudy(appId, studyId, alertIds); + + alertDao.setAlertsReadState(alertIds.getAlertIds(), read); + } + + /** + * Calculates and returns a list of alert categories and the number of alerts + * within that category for a study. + */ + public AlertCategoriesAndCounts getAlertCategoriesAndCounts(String appId, String studyId) { + checkNotNull(appId); + checkNotNull(studyId); + + return alertDao.getAlertCategoriesAndCounts(appId, studyId); + } + + /** + * Ensures that all alerts belong to the specified study. + * + * @throws EntityNotFoundException if any alert is not part of the specified + * study or if the alert does not exist. + */ + private void verifyAlertsInStudy(String appId, String studyId, AlertIdCollection alertIds) + throws EntityNotFoundException { + for (String alertId : alertIds.getAlertIds()) { + Alert alert = alertDao.getAlertById(alertId).orElseThrow(() -> new EntityNotFoundException(Alert.class)); + if (!appId.equals(alert.getAppId()) || !studyId.equals(alert.getStudyId())) { + // trying to modify alert outside this study + throw new EntityNotFoundException(Alert.class); + } + } + } + + /** + * Inserts an AccountRef into an alert. The AccountRef is fetched using the + * alert's userId. + */ + private void injectAccountRef(Alert alert) { + AccountId accountId = BridgeUtils.parseAccountId(alert.getAppId(), alert.getUserId()); + Account account = accountService.getAccount(accountId) + .orElseThrow(() -> new EntityNotFoundException(Account.class)); + alert.setParticipant(new AccountRef(account, alert.getStudyId())); + } + + /** + * Generates a guid. + * + * @return a generated guid. + */ + public String generateGuid() { + return BridgeUtils.generateGuid(); + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/services/ConsentService.java b/src/main/java/org/sagebionetworks/bridge/services/ConsentService.java index b33c42340..63b986218 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/ConsentService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/ConsentService.java @@ -82,6 +82,7 @@ public class ConsentService { private UrlShortenerService urlShortenerService; private TemplateService templateService; private EnrollmentService enrollmentService; + private AlertService alertService; @Value("classpath:conf/app-defaults/consent-page.xhtml") final void setConsentTemplate(org.springframework.core.io.Resource resource) throws IOException { @@ -131,6 +132,10 @@ final void setTemplateService(TemplateService templateService) { final void setEnrollmentService(EnrollmentService enrollmentService) { this.enrollmentService = enrollmentService; } + @Autowired + final void setAlertService(AlertService alertService) { + this.alertService = alertService; + } /** * Get the user's active consent signature (a signature that has not been withdrawn). @@ -335,6 +340,9 @@ public Map withdrawConsent(App app, Subpopulat sendWithdrawEmail(app, account, withdrawal, withdrewOn); + // delete alerts for participant + alertService.deleteAlertsForUserInApp(app.getIdentifier(), account.getId()); + return statuses; } @@ -385,6 +393,9 @@ public void withdrawFromApp(App app, StudyParticipant participant, Withdrawal wi accountService.updateAccount(account); notificationsService.deleteAllRegistrations(app.getIdentifier(), participant.getHealthCode()); + + // delete alerts for user + alertService.deleteAlertsForUserInApp(app.getIdentifier(), account.getId()); } // Helper method, which abstracts away logic for sending withdraw notification email. diff --git a/src/main/java/org/sagebionetworks/bridge/services/EnrollmentService.java b/src/main/java/org/sagebionetworks/bridge/services/EnrollmentService.java index bd571b4d6..28779bd27 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/EnrollmentService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/EnrollmentService.java @@ -42,6 +42,7 @@ import org.sagebionetworks.bridge.models.PagedResourceList; import org.sagebionetworks.bridge.models.accounts.Account; import org.sagebionetworks.bridge.models.accounts.AccountId; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Enrollment; import org.sagebionetworks.bridge.models.studies.EnrollmentDetail; import org.sagebionetworks.bridge.models.studies.EnrollmentFilter; @@ -62,6 +63,8 @@ private static class EnrollmentHolder { @Autowired private EnrollmentDao enrollmentDao; @Autowired + private AlertService alertService; + @Autowired private StudyService studyService; protected DateTime getEnrollmentDateTime() { @@ -192,6 +195,11 @@ public Enrollment addEnrollment(Account account, Enrollment newEnrollment, boole } editEnrollment(account, newEnrollment, newEnrollment); account.getEnrollments().add(newEnrollment); + + // trigger alert for new enrollment + alertService.createAlert( + Alert.newEnrollment(newEnrollment.getStudyId(), newEnrollment.getAppId(), account.getId())); + return newEnrollment; } @@ -231,6 +239,11 @@ public Enrollment unenroll(Enrollment enrollment) { accountService.editAccount(accountId, (acct) -> { holder.enrollment = unenroll(acct, enrollment); }); + + // delete alerts for user + alertService.deleteAlertsForUserInStudy(enrollment.getAppId(), enrollment.getStudyId(), + enrollment.getAccountId()); + return holder.enrollment; } diff --git a/src/main/java/org/sagebionetworks/bridge/services/ExternalIdService.java b/src/main/java/org/sagebionetworks/bridge/services/ExternalIdService.java index 210f8d8ff..9c938c2d0 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/ExternalIdService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/ExternalIdService.java @@ -31,11 +31,17 @@ public class ExternalIdService { static final String PAGE_SIZE_ERROR = "pageSize must be from 1-"+API_MAXIMUM_PAGE_SIZE+" records"; private AccountService accountService; + private AlertService alertService; @Autowired public final void setAccountService(AccountService accountService) { this.accountService = accountService; } + + @Autowired + public final void setAlertService(AlertService alertService) { + this.alertService = alertService; + } public PagedResourceList getPagedExternalIds(String appId, String studyId, String idFilter, Integer offsetBy, Integer pageSize) { @@ -64,5 +70,8 @@ public void deleteExternalIdPermanently(App app, ExternalIdentifier externalId) .orElseThrow(() -> new EntityNotFoundException(Account.class)); enrollment.setExternalId(null); accountService.updateAccount(account); + + // delete alerts for this account + alertService.deleteAlertsForUserInStudy(app.getIdentifier(), enrollment.getStudyId(), account.getId()); } } diff --git a/src/main/java/org/sagebionetworks/bridge/services/StudyActivityEventService.java b/src/main/java/org/sagebionetworks/bridge/services/StudyActivityEventService.java index e1c4c4049..c2ca7921a 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/StudyActivityEventService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/StudyActivityEventService.java @@ -37,10 +37,12 @@ import org.sagebionetworks.bridge.models.ResourceList; import org.sagebionetworks.bridge.models.accounts.Account; import org.sagebionetworks.bridge.models.accounts.AccountId; +import org.sagebionetworks.bridge.models.activities.ActivityEventObjectType; import org.sagebionetworks.bridge.models.activities.StudyActivityEvent; import org.sagebionetworks.bridge.models.activities.StudyActivityEventIdsMap; import org.sagebionetworks.bridge.models.schedules2.Schedule2; import org.sagebionetworks.bridge.models.schedules2.StudyBurst; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Enrollment; import org.sagebionetworks.bridge.validators.Validate; import org.slf4j.Logger; @@ -79,6 +81,7 @@ public class StudyActivityEventService { private ActivityEventService activityEventService; private Schedule2Service scheduleService; private CacheProvider cacheProvider; + private AlertService alertService; @Autowired final void setStudyActivityEventDao(StudyActivityEventDao dao) { @@ -104,6 +107,10 @@ final void setSchedule2Service(Schedule2Service scheduleService) { final void setCacheProvider(CacheProvider cacheProvider) { this.cacheProvider = cacheProvider; } + @Autowired + final void setAlertService(AlertService alertService) { + this.alertService = alertService; + } DateTime getCreatedOn() { return DateTime.now(); @@ -191,7 +198,13 @@ public void publishEvent(StudyActivityEvent event, boolean showError, boolean up List failedEventIds = new ArrayList<>(); if (event.getUpdateType().canUpdate(mostRecent, event)) { dao.publishEvent(event); - + + if (event.getEventId().equals(ActivityEventObjectType.TIMELINE_RETRIEVED_ID)) { + // trigger alert for timeline retrieval + alertService + .createAlert(Alert.timelineAccessed(event.getStudyId(), event.getAppId(), event.getUserId())); + } + CacheKey cacheKey = CacheKey.etag(StudyActivityEvent.class, event.getUserId()); cacheProvider.setObject(cacheKey, event.getCreatedOn()); } else { @@ -330,7 +343,7 @@ private void addIfPresent(List events, Map /** * If the triggering event is mutable, study burst events can be created as well. Any errors - * that occur are collected in the list of failedEventIds. + * that occur are collected in the list of failedEventIds. */ private void createStudyBurstEvents(Schedule2 schedule, StudyActivityEvent event, List failedEventIds) { String eventId = event.getEventId(); @@ -343,6 +356,7 @@ private void createStudyBurstEvents(Schedule2 schedule, StudyActivityEvent event .withCreatedOn(event.getCreatedOn()) .withObjectType(STUDY_BURST); + boolean createdBurstEvents = false; for(StudyBurst burst : schedule.getStudyBursts()) { if (burst.getOriginEventId().equals(eventId)) { builder.withUpdateType(burst.getUpdateType()); @@ -374,12 +388,17 @@ private void createStudyBurstEvents(Schedule2 schedule, StudyActivityEvent event // Study bursts also have an update type that must be respected. if (burst.getUpdateType().canUpdate(mostRecent, burstEvent)) { dao.publishEvent(burstEvent); + createdBurstEvents = true; } else { failedEventIds.add(burstEvent.getEventId()); } } } } + if (createdBurstEvents) { + // trigger alert for study burst change if study burst events were created + alertService.createAlert(Alert.studyBurstChange(event.getStudyId(), event.getAppId(), event.getUserId())); + } } private void deleteStudyBurstEvents(Schedule2 schedule, StudyActivityEvent event) { @@ -427,4 +446,4 @@ private void addEnrollmentIfMissing(Account account, List ev events.add(event); } } -} \ No newline at end of file +} diff --git a/src/main/java/org/sagebionetworks/bridge/services/StudyService.java b/src/main/java/org/sagebionetworks/bridge/services/StudyService.java index 433d2da96..43b9f6a3d 100644 --- a/src/main/java/org/sagebionetworks/bridge/services/StudyService.java +++ b/src/main/java/org/sagebionetworks/bridge/services/StudyService.java @@ -69,6 +69,8 @@ public class StudyService { private AccountService accountService; @Autowired private DemographicService demographicService; + @Autowired + private AlertService alertService; protected String getDefaultTimeZoneId() { return DateTimeZone.getDefault().getID(); @@ -290,6 +292,9 @@ public void deleteStudy(String appId, String studyId) { cacheKey = CacheKey.etag(Study.class, appId, studyId); cacheProvider.removeObject(cacheKey); + + // delete alerts for this study + alertService.deleteAlertsForStudy(appId, studyId); } public void deleteStudyPermanently(String appId, String studyId) { @@ -446,6 +451,11 @@ private Study phaseTransition(String appId, String studyId, StudyPhase targetPha cacheKey = CacheKey.etag(Study.class, appId, studyId); cacheProvider.setObject(cacheKey, study.getModifiedOn()); + // delete alerts for this study if it is transitioned to completed + if (targetPhase == StudyPhase.COMPLETED) { + alertService.deleteAlertsForStudy(appId, studyId); + } + return study; } diff --git a/src/main/java/org/sagebionetworks/bridge/spring/controllers/AdherenceController.java b/src/main/java/org/sagebionetworks/bridge/spring/controllers/AdherenceController.java index bb2d3b624..0209a1065 100644 --- a/src/main/java/org/sagebionetworks/bridge/spring/controllers/AdherenceController.java +++ b/src/main/java/org/sagebionetworks/bridge/spring/controllers/AdherenceController.java @@ -117,7 +117,7 @@ public WeeklyAdherenceReport getWeeklyAdherenceReportForWorker(@PathVariable Str Account account = accountService.getAccount(accountId) .orElseThrow(() -> new EntityNotFoundException(Account.class)); - return service.getWeeklyAdherenceReport(appId, studyId, account); + return service.getWeeklyAdherenceReportForWorker(appId, studyId, account); } @PostMapping("/v5/studies/{studyId}/adherence/weekly") diff --git a/src/main/java/org/sagebionetworks/bridge/spring/controllers/AlertController.java b/src/main/java/org/sagebionetworks/bridge/spring/controllers/AlertController.java new file mode 100644 index 000000000..1032e6cca --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/spring/controllers/AlertController.java @@ -0,0 +1,156 @@ +package org.sagebionetworks.bridge.spring.controllers; + +import static org.sagebionetworks.bridge.BridgeConstants.API_DEFAULT_PAGE_SIZE; + +import org.sagebionetworks.bridge.AuthEvaluatorField; +import org.sagebionetworks.bridge.AuthUtils; +import org.sagebionetworks.bridge.BridgeUtils; +import org.sagebionetworks.bridge.Roles; +import org.sagebionetworks.bridge.exceptions.BadRequestException; +import org.sagebionetworks.bridge.exceptions.EntityNotFoundException; +import org.sagebionetworks.bridge.exceptions.NotAuthenticatedException; +import org.sagebionetworks.bridge.exceptions.UnauthorizedException; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.StatusMessage; +import org.sagebionetworks.bridge.models.accounts.UserSession; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; +import org.sagebionetworks.bridge.services.AlertService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@CrossOrigin +@RestController +public class AlertController extends BaseController { + private static final StatusMessage DELETE_ALERTS_MESSAGE = new StatusMessage("Alerts successfully deleted"); + private static final StatusMessage MARK_ALERTS_READ_MESSAGE = new StatusMessage( + "Alerts successfully marked as read"); + private static final StatusMessage MARK_ALERTS_UNREAD_MESSAGE = new StatusMessage( + "Alerts successfully marked as unread"); + + private AlertService alertService; + + @Autowired + public final void setAlertService(AlertService alertService) { + this.alertService = alertService; + } + + /** + * Fetches all alerts for a study. + * + * @param studyId The studyId to fetch the alerts from. + * @param offsetBy The offset at which the list of alerts should begin. + * @param pageSize The maximum number of entries in the returned list of alerts. + * @return The fetched list of alerts. + * @throws NotAuthenticatedException if the caller is not authenticated. + * @throws UnauthorizedException if the caller is not a researcher or study + * coordinator. + * @throws BadRequestException if offsetBy or pageSize is invalid. + */ + @PostMapping("/v5/studies/{studyId}/alerts") + public PagedResourceList getAlerts(@PathVariable String studyId, + @RequestParam(required = false) String offsetBy, @RequestParam(required = false) String pageSize) + throws NotAuthenticatedException, UnauthorizedException, BadRequestException { + UserSession session = getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + AuthUtils.CAN_EDIT_STUDY_PARTICIPANTS.checkAndThrow(AuthEvaluatorField.STUDY_ID, studyId); + + AlertFilter alertFilter = parseJson(AlertFilter.class); + int offsetInt = BridgeUtils.getIntOrDefault(offsetBy, 0); + int pageSizeInt = BridgeUtils.getIntOrDefault(pageSize, API_DEFAULT_PAGE_SIZE); + return alertService.getAlerts(session.getAppId(), studyId, offsetInt, pageSizeInt, alertFilter); + } + + /** + * Deletes alerts given a list of their ids. + * + * This uses the POST method because DELETE cannot have a body. + * + * @param studyId The studyId to delete the alerts from. + * @return A status message indicating the alerts were deleted. + * @throws NotAuthenticatedException if the caller is not authenticated. + * @throws UnauthorizedException if the caller is not a researcher or study + * coordinator. + * @throws EntityNotFoundException if the alerts to delete do not exist or are + * not from this study. + */ + @PostMapping("/v5/studies/{studyId}/alerts/delete") + public StatusMessage deleteAlerts(@PathVariable String studyId) + throws NotAuthenticatedException, UnauthorizedException, EntityNotFoundException { + UserSession session = getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + AuthUtils.CAN_EDIT_STUDY_PARTICIPANTS.checkAndThrow(AuthEvaluatorField.STUDY_ID, studyId); + + AlertIdCollection alertsToDelete = parseJson(AlertIdCollection.class); + alertService.deleteAlerts(session.getAppId(), studyId, alertsToDelete); + return DELETE_ALERTS_MESSAGE; + } + + /** + * Marks alerts read given a list of their ids. + * + * @param studyId The studyId in which to mark alerts read. + * @return A status message indicating the alerts were marked read. + * @throws NotAuthenticatedException if the caller is not authenticated. + * @throws UnauthorizedException if the caller is not a researcher or study + * coordinator. + * @throws EntityNotFoundException if the alerts to mark read do not exist or + * are not from this study. + */ + @PostMapping("/v5/studies/{studyId}/alerts/read") + public StatusMessage markAlertsRead(@PathVariable String studyId) + throws NotAuthenticatedException, UnauthorizedException, EntityNotFoundException { + UserSession session = getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + AuthUtils.CAN_EDIT_STUDY_PARTICIPANTS.checkAndThrow(AuthEvaluatorField.STUDY_ID, studyId); + + AlertIdCollection alertsToMarkRead = parseJson(AlertIdCollection.class); + alertService.markAlertsRead(session.getAppId(), studyId, alertsToMarkRead); + return MARK_ALERTS_READ_MESSAGE; + } + + /** + * Marks alerts unread given a list of their ids. + * + * @param studyId The studyId in which to mark alerts unread. + * @return A status message indicating the alerts were marked unread. + * @throws NotAuthenticatedException if the caller is not authenticated. + * @throws UnauthorizedException if the caller is not a researcher or study + * coordinator. + * @throws EntityNotFoundException if the alerts to mark unread do not exist + * or are not from this study. + */ + @PostMapping("/v5/studies/{studyId}/alerts/unread") + public StatusMessage markAlertsUnread(@PathVariable String studyId) + throws NotAuthenticatedException, UnauthorizedException, EntityNotFoundException { + UserSession session = getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + AuthUtils.CAN_EDIT_STUDY_PARTICIPANTS.checkAndThrow(AuthEvaluatorField.STUDY_ID, studyId); + + AlertIdCollection alertsToMarkUnread = parseJson(AlertIdCollection.class); + alertService.markAlertsUnread(session.getAppId(), studyId, alertsToMarkUnread); + return MARK_ALERTS_UNREAD_MESSAGE; + } + + /** + * Fetches a list of alert categories and the number of alerts in each category + * for a particular study. + * + * @param studyId The studyId to fetch the categories and counts from. + * @return The fetched list of categories and counts. + * @throws NotAuthenticatedException if the caller is not authenticated. + * @throws UnauthorizedException if the caller is not a researcher or study + * coordinator. + */ + @GetMapping("/v5/studies/{studyId}/alerts/categories/counts") + public AlertCategoriesAndCounts getAlertCategoriesAndCounts(@PathVariable String studyId) + throws NotAuthenticatedException, UnauthorizedException { + UserSession session = getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + AuthUtils.CAN_EDIT_STUDY_PARTICIPANTS.checkAndThrow(AuthEvaluatorField.STUDY_ID, studyId); + + return alertService.getAlertCategoriesAndCounts(session.getAppId(), studyId); + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/spring/controllers/StudyParticipantController.java b/src/main/java/org/sagebionetworks/bridge/spring/controllers/StudyParticipantController.java index 51d3e1687..86806b235 100644 --- a/src/main/java/org/sagebionetworks/bridge/spring/controllers/StudyParticipantController.java +++ b/src/main/java/org/sagebionetworks/bridge/spring/controllers/StudyParticipantController.java @@ -129,7 +129,7 @@ public class StudyParticipantController extends BaseController { private ReportService reportService; private AccountWorkflowService accountWorkflowService; - + @Autowired final void setParticipantService(ParticipantService participantService) { this.participantService = participantService; @@ -203,7 +203,7 @@ public ResponseEntity getTimelineForSelf(@PathVariable String studyId) .withUserId(session.getId()) .withObjectType(TIMELINE_RETRIEVED) .withTimestamp(timelineRequestedOn).build(), false, true); - + return new ResponseEntity<>(INSTANCE.calculateTimeline(schedule), OK); } @@ -861,4 +861,4 @@ private Account getValidAccountInStudy(String appId, String studyId, String idTo return account; } -} \ No newline at end of file +} diff --git a/src/main/java/org/sagebionetworks/bridge/validators/AlertFilterValidator.java b/src/main/java/org/sagebionetworks/bridge/validators/AlertFilterValidator.java new file mode 100644 index 000000000..398b24868 --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/validators/AlertFilterValidator.java @@ -0,0 +1,25 @@ +package org.sagebionetworks.bridge.validators; + +import static org.sagebionetworks.bridge.validators.Validate.CANNOT_BE_NULL; + +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +public class AlertFilterValidator implements Validator { + public static final AlertFilterValidator INSTANCE = new AlertFilterValidator(); + + @Override + public boolean supports(Class clazz) { + return AlertFilter.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + AlertFilter alertFilter = (AlertFilter) target; + + if (alertFilter.getAlertCategories() == null) { + errors.rejectValue("alertCategories", CANNOT_BE_NULL); + } + } +} diff --git a/src/main/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidator.java b/src/main/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidator.java new file mode 100644 index 000000000..55ce59f4f --- /dev/null +++ b/src/main/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidator.java @@ -0,0 +1,25 @@ +package org.sagebionetworks.bridge.validators; + +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import static org.sagebionetworks.bridge.validators.Validate.CANNOT_BE_NULL; + +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; + +public class AlertIdCollectionValidator implements Validator { + public static final AlertIdCollectionValidator INSTANCE = new AlertIdCollectionValidator(); + + @Override + public boolean supports(Class clazz) { + return AlertIdCollection.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + AlertIdCollection alertIdCollection = (AlertIdCollection) target; + + if (alertIdCollection.getAlertIds() == null) { + errors.rejectValue("alertIds", CANNOT_BE_NULL); + } + } +} diff --git a/src/main/resources/db/changelog/changelog.sql b/src/main/resources/db/changelog/changelog.sql index 9ef888930..b11870ee5 100644 --- a/src/main/resources/db/changelog/changelog.sql +++ b/src/main/resources/db/changelog/changelog.sql @@ -1040,3 +1040,24 @@ ADD INDEX `TimelineMetadata-SessionInstanceGuid` (sessionInstanceGuid); ALTER TABLE `DemographicsValues` ADD COLUMN `invalidity` varchar(512) DEFAULT NULL; + +-- changeset bridge:76 + +CREATE TABLE IF NOT EXISTS `Alerts` ( + `id` varchar(60) NOT NULL, + `createdOn` bigint(20) NOT NULL, + `studyId` varchar(60) NOT NULL, + `appId` varchar(60) NOT NULL, + `userId` varchar(255) NOT NULL, + `category` varchar(255) NOT NULL, + `data` varchar(2048) NOT NULL, + `isRead` boolean NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY (`appId`, `studyId`, `userId`, `category`), + INDEX (`appId`, `studyId`), + INDEX (`appId`, `studyId`, `userId`), + INDEX (`appId`, `studyId`, `category`), + INDEX (`appId`, `userId`), + CONSTRAINT `Alert-Account-Constraint` FOREIGN KEY (`userId`) REFERENCES `Accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `Alert-Study-Constraint` FOREIGN KEY (`studyId`, `appId`) REFERENCES `Substudies` (`id`, `studyId`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; diff --git a/src/test/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDaoTest.java b/src/test/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDaoTest.java new file mode 100644 index 000000000..22a27e4ff --- /dev/null +++ b/src/test/java/org/sagebionetworks/bridge/hibernate/HibernateAlertDaoTest.java @@ -0,0 +1,231 @@ +package org.sagebionetworks.bridge.hibernate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sagebionetworks.bridge.TestConstants.TEST_APP_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_STUDY_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_USER_ID; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.sagebionetworks.bridge.json.BridgeObjectMapper; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertCategoryAndCount; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +public class HibernateAlertDaoTest { + private static final String ALERT_ID = "test-alert-id"; + + @Mock + HibernateHelper hibernateHelper; + + @InjectMocks + HibernateAlertDao hibernateAlertDao; + + Alert alert; + + @BeforeMethod + public void beforeMethod() { + MockitoAnnotations.initMocks(this); + + alert = new Alert(ALERT_ID, null, TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, null, AlertCategory.NEW_ENROLLMENT, + BridgeObjectMapper.get().nullNode(), false); + } + + @Test + public void createAlert() { + hibernateAlertDao.createAlert(alert); + + verify(hibernateHelper).create(alert); + } + + @Test + public void deleteAlert() { + hibernateAlertDao.deleteAlert(alert); + + verify(hibernateHelper).deleteById(Alert.class, ALERT_ID); + } + + @Test + public void getAlert() { + when(hibernateHelper.queryGetOne(any(), any(), any())).thenReturn(Optional.of(alert)); + + Optional returnedAlert = hibernateAlertDao.getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT); + + assertTrue(returnedAlert.isPresent()); + assertSame(returnedAlert.get(), alert); + verify(hibernateHelper).queryGetOne("FROM Alert a WHERE " + + "a.studyId = :studyId AND " + + "a.appId = :appId AND " + + "a.userId = :userId AND " + + "a.category = :category", + ImmutableMap.of("studyId", TEST_STUDY_ID, + "appId", TEST_APP_ID, + "userId", TEST_USER_ID, + "category", AlertCategory.NEW_ENROLLMENT), Alert.class); + } + + @Test + public void getAlertDoesNotExist() { + when(hibernateHelper.queryGetOne(any(), any(), any())).thenReturn(Optional.empty()); + + Optional returnedAlert = hibernateAlertDao.getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT); + + assertFalse(returnedAlert.isPresent()); + verify(hibernateHelper).queryGetOne("FROM Alert a WHERE " + + "a.studyId = :studyId AND " + + "a.appId = :appId AND " + + "a.userId = :userId AND " + + "a.category = :category", + ImmutableMap.of("studyId", TEST_STUDY_ID, + "appId", TEST_APP_ID, + "userId", TEST_USER_ID, + "category", AlertCategory.NEW_ENROLLMENT), Alert.class); + } + + @Test + public void getAlertById() { + when(hibernateHelper.getById(Alert.class, ALERT_ID)).thenReturn(alert); + + Optional returnedAlert = hibernateAlertDao.getAlertById(ALERT_ID); + + assertTrue(returnedAlert.isPresent()); + assertSame(returnedAlert.get(), alert); + verify(hibernateHelper).getById(Alert.class, ALERT_ID); + } + + @Test + public void getAlertByIdDoesNotExist() { + when(hibernateHelper.getById(Alert.class, ALERT_ID)).thenReturn(null); + + Optional returnedAlert = hibernateAlertDao.getAlertById(ALERT_ID); + + assertFalse(returnedAlert.isPresent()); + verify(hibernateHelper).getById(Alert.class, ALERT_ID); + } + + @Test + public void getAlerts() { + when(hibernateHelper.queryCount(any(), any())).thenReturn(1); + when(hibernateHelper.queryGet(any(), any(), any(), any(), any())).thenReturn(ImmutableList.of(alert)); + Set alertCategories = ImmutableSet.of(AlertCategory.LOW_ADHERENCE, AlertCategory.NEW_ENROLLMENT); + + PagedResourceList returnedAlerts = hibernateAlertDao.getAlerts(TEST_APP_ID, TEST_STUDY_ID, 2, 100, alertCategories); + + assertEquals(returnedAlerts.getTotal().intValue(), 1); + assertEquals(returnedAlerts.getRequestParams().get("offsetBy"), 2); + assertEquals(returnedAlerts.getRequestParams().get("pageSize"), 100); + assertEquals(returnedAlerts.getItems().size(), 1); + assertSame(returnedAlerts.getItems().get(0), alert); + String QUERY = "FROM Alert a WHERE " + + "a.appId = :appId AND " + + "a.studyId = :studyId AND " + + "a.category in (:alertCategories) " + + "ORDER BY createdOn DESC"; + verify(hibernateHelper).queryCount("SELECT COUNT(*) " + QUERY, + ImmutableMap.of("studyId", TEST_STUDY_ID, + "appId", TEST_APP_ID, + "alertCategories", alertCategories)); + verify(hibernateHelper).queryGet(QUERY, + ImmutableMap.of("studyId", TEST_STUDY_ID, + "appId", TEST_APP_ID, + "alertCategories", alertCategories), 2, 100, Alert.class); + } + + @Test + public void deleteAlerts() { + hibernateAlertDao.deleteAlerts(ImmutableList.of(ALERT_ID)); + + verify(hibernateHelper).query("DELETE FROM Alert a WHERE " + + "a.id in (:alertIds)", + ImmutableMap.of("alertIds", ImmutableList.of(ALERT_ID))); + } + + @Test + public void deleteAlertsForStudy() { + hibernateAlertDao.deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); + + verify(hibernateHelper).query("DELETE FROM Alert a WHERE " + + "a.appId = :appId AND " + + "a.studyId = :studyId", + ImmutableMap.of("studyId", TEST_STUDY_ID, + "appId", TEST_APP_ID)); + } + + @Test + public void deleteAlertsForUserInApp() { + hibernateAlertDao.deleteAlertsForUserInApp(TEST_APP_ID, TEST_USER_ID); + + verify(hibernateHelper).query("DELETE FROM Alert a WHERE " + + "a.appId = :appId AND " + + "a.userId = :userId", + ImmutableMap.of("appId", TEST_APP_ID, + "userId", TEST_USER_ID)); + } + + @Test + public void deleteAlertsForUserInStudy() { + hibernateAlertDao.deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); + + verify(hibernateHelper).query("DELETE FROM Alert a WHERE " + + "a.appId = :appId AND " + + "a.studyId = :studyId AND " + + "a.userId = :userId", + ImmutableMap.of("appId", TEST_APP_ID, + "studyId", TEST_STUDY_ID, + "userId", TEST_USER_ID)); + } + + @Test + public void getAlertCategoriesAndCounts() { + List categoriesAndCounts = ImmutableList.of(new AlertCategoryAndCount()); + when(hibernateHelper.queryGet(any(), any(), any(), any(), eq(AlertCategoryAndCount.class))) + .thenReturn(categoriesAndCounts); + + AlertCategoriesAndCounts returnedAlertCategoriesAndCounts = hibernateAlertDao + .getAlertCategoriesAndCounts(TEST_APP_ID, TEST_STUDY_ID); + + assertSame(returnedAlertCategoriesAndCounts.getAlertCategoriesAndCounts(), categoriesAndCounts); + verify(hibernateHelper).queryGet( + "SELECT NEW org.sagebionetworks.bridge.models.studies.AlertCategoryAndCount(category, COUNT(*) as count) " + + + "FROM Alert a WHERE " + + "a.appId = :appId AND " + + "a.studyId = :studyId " + + "GROUP BY category " + + "ORDER BY category", + ImmutableMap.of("appId", TEST_APP_ID, + "studyId", TEST_STUDY_ID), + null, null, AlertCategoryAndCount.class); + } + + @Test + public void setAlertReadState() { + hibernateAlertDao.setAlertsReadState(ImmutableList.of(ALERT_ID), true); + + verify(hibernateHelper).query("UPDATE Alert a " + + "SET a.read = :read WHERE " + + "a.id in (:alertIds)", + ImmutableMap.of("read", true, + "alertIds", ImmutableList.of(ALERT_ID))); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/services/AdherenceServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/AdherenceServiceTest.java index ad3f696e0..e3491b2d3 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/AdherenceServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/AdherenceServiceTest.java @@ -102,9 +102,11 @@ import org.sagebionetworks.bridge.models.schedules2.timelines.Scheduler; import org.sagebionetworks.bridge.models.schedules2.timelines.Timeline; import org.sagebionetworks.bridge.models.schedules2.timelines.TimelineMetadata; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Enrollment; import org.sagebionetworks.bridge.models.studies.Study; import org.sagebionetworks.bridge.models.studies.StudyCustomEvent; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; public class AdherenceServiceTest extends Mockito { @@ -129,6 +131,9 @@ public class AdherenceServiceTest extends Mockito { @Mock RequestInfoService mockRequestInfoService; + + @Mock + AlertService alertService; @Captor ArgumentCaptor searchCaptor; @@ -142,6 +147,9 @@ public class AdherenceServiceTest extends Mockito { @Captor ArgumentCaptor weeklyReportCaptor; + @Captor + ArgumentCaptor alertCaptor; + @InjectMocks @Spy AdherenceService service; @@ -1095,7 +1103,112 @@ public void getWeeklyAdherenceReport_studyHasNoSchedule() { service.getWeeklyAdherenceReport(TEST_APP_ID, TEST_STUDY_ID, account); } - + + @Test + public void getWeeklyAdherenceReportForWorker_lowAdherence() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + + Study study = Study.create(); + study.setAdherenceThresholdPercentage(60); + when(mockStudyService.getStudy(TEST_APP_ID, TEST_STUDY_ID, true)).thenReturn(study); + + StudyAdherenceReport report = new StudyAdherenceReport(); + doReturn(report).when(service).generateReport(any(), any(), any(), any(), any(), any()); + WeeklyAdherenceReport weeklyReport = new WeeklyAdherenceReport(); + weeklyReport.setWeeklyAdherencePercent(60); + doReturn(weeklyReport).when(service).deriveWeeklyAdherenceFromStudyReportWeek(any(), any(), any()); + + service.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); + + verify(alertService).createAlert(alertCaptor.capture()); + Alert alert = alertCaptor.getValue(); + assertEquals(alert.getAppId(), TEST_APP_ID); + assertEquals(alert.getStudyId(), TEST_STUDY_ID); + assertEquals(alert.getUserId(), TEST_USER_ID); + assertEquals(alert.getCategory(), AlertCategory.LOW_ADHERENCE); + assertEquals(alert.getData().toString(), "{\"adherenceThreshold\":60.0,\"type\":\"LowAdherenceAlertData\"}"); + } + + @Test + public void getWeeklyAdherenceReportForWorker_sufficientAdherence() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + + Study study = Study.create(); + study.setAdherenceThresholdPercentage(60); + when(mockStudyService.getStudy(TEST_APP_ID, TEST_STUDY_ID, true)).thenReturn(study); + + StudyAdherenceReport report = new StudyAdherenceReport(); + doReturn(report).when(service).generateReport(any(), any(), any(), any(), any(), any()); + WeeklyAdherenceReport weeklyReport = new WeeklyAdherenceReport(); + weeklyReport.setWeeklyAdherencePercent(61); + doReturn(weeklyReport).when(service).deriveWeeklyAdherenceFromStudyReportWeek(any(), any(), any()); + + service.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); + + verifyZeroInteractions(alertService); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void getWeeklyAdherenceReportForWorker_studyDoesNotExist() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + + Study study = Study.create(); + study.setAdherenceThresholdPercentage(60); + when(mockStudyService.getStudy(TEST_APP_ID, TEST_STUDY_ID, true)) + .thenThrow(new EntityNotFoundException(Study.class)); + + StudyAdherenceReport report = new StudyAdherenceReport(); + doReturn(report).when(service).generateReport(any(), any(), any(), any(), any(), any()); + WeeklyAdherenceReport weeklyReport = new WeeklyAdherenceReport(); + weeklyReport.setWeeklyAdherencePercent(60); + doReturn(weeklyReport).when(service).deriveWeeklyAdherenceFromStudyReportWeek(any(), any(), any()); + + service.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); + } + + @Test + public void getWeeklyAdherenceReportForWorker_noWeeklyAdherencePercent() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + + Study study = Study.create(); + study.setAdherenceThresholdPercentage(60); + when(mockStudyService.getStudy(TEST_APP_ID, TEST_STUDY_ID, true)).thenReturn(study); + + StudyAdherenceReport report = new StudyAdherenceReport(); + doReturn(report).when(service).generateReport(any(), any(), any(), any(), any(), any()); + WeeklyAdherenceReport weeklyReport = new WeeklyAdherenceReport(); + weeklyReport.setWeeklyAdherencePercent(null); + doReturn(weeklyReport).when(service).deriveWeeklyAdherenceFromStudyReportWeek(any(), any(), any()); + + service.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); + + verifyZeroInteractions(alertService); + } + + @Test + public void getWeeklyAdherenceReportForWorker_noAdherenceThresholdPercentage() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + + Study study = Study.create(); + study.setAdherenceThresholdPercentage(null); + when(mockStudyService.getStudy(TEST_APP_ID, TEST_STUDY_ID, true)).thenReturn(study); + + StudyAdherenceReport report = new StudyAdherenceReport(); + doReturn(report).when(service).generateReport(any(), any(), any(), any(), any(), any()); + WeeklyAdherenceReport weeklyReport = new WeeklyAdherenceReport(); + weeklyReport.setWeeklyAdherencePercent(60); + doReturn(weeklyReport).when(service).deriveWeeklyAdherenceFromStudyReportWeek(any(), any(), any()); + + service.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); + + verifyZeroInteractions(alertService); + } + @Test public void getWeeklyAdherenceReports() { AdherenceReportSearch search = new AdherenceReportSearch(); diff --git a/src/test/java/org/sagebionetworks/bridge/services/AlertServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/AlertServiceTest.java new file mode 100644 index 000000000..d381767c0 --- /dev/null +++ b/src/test/java/org/sagebionetworks/bridge/services/AlertServiceTest.java @@ -0,0 +1,461 @@ +package org.sagebionetworks.bridge.services; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sagebionetworks.bridge.TestConstants.TEST_APP_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_EXTERNAL_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_STUDY_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_USER_ID; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; + +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.sagebionetworks.bridge.BridgeUtils; +import org.sagebionetworks.bridge.dao.AlertDao; +import org.sagebionetworks.bridge.exceptions.EntityNotFoundException; +import org.sagebionetworks.bridge.exceptions.InvalidEntityException; +import org.sagebionetworks.bridge.json.BridgeObjectMapper; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.accounts.Account; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; +import org.sagebionetworks.bridge.models.studies.Enrollment; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +public class AlertServiceTest { + private static final String ALERT_ID = "test-alert-id"; + + @Mock + AlertDao alertDao; + + @Mock + AccountService accountService; + + @Captor + ArgumentCaptor alertCaptor; + + @InjectMocks + AlertService alertService; + + Alert alert; + + @BeforeMethod + public void beforeMethod() { + MockitoAnnotations.initMocks(this); + + alert = new Alert(null, null, TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, null, AlertCategory.NEW_ENROLLMENT, + BridgeObjectMapper.get().nullNode(), false); + } + + @Test + public void createAlert() { + when(alertDao.getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT)).thenReturn(Optional.empty()); + + alertService.createAlert(alert); + + verify(alertDao).getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT); + verify(alertDao).createAlert(alertCaptor.capture()); + assertSame(alertCaptor.getValue(), alert); + assertNotNull(alert.getId()); + assertNotNull(alert.getCreatedOn()); + } + + @Test + public void createAlert_alreadyExists() { + Alert existingAlert = new Alert(); + when(alertDao.getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT)) + .thenReturn(Optional.of(existingAlert)); + + alertService.createAlert(alert); + + verify(alertDao).getAlert(TEST_STUDY_ID, TEST_APP_ID, TEST_USER_ID, AlertCategory.NEW_ENROLLMENT); + verify(alertDao).deleteAlert(existingAlert); + verify(alertDao).createAlert(alertCaptor.capture()); + assertSame(alertCaptor.getValue(), alert); + assertNotNull(alert.getId()); + assertNotNull(alert.getCreatedOn()); + } + + @Test(expectedExceptions = NullPointerException.class) + public void createAlert_nullStudyId() { + alert.setStudyId(null); + + alertService.createAlert(alert); + } + + @Test(expectedExceptions = NullPointerException.class) + public void createAlert_nullAppId() { + alert.setAppId(null); + + alertService.createAlert(alert); + } + + @Test(expectedExceptions = NullPointerException.class) + public void createAlert_nullUserId() { + alert.setUserId(null); + + alertService.createAlert(alert); + } + + @Test(expectedExceptions = NullPointerException.class) + public void createAlert_nullCategory() { + alert.setCategory(null); + + alertService.createAlert(alert); + } + + @Test + public void getAlerts() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + Enrollment enrollment = Enrollment.create(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); + enrollment.setExternalId(TEST_EXTERNAL_ID); + account.setEnrollments(ImmutableSet.of(enrollment)); + when(accountService.getAccount(any())).thenReturn(Optional.of(account)); + PagedResourceList alertsPage = new PagedResourceList<>(ImmutableList.of(alert), 1); + when(alertDao.getAlerts(eq(TEST_APP_ID), eq(TEST_STUDY_ID), eq(0), eq(100), any())).thenReturn(alertsPage); + Set alertCategories = ImmutableSet.of(AlertCategory.NEW_ENROLLMENT, + AlertCategory.TIMELINE_ACCESSED); + AlertFilter alertFilter = new AlertFilter(alertCategories); + + PagedResourceList returnedAlerts = alertService.getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, + alertFilter); + + verify(alertDao).getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, alertCategories); + verify(accountService).getAccount(BridgeUtils.parseAccountId(TEST_APP_ID, TEST_USER_ID)); + assertSame(returnedAlerts, alertsPage); + assertNotNull(alert.getParticipant()); + assertEquals(alert.getParticipant().getIdentifier(), TEST_USER_ID); + assertEquals(alert.getParticipant().getExternalId(), TEST_EXTERNAL_ID); + } + + @Test(expectedExceptions = InvalidEntityException.class) + public void getAlerts_invalidAlertFilter() { + AlertFilter alertFilter = new AlertFilter(null); + + alertService.getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, alertFilter); + } + + @Test + public void getAlerts_emptyAlertFilter() { + Account account = Account.create(); + account.setId(TEST_USER_ID); + Enrollment enrollment = Enrollment.create(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); + enrollment.setExternalId(TEST_EXTERNAL_ID); + account.setEnrollments(ImmutableSet.of(enrollment)); + when(accountService.getAccount(any())).thenReturn(Optional.of(account)); + PagedResourceList alertsPage = new PagedResourceList<>(ImmutableList.of(alert), 1); + when(alertDao.getAlerts(eq(TEST_APP_ID), eq(TEST_STUDY_ID), eq(0), eq(100), any())).thenReturn(alertsPage); + AlertFilter alertFilter = new AlertFilter(ImmutableSet.of()); + + PagedResourceList returnedAlerts = alertService.getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, + alertFilter); + + verify(alertDao).getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, + Arrays.stream(AlertCategory.values()).collect(Collectors.toSet())); + verify(accountService).getAccount(BridgeUtils.parseAccountId(TEST_APP_ID, TEST_USER_ID)); + assertSame(returnedAlerts, alertsPage); + assertNotNull(alert.getParticipant()); + assertEquals(alert.getParticipant().getIdentifier(), TEST_USER_ID); + assertEquals(alert.getParticipant().getExternalId(), TEST_EXTERNAL_ID); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void getAlerts_noAccount() { + when(accountService.getAccount(any())).thenReturn(Optional.empty()); + PagedResourceList alertsPage = new PagedResourceList<>(ImmutableList.of(alert), 1); + when(alertDao.getAlerts(eq(TEST_APP_ID), eq(TEST_STUDY_ID), eq(0), eq(100), any())).thenReturn(alertsPage); + + alertService.getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, 100, new AlertFilter(ImmutableSet.of())); + } + + @Test(expectedExceptions = NullPointerException.class) + public void getAlerts_nullAppId() { + alertService.getAlerts(null, TEST_STUDY_ID, 0, 0, new AlertFilter(ImmutableSet.of())); + } + + @Test(expectedExceptions = NullPointerException.class) + public void getAlerts_nullStudyId() { + alertService.getAlerts(TEST_APP_ID, null, 0, 0, new AlertFilter(ImmutableSet.of())); + } + + @Test + public void deleteAlerts() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, alertIds); + + verify(alertDao).getAlertById(ALERT_ID); + verify(alertDao).deleteAlerts(ImmutableList.of(ALERT_ID)); + } + + @Test(expectedExceptions = InvalidEntityException.class) + public void deleteAlerts_invalidAlertIdCollection() { + AlertIdCollection alertIds = new AlertIdCollection(null); + + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void deleteAlerts_alertDoesNotExist() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.empty()); + + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void deleteAlerts_wrongApp() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.deleteAlerts("wrong app id", TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void deleteAlerts_wrongStudy() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.deleteAlerts(TEST_APP_ID, "wrong study id", alertIds); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlerts_nullAppId() { + alertService.deleteAlerts(null, TEST_STUDY_ID, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlerts_nullStudyId() { + alertService.deleteAlerts(TEST_APP_ID, null, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlerts_nullAlertIds() { + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, null); + } + + @Test + public void deleteAlertsForStudy() { + alertService.deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); + + verify(alertDao).deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForStudy_nullAppId() { + alertService.deleteAlertsForStudy(null, TEST_STUDY_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForStudy_nullStudyId() { + alertService.deleteAlertsForStudy(TEST_APP_ID, null); + } + + @Test + public void deleteAlertsForUserInApp() { + alertService.deleteAlertsForUserInApp(TEST_APP_ID, TEST_USER_ID); + + verify(alertDao).deleteAlertsForUserInApp(TEST_APP_ID, TEST_USER_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForUserInApp_nullAppId() { + alertService.deleteAlertsForUserInApp(null, TEST_USER_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForUserInApp_nullUserId() { + alertService.deleteAlertsForUserInApp(TEST_APP_ID, null); + } + + @Test + public void deleteAlertsForUserInStudy() { + alertService.deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); + + verify(alertDao).deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForUserInStudy_nullAppId() { + alertService.deleteAlertsForUserInStudy(null, TEST_STUDY_ID, TEST_USER_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForUserInStudy_nullStudyId() { + alertService.deleteAlertsForUserInStudy(TEST_APP_ID, null, TEST_USER_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void deleteAlertsForUserInStudy_nullUserId() { + alertService.deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, null); + } + + @Test + public void markAlertsRead() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsRead(TEST_APP_ID, TEST_STUDY_ID, alertIds); + + verify(alertDao).getAlertById(ALERT_ID); + verify(alertDao).setAlertsReadState(ImmutableList.of(ALERT_ID), true); + } + + @Test(expectedExceptions = InvalidEntityException.class) + public void markAlertsRead_invalidAlertIdCollection() { + AlertIdCollection alertIds = new AlertIdCollection(null); + + alertService.markAlertsRead(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsRead_alertDoesNotExist() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.empty()); + + alertService.markAlertsRead(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsRead_wrongApp() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsRead("wrong app id", TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsRead_wrongStudy() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsRead(TEST_APP_ID, "wrong study id", alertIds); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsRead_nullAppId() { + alertService.deleteAlerts(null, TEST_STUDY_ID, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsRead_nullStudyId() { + alertService.deleteAlerts(TEST_APP_ID, null, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsRead_nullAlertIds() { + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, null); + } + + @Test + public void markAlertsUnread() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsUnread(TEST_APP_ID, TEST_STUDY_ID, alertIds); + + verify(alertDao).getAlertById(ALERT_ID); + verify(alertDao).setAlertsReadState(ImmutableList.of(ALERT_ID), false); + } + + @Test(expectedExceptions = InvalidEntityException.class) + public void markAlertsUnread_invalidAlertIdCollection() { + AlertIdCollection alertIds = new AlertIdCollection(null); + + alertService.markAlertsUnread(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsUnread_alertDoesNotExist() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.empty()); + + alertService.markAlertsUnread(TEST_APP_ID, TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsUnread_wrongApp() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsUnread("wrong app id", TEST_STUDY_ID, alertIds); + } + + @Test(expectedExceptions = EntityNotFoundException.class) + public void markAlertsUnread_wrongStudy() { + alert.setId(ALERT_ID); + AlertIdCollection alertIds = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + when(alertDao.getAlertById(ALERT_ID)).thenReturn(Optional.of(alert)); + + alertService.markAlertsUnread(TEST_APP_ID, "wrong study id", alertIds); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsUnread_nullAppId() { + alertService.deleteAlerts(null, TEST_STUDY_ID, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsUnread_nullStudyId() { + alertService.deleteAlerts(TEST_APP_ID, null, new AlertIdCollection(ImmutableList.of(ALERT_ID))); + } + + @Test(expectedExceptions = NullPointerException.class) + public void markAlertsUnread_nullAlertIds() { + alertService.deleteAlerts(TEST_APP_ID, TEST_STUDY_ID, null); + } + + @Test + public void getAlertCategoriesAndCounts() { + AlertCategoriesAndCounts alertCategoriesAndCounts = new AlertCategoriesAndCounts(); + when(alertDao.getAlertCategoriesAndCounts(TEST_APP_ID, TEST_STUDY_ID)).thenReturn(alertCategoriesAndCounts); + + AlertCategoriesAndCounts returnedAlertCategoriesAndCounts = alertService + .getAlertCategoriesAndCounts(TEST_APP_ID, TEST_STUDY_ID); + + assertSame(returnedAlertCategoriesAndCounts, alertCategoriesAndCounts); + verify(alertDao).getAlertCategoriesAndCounts(TEST_APP_ID, TEST_STUDY_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void getAlertCategoriesAndCounts_nullAppId() { + alertService.getAlertCategoriesAndCounts(null, TEST_STUDY_ID); + } + + @Test(expectedExceptions = NullPointerException.class) + public void getAlertCategoriesAndCounts_nullStudyId() { + alertService.getAlertCategoriesAndCounts(TEST_APP_ID, null); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/services/ConsentServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/ConsentServiceTest.java index 9a4bb8a5e..a78b278ab 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/ConsentServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/ConsentServiceTest.java @@ -138,6 +138,8 @@ public class ConsentServiceTest extends Mockito { private StudyConsentView studyConsentView; @Mock private TemplateService templateService; + @Mock + private AlertService alertService; @Captor private ArgumentCaptor emailCaptor; @Captor @@ -409,6 +411,9 @@ public void withdrawConsentWithParticipant() throws Exception { assertEquals(withdrawalEnrollment.getAppId(), app.getIdentifier()); assertEquals(withdrawalEnrollment.getStudyId(), TEST_STUDY_ID); assertEquals(withdrawalEnrollment.getAccountId(), ID); + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -426,6 +431,9 @@ public void withdrawConsentRemovesDataGroups() throws Exception { assertEquals(account.getDataGroups(), ImmutableSet.of("leaveBehind1", "leaveBehind2")); verify(subpopulation).getDataGroupsAssignedWhileConsented(); + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -480,6 +488,9 @@ public void withdrawFromAppWithEmail() throws Exception { assertNotNull(sig.getWithdrewOn()); } } + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -502,6 +513,9 @@ public void withdrawFromAppWithPhone() { } verify(notificationsService).deleteAllRegistrations(app.getIdentifier(), HEALTH_CODE); + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -522,6 +536,9 @@ public void withdrawFromAppRemovesDataGroups() { assertEquals(account.getDataGroups(), ImmutableSet.of("remainingDataGroup1", "remainingDataGroup2")); verify(subpopulation).getDataGroupsAssignedWhileConsented(); verify(subpopulation, never()).getStudyIdsAssignedOnConsent(); + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -568,6 +585,9 @@ public void withdrawFromAppRemovesFromMultipleStudies() { Long.valueOf(SIGNED_ON + 10000L)); assertEquals(account.getAllConsentSignatureHistories().get(SUBPOP_GUID_2).get(0).getWithdrewOn(), Long.valueOf(SIGNED_ON + 10000L)); + + // verify alerts deleted for participant + verify(alertService).deleteAlertsForUserInApp(app.getIdentifier(), ID); } @Test @@ -1169,4 +1189,4 @@ private void setupWithdrawTest() { account.setConsentSignatureHistory(SUBPOP_GUID, ImmutableList.of(CONSENT_SIGNATURE)); } -} \ No newline at end of file +} diff --git a/src/test/java/org/sagebionetworks/bridge/services/EnrollmentServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/EnrollmentServiceTest.java index a5fed7351..1d86244b6 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/EnrollmentServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/EnrollmentServiceTest.java @@ -60,10 +60,12 @@ import org.sagebionetworks.bridge.models.PagedResourceList; import org.sagebionetworks.bridge.models.accounts.Account; import org.sagebionetworks.bridge.models.accounts.AccountId; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Enrollment; import org.sagebionetworks.bridge.models.studies.EnrollmentDetail; import org.sagebionetworks.bridge.models.studies.EnrollmentFilter; import org.sagebionetworks.bridge.models.studies.Study; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; public class EnrollmentServiceTest extends Mockito { @@ -78,6 +80,9 @@ public class EnrollmentServiceTest extends Mockito { @Mock EnrollmentDao mockEnrollmentDao; + + @Mock + AlertService alertService; @InjectMocks @Spy @@ -85,7 +90,10 @@ public class EnrollmentServiceTest extends Mockito { @Captor ArgumentCaptor accountCaptor; - + + @Captor + ArgumentCaptor alertCaptor; + @BeforeMethod public void beforeMethod() { MockitoAnnotations.initMocks(this); @@ -227,6 +235,10 @@ public void enroll_bySelf() { assertEquals(retValue.getNote(), TEST_NOTE); assertTrue(account.getEnrollments().contains(retValue)); + + // verify new enrollment alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertNewEnrollmentAlert(alertCaptor.getValue()); } @Test @@ -250,6 +262,10 @@ public void enroll_byAdmin() { assertEquals(retValue.getAccountId(), TEST_USER_ID); assertEquals(retValue.getEnrolledBy(), "adminUser"); assertEquals(retValue.getNote(), TEST_NOTE); + + // verify new enrollment alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertNewEnrollmentAlert(alertCaptor.getValue()); } @Test @@ -275,6 +291,10 @@ public void enroll_byResearcher() { assertEquals(retValue.getAccountId(), TEST_USER_ID); assertEquals(retValue.getEnrolledBy(), "adminUser"); assertEquals(retValue.getNote(), TEST_NOTE); + + // verify new enrollment alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertNewEnrollmentAlert(alertCaptor.getValue()); } @Test @@ -297,6 +317,10 @@ public void enroll_byStudyCoordinator() { Enrollment retValue = service.enroll(enrollment); assertEquals(retValue.getAccountId(), TEST_USER_ID); assertEquals(retValue.getNote(), TEST_NOTE); + + // verify new enrollment alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertNewEnrollmentAlert(alertCaptor.getValue()); } @Test @@ -322,6 +346,9 @@ public void enroll_alreadyExists() { Enrollment enrollment = Enrollment.create(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); service.enroll(enrollment); + + // verify no new enrollment alert created + verifyZeroInteractions(alertService); } @Test(expectedExceptions = EntityNotFoundException.class, @@ -378,6 +405,9 @@ public void enroll_alreadyExistsButIsWithdrawn() { assertNull(retValue.getWithdrawnBy()); assertNull(retValue.getWithdrawalNote()); assertFalse(retValue.isConsentRequired()); + + // verify new enrollment alert created + verifyZeroInteractions(alertService); } @Test(expectedExceptions = UnauthorizedException.class) @@ -450,6 +480,9 @@ public void unenroll_bySelf() { assertEquals(captured.getWithdrawnOn(), MODIFIED_ON.minusHours(1)); assertNull(captured.getWithdrawnBy()); assertEquals(captured.getWithdrawalNote(), "Withdrawal reason"); + + // verify alerts for this user are deleted + verify(alertService).deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); } @Test @@ -502,6 +535,9 @@ public void unenroll_byThirdPartyAdmin() { assertEquals(captured.getWithdrawnOn(), MODIFIED_ON); assertEquals(captured.getWithdrawnBy(), "adminUser"); assertEquals(captured.getWithdrawalNote(), "Withdrawal reason"); + + // verify alerts for this user are deleted + verify(alertService).deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); } @Test @@ -534,6 +570,9 @@ public void unenroll_byThirdPartyResearcher() { assertEquals(captured.getWithdrawnOn(), MODIFIED_ON); assertEquals(captured.getWithdrawnBy(), "adminUser"); assertEquals(captured.getWithdrawalNote(), "Withdrawal reason"); + + // verify alerts for this user are deleted + verify(alertService).deleteAlertsForUserInStudy(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); } @Test(expectedExceptions = EntityNotFoundException.class) @@ -891,4 +930,12 @@ public void updateEnrollment_onlyUpdatesTargetedEnrollment() { assertNotNull(captured); assertEquals(captured.getNote(), TEST_NOTE); } -} \ No newline at end of file + + private void assertNewEnrollmentAlert(Alert alert) { + assertNotNull(alert); + assertEquals(alert.getAppId(), TEST_APP_ID); + assertEquals(alert.getStudyId(), TEST_STUDY_ID); + assertEquals(alert.getUserId(), TEST_USER_ID); + assertEquals(alert.getCategory(), AlertCategory.NEW_ENROLLMENT); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/services/ExternalIdServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/ExternalIdServiceTest.java index 568a1724a..ebdd0ea9b 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/ExternalIdServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/ExternalIdServiceTest.java @@ -47,6 +47,9 @@ public class ExternalIdServiceTest { @Mock private StudyService mockStudyService; + + @Mock + private AlertService alertService; @InjectMocks private ExternalIdService externalIdService; @@ -122,6 +125,7 @@ public void deleteExternalIdPermanently() { AccountId accountId = AccountId.forExternalId(TEST_APP_ID, ID); Account account = Account.create(); + account.setId(TEST_USER_ID); account.setEnrollments(ImmutableSet.of( Enrollment.create(TEST_APP_ID, STUDY_ID, TEST_USER_ID, ID))); when(mockAccountService.getAccount(accountId)).thenReturn(Optional.of(account)); @@ -132,6 +136,9 @@ public void deleteExternalIdPermanently() { Enrollment en = getElement(account.getEnrollments(), Enrollment::getStudyId, STUDY_ID) .orElseThrow(() -> new EntityNotFoundException(Enrollment.class)); assertNull(en.getExternalId()); + + // verify alerts for this external id are deleted + verify(alertService).deleteAlertsForUserInStudy(TEST_APP_ID, STUDY_ID, TEST_USER_ID); } @Test(expectedExceptions = EntityNotFoundException.class) diff --git a/src/test/java/org/sagebionetworks/bridge/services/StudyActivityEventServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/StudyActivityEventServiceTest.java index 89925f080..b733bb74a 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/StudyActivityEventServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/StudyActivityEventServiceTest.java @@ -68,9 +68,11 @@ import org.sagebionetworks.bridge.models.activities.StudyActivityEventIdsMap; import org.sagebionetworks.bridge.models.schedules2.Schedule2; import org.sagebionetworks.bridge.models.schedules2.StudyBurst; +import org.sagebionetworks.bridge.models.studies.Alert; import org.sagebionetworks.bridge.models.studies.Enrollment; import org.sagebionetworks.bridge.models.studies.Study; import org.sagebionetworks.bridge.models.studies.StudyCustomEvent; +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; public class StudyActivityEventServiceTest extends Mockito { private static final CacheKey ETAG_KEY = CacheKey.etag(StudyActivityEvent.class, TEST_USER_ID); @@ -98,6 +100,9 @@ public class StudyActivityEventServiceTest extends Mockito { @Mock CacheProvider mockCacheProvider; + + @Mock + AlertService alertService; @InjectMocks @Spy @@ -105,6 +110,9 @@ public class StudyActivityEventServiceTest extends Mockito { @Captor ArgumentCaptor eventCaptor; + + @Captor + ArgumentCaptor alertCaptor; Study study; @@ -281,6 +289,13 @@ public void publishEvent_noEventPersisted() { verify(mockDao).publishEvent(any()); verify(mockCacheProvider).setObject(ETAG_KEY, CREATED_ON); + + // verify alert for timeline retrieved + verify(alertService).createAlert(alertCaptor.capture()); + assertEquals(alertCaptor.getValue().getAppId(), TEST_APP_ID); + assertEquals(alertCaptor.getValue().getStudyId(), TEST_STUDY_ID); + assertEquals(alertCaptor.getValue().getUserId(), TEST_USER_ID); + assertEquals(alertCaptor.getValue().getCategory(), AlertCategory.TIMELINE_ACCESSED); } @Test @@ -357,6 +372,9 @@ public void publishEvent_throwsErrorWithMultipleFields() { } catch(BadRequestException e) { assertTrue(e.getMessage().contains("Study event(s) failed to publish: study_burst:foo:01, study_burst:foo:02")); } + + // verify no study burst alerts created + verifyZeroInteractions(alertService); } @Test @@ -388,6 +406,10 @@ public void publishEvent_studyBurstEventThrowsError() { } catch(BadRequestException e) { assertEquals(e.getMessage(), "Study event(s) failed to publish: study_burst:foo:01."); } + + // verify study burst alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertStudyBurstAlert(alertCaptor.getValue()); } @Test @@ -442,6 +464,10 @@ public void publishEvent_publishesStudyBursts() { assertEquals(sb3.getPeriodFromOrigin(), Period.parse("P3W")); verify(mockCacheProvider).setObject(ETAG_KEY, CREATED_ON); + + // verify study burst alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertStudyBurstAlert(alertCaptor.getValue()); } @Test @@ -486,6 +512,10 @@ public void publishEvent_publishesStudyBurstsNoDelays() { assertEquals(sb3.getEventId(), "study_burst:foo:03"); assertEquals(sb3.getTimestamp(), ENROLLMENT_TS.plusWeeks(2)); assertEquals(sb3.getPeriodFromOrigin(), Period.parse("P2W")); + + // verify study burst alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertStudyBurstAlert(alertCaptor.getValue()); } @Test @@ -541,6 +571,10 @@ public void publishEvent_studyBurstsSuppressedButIgnored() { service.publishEvent(event, false, false); verify(mockDao, times(4)).publishEvent(eventCaptor.capture()); + + // verify study burst alert created + verify(alertService).createAlert(alertCaptor.capture()); + assertStudyBurstAlert(alertCaptor.getValue()); } @Test @@ -1023,4 +1057,11 @@ public void getRecentStudyActivityEvents_noDuplicationError() { assertEquals(list.getItems().size(), 1); assertEquals(list.getItems().get(0).getTimestamp(), CREATED_ON); } -} \ No newline at end of file + + private void assertStudyBurstAlert(Alert alert) { + assertEquals(alert.getAppId(), TEST_APP_ID); + assertEquals(alert.getStudyId(), TEST_STUDY_ID); + assertEquals(alert.getUserId(), TEST_USER_ID); + assertEquals(alert.getCategory(), AlertCategory.STUDY_BURST_CHANGE); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/services/StudyServiceTest.java b/src/test/java/org/sagebionetworks/bridge/services/StudyServiceTest.java index aa629c866..fb3ce56cc 100644 --- a/src/test/java/org/sagebionetworks/bridge/services/StudyServiceTest.java +++ b/src/test/java/org/sagebionetworks/bridge/services/StudyServiceTest.java @@ -98,6 +98,9 @@ public class StudyServiceTest extends Mockito { @Mock private DemographicService mockDemographicService; + @Mock + private AlertService alertService; + @Captor private ArgumentCaptor studyCaptor; @@ -632,6 +635,9 @@ public void deleteStudy() { CacheKey cacheKey = CacheKey.etag(Study.class, TEST_APP_ID, TEST_STUDY_ID); verify(mockCacheProvider).removeObject(cacheKey); + + // verify alerts for this study are deleted + verify(alertService).deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); } @Test(expectedExceptions = BadRequestException.class, @@ -657,6 +663,9 @@ public void deleteStudy_adminCanForceStudyInWrongPhase() { verify(mockStudyDao).updateStudy(study); verify(mockCacheProvider).removeObject(CACHE_KEY); + + // verify alerts for this study are deleted + verify(alertService).deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); } @@ -680,7 +689,7 @@ public void deleteStudyPermanently() { CacheKey cacheKey = CacheKey.etag(Study.class, TEST_APP_ID, TEST_STUDY_ID); verify(mockCacheProvider).removeObject(cacheKey); - } + } @Test public void deleteStudyPermanently_deletesScheduleFirst() { @@ -746,6 +755,9 @@ public void transitionToDesign() { verify(mockStudyDao).updateStudy(study); assertEquals(study.getPhase(), DESIGN); assertEquals(study.getModifiedOn(), MODIFIED_ON); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -768,6 +780,9 @@ public void transitionToRecruitment() { assertEquals(study.getModifiedOn(), MODIFIED_ON); verify(mockScheduleService).publishSchedule(TEST_APP_ID, SCHEDULE_GUID); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -792,6 +807,9 @@ public void transitionToRecruitmentScheduleAlreadyPublished() { assertEquals(study.getModifiedOn(), MODIFIED_ON); verify(mockScheduleService, never()).publishSchedule(any(), any()); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -814,6 +832,9 @@ public void transitionToRecruitmentWithNoSchedule() { assertNull(study.getScheduleGuid()); verifyZeroInteractions(mockScheduleService); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -831,6 +852,9 @@ public void transitionToInFlight() { verify(mockStudyDao).updateStudy(study); assertEquals(study.getPhase(), IN_FLIGHT); assertEquals(study.getModifiedOn(), MODIFIED_ON); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -848,6 +872,9 @@ public void transitionToAnalysis() { verify(mockStudyDao).updateStudy(study); assertEquals(study.getPhase(), ANALYSIS); assertEquals(study.getModifiedOn(), MODIFIED_ON); + + // verify no alerts deleted + verifyZeroInteractions(alertService); } @Test @@ -865,6 +892,9 @@ public void transitionToCompleted() { verify(mockStudyDao).updateStudy(study); assertEquals(study.getPhase(), COMPLETED); assertEquals(study.getModifiedOn(), MODIFIED_ON); + + // verify alerts deleted for this study + verify(alertService).deleteAlertsForStudy(TEST_APP_ID, TEST_STUDY_ID); } @Test diff --git a/src/test/java/org/sagebionetworks/bridge/spring/controllers/AdherenceControllerTest.java b/src/test/java/org/sagebionetworks/bridge/spring/controllers/AdherenceControllerTest.java index 3d4cf4e37..8729b2a13 100644 --- a/src/test/java/org/sagebionetworks/bridge/spring/controllers/AdherenceControllerTest.java +++ b/src/test/java/org/sagebionetworks/bridge/spring/controllers/AdherenceControllerTest.java @@ -662,13 +662,13 @@ public void getWeeklyAdherenceReportForWorker() { .thenReturn(Optional.of(account)); WeeklyAdherenceReport report = new WeeklyAdherenceReport(); - when(mockService.getWeeklyAdherenceReport(TEST_APP_ID, TEST_STUDY_ID, account)) + when(mockService.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account)) .thenReturn(report); WeeklyAdherenceReport retValue = controller.getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, TEST_USER_ID); assertSame(retValue, report); - verify(mockService).getWeeklyAdherenceReport(TEST_APP_ID, TEST_STUDY_ID, account); + verify(mockService).getWeeklyAdherenceReportForWorker(TEST_APP_ID, TEST_STUDY_ID, account); } @Test(expectedExceptions = UnauthorizedException.class) @@ -755,4 +755,4 @@ public void getAdherenceStatistics_unauthorized() { controller.getAdherenceStatistics(TEST_STUDY_ID, null); } -} \ No newline at end of file +} diff --git a/src/test/java/org/sagebionetworks/bridge/spring/controllers/AlertControllerTest.java b/src/test/java/org/sagebionetworks/bridge/spring/controllers/AlertControllerTest.java new file mode 100644 index 000000000..d9f6e1a8e --- /dev/null +++ b/src/test/java/org/sagebionetworks/bridge/spring/controllers/AlertControllerTest.java @@ -0,0 +1,315 @@ +package org.sagebionetworks.bridge.spring.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sagebionetworks.bridge.BridgeConstants.API_DEFAULT_PAGE_SIZE; +import static org.sagebionetworks.bridge.TestConstants.TEST_APP_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_STUDY_ID; +import static org.sagebionetworks.bridge.TestConstants.TEST_USER_ID; +import static org.sagebionetworks.bridge.TestUtils.assertCrossOrigin; +import static org.sagebionetworks.bridge.TestUtils.assertPost; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; + +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.sagebionetworks.bridge.RequestContext; +import org.sagebionetworks.bridge.Roles; +import org.sagebionetworks.bridge.exceptions.UnauthorizedException; +import org.sagebionetworks.bridge.models.PagedResourceList; +import org.sagebionetworks.bridge.models.StatusMessage; +import org.sagebionetworks.bridge.models.accounts.StudyParticipant; +import org.sagebionetworks.bridge.models.accounts.UserSession; +import org.sagebionetworks.bridge.models.studies.Alert; +import org.sagebionetworks.bridge.models.studies.AlertCategoriesAndCounts; +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; +import org.sagebionetworks.bridge.services.AlertService; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +public class AlertControllerTest { + @Spy + @InjectMocks + AlertController alertController; + + @Mock + AlertService alertService; + + UserSession session; + + @BeforeMethod + public void beforeMethod() { + MockitoAnnotations.initMocks(this); + + session = new UserSession(); + session.setAppId(TEST_APP_ID); + session.setParticipant(new StudyParticipant.Builder().withId(TEST_USER_ID).build()); + doReturn(session).when(alertController).getAuthenticatedAndConsentedSession(); + doReturn(session).when(alertController).getAuthenticatedSession(ArgumentMatchers.any()); + } + + @AfterMethod + public void afterMethod() { + RequestContext.set(RequestContext.NULL_INSTANCE); + } + + @Test + public void verifyAnnotations() throws Exception { + assertCrossOrigin(AlertController.class); + assertPost(AlertController.class, "getAlerts"); + assertPost(AlertController.class, "deleteAlerts"); + } + + @Test + public void getAlerts() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.RESEARCHER)).build()); + PagedResourceList alerts = new PagedResourceList<>(ImmutableList.of(new Alert()), 1); + when(alertService.getAlerts(any(), any(), anyInt(), anyInt(), any())).thenReturn(alerts); + AlertFilter alertFilter = new AlertFilter(ImmutableSet.of()); + doReturn(alertFilter).when(alertController).parseJson(AlertFilter.class); + + PagedResourceList returnedAlerts = alertController.getAlerts(TEST_STUDY_ID, "1", "23"); + + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertController).parseJson(AlertFilter.class); + verify(alertService).getAlerts(TEST_APP_ID, TEST_STUDY_ID, 1, 23, alertFilter); + assertSame(returnedAlerts, alerts); + } + + @Test + public void getAlerts_blankParams() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + PagedResourceList alerts = new PagedResourceList<>(ImmutableList.of(new Alert()), 1); + when(alertService.getAlerts(any(), any(), anyInt(), anyInt(), any())).thenReturn(alerts); + AlertFilter alertFilter = new AlertFilter(ImmutableSet.of()); + doReturn(alertFilter).when(alertController).parseJson(AlertFilter.class); + + PagedResourceList returnedAlerts = alertController.getAlerts(TEST_STUDY_ID, null, null); + + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertController).parseJson(AlertFilter.class); + verify(alertService).getAlerts(TEST_APP_ID, TEST_STUDY_ID, 0, API_DEFAULT_PAGE_SIZE, alertFilter); + assertSame(returnedAlerts, alerts); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void getAlerts_cannotEditStudyParticipants_noRoles() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)).build()); + + alertController.getAlerts(TEST_STUDY_ID, null, null); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void getAlerts_cannotEditStudyParticipants_wrongStudy() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of("wrong study id")) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + + alertController.getAlerts(TEST_STUDY_ID, null, null); + } + + @Test(expectedExceptions = MismatchedInputException.class) + public void getAlerts_wrongSchema() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + doAnswer((invocation) -> { + throw MismatchedInputException.from(new JsonFactory().createParser("[]"), AlertFilter.class, + "bad json"); + }).when(alertController).parseJson(AlertFilter.class); + + alertController.getAlerts(TEST_STUDY_ID, null, null); + } + + @Test + public void deleteAlerts() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + AlertIdCollection alertIdCollection = new AlertIdCollection(ImmutableList.of("foo")); + doReturn(alertIdCollection).when(alertController).parseJson(AlertIdCollection.class); + + StatusMessage message = alertController.deleteAlerts(TEST_STUDY_ID); + + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertController).parseJson(AlertIdCollection.class); + verify(alertService).deleteAlerts(eq(TEST_APP_ID), eq(TEST_STUDY_ID), same(alertIdCollection)); + assertEquals(message.getMessage(), "Alerts successfully deleted"); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void deleteAlerts_cannotEditStudyParticipants_noRoles() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)).build()); + + alertController.deleteAlerts(TEST_STUDY_ID); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void deleteAlerts_cannotEditStudyParticipants_wrongStudy() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of("wrong study id")) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + + alertController.deleteAlerts(TEST_STUDY_ID); + } + + @Test(expectedExceptions = MismatchedInputException.class) + public void deleteAlerts_wrongSchema() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + doAnswer((invocation) -> { + throw MismatchedInputException.from(new JsonFactory().createParser("[]"), AlertIdCollection.class, + "bad json"); + }).when(alertController).parseJson(AlertIdCollection.class); + + alertController.deleteAlerts(TEST_STUDY_ID); + } + + @Test + public void markAlertsRead() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + AlertIdCollection alertIdCollection = new AlertIdCollection(ImmutableList.of("foo")); + doReturn(alertIdCollection).when(alertController).parseJson(AlertIdCollection.class); + + StatusMessage message = alertController.markAlertsRead(TEST_STUDY_ID); + + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertController).parseJson(AlertIdCollection.class); + verify(alertService).markAlertsRead(eq(TEST_APP_ID), eq(TEST_STUDY_ID), same(alertIdCollection)); + assertEquals(message.getMessage(), "Alerts successfully marked as read"); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void markAlertsRead_cannotEditStudyParticipants_noRoles() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)).build()); + + alertController.markAlertsRead(TEST_STUDY_ID); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void markAlertsRead_cannotEditStudyParticipants_wrongStudy() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of("wrong study id")) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + + alertController.markAlertsRead(TEST_STUDY_ID); + } + + @Test(expectedExceptions = MismatchedInputException.class) + public void markAlertsRead_wrongSchema() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + doAnswer((invocation) -> { + throw MismatchedInputException.from(new JsonFactory().createParser("[]"), AlertIdCollection.class, + "bad json"); + }).when(alertController).parseJson(AlertIdCollection.class); + + alertController.markAlertsRead(TEST_STUDY_ID); + } + + @Test + public void markAlertsUnread() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + AlertIdCollection alertIdCollection = new AlertIdCollection(ImmutableList.of("foo")); + doReturn(alertIdCollection).when(alertController).parseJson(AlertIdCollection.class); + + StatusMessage message = alertController.markAlertsUnread(TEST_STUDY_ID); + + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertController).parseJson(AlertIdCollection.class); + verify(alertService).markAlertsUnread(eq(TEST_APP_ID), eq(TEST_STUDY_ID), same(alertIdCollection)); + assertEquals(message.getMessage(), "Alerts successfully marked as unread"); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void markAlertsUnread_cannotEditStudyParticipants_noRoles() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)).build()); + + alertController.markAlertsUnread(TEST_STUDY_ID); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void markAlertsUnread_cannotEditStudyParticipants_wrongStudy() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of("wrong study id")) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + + alertController.markAlertsUnread(TEST_STUDY_ID); + } + + @Test(expectedExceptions = MismatchedInputException.class) + public void markAlertsUnread_wrongSchema() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + doAnswer((invocation) -> { + throw MismatchedInputException.from(new JsonFactory().createParser("[]"), AlertIdCollection.class, + "bad json"); + }).when(alertController).parseJson(AlertIdCollection.class); + + alertController.markAlertsUnread(TEST_STUDY_ID); + } + + @Test + public void getAlertCategoriesAndCounts() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + AlertCategoriesAndCounts alertCategoriesAndCounts = new AlertCategoriesAndCounts(); + when(alertService.getAlertCategoriesAndCounts(TEST_APP_ID, TEST_STUDY_ID)).thenReturn(alertCategoriesAndCounts); + + AlertCategoriesAndCounts returnedAlertCategoriesAndCounts = alertController + .getAlertCategoriesAndCounts(TEST_STUDY_ID); + + assertSame(returnedAlertCategoriesAndCounts, alertCategoriesAndCounts); + verify(alertController).getAuthenticatedSession(Roles.RESEARCHER, Roles.STUDY_COORDINATOR); + verify(alertService).getAlertCategoriesAndCounts(eq(TEST_APP_ID), eq(TEST_STUDY_ID)); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void getAlertCategoriesAndCounts_cannotEditStudyParticipants_noRoles() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of(TEST_STUDY_ID)).build()); + + alertController.getAlertCategoriesAndCounts(TEST_STUDY_ID); + } + + @Test(expectedExceptions = UnauthorizedException.class) + public void getAlertCategoriesAndCounts_cannotEditStudyParticipants_wrongStudy() { + RequestContext.set(new RequestContext.Builder() + .withOrgSponsoredStudies(ImmutableSet.of("wrong study id")) + .withCallerRoles(ImmutableSet.of(Roles.STUDY_COORDINATOR)).build()); + + alertController.getAlertCategoriesAndCounts(TEST_STUDY_ID); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/validators/AlertFilterValidatorTest.java b/src/test/java/org/sagebionetworks/bridge/validators/AlertFilterValidatorTest.java new file mode 100644 index 000000000..a4130169d --- /dev/null +++ b/src/test/java/org/sagebionetworks/bridge/validators/AlertFilterValidatorTest.java @@ -0,0 +1,44 @@ +package org.sagebionetworks.bridge.validators; + +import static org.sagebionetworks.bridge.TestUtils.assertValidatorMessage; +import static org.sagebionetworks.bridge.validators.Validate.CANNOT_BE_NULL; +import static org.testng.Assert.assertTrue; + +import org.sagebionetworks.bridge.models.studies.Alert.AlertCategory; +import org.sagebionetworks.bridge.models.studies.AlertFilter; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.common.collect.ImmutableSet; + +public class AlertFilterValidatorTest { + private final AlertFilterValidator validator = new AlertFilterValidator(); + private AlertFilter alertFilter; + + @BeforeMethod + public void beforeMethod() { + alertFilter = new AlertFilter(ImmutableSet.of(AlertCategory.NEW_ENROLLMENT)); + } + + @Test + public void supports() { + assertTrue(validator.supports(AlertFilter.class)); + } + + @Test + public void valid() { + Validate.entityThrowingException(validator, alertFilter); + } + + @Test + public void valid_Empty() { + alertFilter.setAlertCategories(ImmutableSet.of()); + Validate.entityThrowingException(validator, alertFilter); + } + + @Test + public void invalid_nullAlertCategories() { + alertFilter.setAlertCategories(null); + assertValidatorMessage(validator, alertFilter, "alertCategories", CANNOT_BE_NULL); + } +} diff --git a/src/test/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidatorTest.java b/src/test/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidatorTest.java new file mode 100644 index 000000000..ff1a305b6 --- /dev/null +++ b/src/test/java/org/sagebionetworks/bridge/validators/AlertIdCollectionValidatorTest.java @@ -0,0 +1,44 @@ +package org.sagebionetworks.bridge.validators; + +import static org.sagebionetworks.bridge.TestUtils.assertValidatorMessage; +import static org.sagebionetworks.bridge.validators.Validate.CANNOT_BE_NULL; +import static org.testng.Assert.assertTrue; + +import org.sagebionetworks.bridge.models.studies.AlertIdCollection; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.common.collect.ImmutableList; + +public class AlertIdCollectionValidatorTest { + private final AlertIdCollectionValidator validator = new AlertIdCollectionValidator(); + private static final String ALERT_ID = "test-alert-id"; + private AlertIdCollection alertIdCollection; + + @BeforeMethod + public void beforeMethod() { + alertIdCollection = new AlertIdCollection(ImmutableList.of(ALERT_ID)); + } + + @Test + public void supports() { + assertTrue(validator.supports(AlertIdCollection.class)); + } + + @Test + public void valid() { + Validate.entityThrowingException(validator, alertIdCollection); + } + + @Test + public void valid_Empty() { + alertIdCollection.setAlertIds(ImmutableList.of()); + Validate.entityThrowingException(validator, alertIdCollection); + } + + @Test + public void invalid_nullAlertIds() { + alertIdCollection.setAlertIds(null); + assertValidatorMessage(validator, alertIdCollection, "alertIds", CANNOT_BE_NULL); + } +}