diff --git a/.gitignore b/.gitignore index 9140fd929a..aaa11692cf 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,8 @@ project/plugins/project/ *.ear # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +core +core.[0-9]* hs_err_pid* ### OSX ### diff --git a/docs/ai/CLAUDE.md b/docs/ai/CLAUDE.md index 0b315a0832..c79ecd7479 100644 --- a/docs/ai/CLAUDE.md +++ b/docs/ai/CLAUDE.md @@ -34,17 +34,27 @@ instantiated via `jdbi.onDemand()`. ## ConsentModule singleton pattern -Every `@Provides` method in `ConsentModule` that creates a new service or DAO instance uses -`@Singleton` + `synchronized` + a lazy null-guard field to guarantee a single instance -on both the Guice injection path and the direct inter-provider call path: +Every `@Provides` method in `ConsentModule` that creates a new service or DAO instance is +annotated `@Singleton`, and takes each of its dependencies as a method parameter so Guice +resolves them. Guice caches the singleton itself, so no lazy field or `synchronized` guard +is needed: ```java @Provides @Singleton -synchronized EmailService providesEmailService() { - if (emailService == null) { - emailService = new EmailService(...); - } - return emailService; +private DatasetService providesDatasetService( + Jdbi jdbi, + DatasetServiceDAO datasetServiceDAO, + ElasticSearchService elasticSearchService, + EmailService emailService, + OntologyService ontologyService) { + return new DatasetService( + jdbi, datasetServiceDAO, elasticSearchService, emailService, ontologyService); } ``` + +Never call one `@Provides` method from another. A direct call bypasses Guice's scoping and +builds a second instance with its own `jdbi.onDemand` DAOs. Declare the dependency as a +parameter instead. Adding a new service means adding a provider here — a service that is +only JIT-bound (constructed by Guice without a declared provider) is unscoped, so a second +injection point silently creates a second instance. diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java index a962ca5a46..39c1d4d60c 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java @@ -80,6 +80,8 @@ import org.broadinstitute.consent.http.resources.SamResource; import org.broadinstitute.consent.http.resources.SigningOfficialDashboardResource; import org.broadinstitute.consent.http.resources.StatusResource; +import org.broadinstitute.consent.http.resources.StudyAssetResource; +import org.broadinstitute.consent.http.resources.StudyCommentResource; import org.broadinstitute.consent.http.resources.StudyDatasetTemplateResource; import org.broadinstitute.consent.http.resources.StudyResource; import org.broadinstitute.consent.http.resources.SupportResource; @@ -200,6 +202,8 @@ public void run(ConsentConfiguration config, Environment env) { env.jersey().register(injector.getInstance(StatusResource.class)); env.jersey().register(injector.getInstance(StudyDatasetTemplateResource.class)); env.jersey().register(injector.getInstance(StudyResource.class)); + env.jersey().register(injector.getInstance(StudyAssetResource.class)); + env.jersey().register(injector.getInstance(StudyCommentResource.class)); env.jersey().register(injector.getInstance(SupportResource.class)); env.jersey().register(injector.getInstance(TDRResource.class)); env.jersey().register(injector.getInstance(TosResource.class)); diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java index 3888d2bc92..f99977a601 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java @@ -76,6 +76,8 @@ import org.broadinstitute.consent.http.service.OntologyService; import org.broadinstitute.consent.http.service.ResearcherDashboardService; import org.broadinstitute.consent.http.service.SigningOfficialDashboardService; +import org.broadinstitute.consent.http.service.StudyAssetService; +import org.broadinstitute.consent.http.service.StudyCommentService; import org.broadinstitute.consent.http.service.SupportRequestService; import org.broadinstitute.consent.http.service.UseRestrictionConverter; import org.broadinstitute.consent.http.service.UserService; @@ -635,8 +637,21 @@ private CounterService providesCounterService(Jdbi jdbi) { @Provides @Singleton - private MetricsService providesMetricsService(Jdbi jdbi) { - return new MetricsService(jdbi); + private MetricsService providesMetricsService(Jdbi jdbi, DatasetService datasetService) { + return new MetricsService(jdbi, datasetService); + } + + @Provides + @Singleton + private StudyAssetService providesStudyAssetService(DatasetService datasetService) { + return new StudyAssetService(datasetService); + } + + @Provides + @Singleton + private StudyCommentService providesStudyCommentService( + Jdbi jdbi, DatasetService datasetService) { + return new StudyCommentService(jdbi, datasetService); } @Provides diff --git a/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java b/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java index 6ea4ca746a..676e257f6b 100644 --- a/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java +++ b/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java @@ -105,6 +105,12 @@ protected boolean authorize(AuthUser authUser, String role) { boolean authorize = false; try { User user = userService.findUserByEmail(authUser.getEmail()); + // A user with no user_role rows has a null role list, not an empty one. Without this guard + // the authorizer throws a NullPointerException and Jersey turns what should be a plain + // denial into a 500 on every @RolesAllowed endpoint. Mirrors User#hasAnyUserRole. + if (user == null || user.getRoles() == null) { + return false; + } return user.getRoles().stream().anyMatch(r -> r.getName().equalsIgnoreCase(role)); } catch (NotFoundException e) { logWarn("User not found, authorization incomplete: %s".formatted(authUser.getEmail())); diff --git a/src/main/java/org/broadinstitute/consent/http/db/DataAccessRequestDAO.java b/src/main/java/org/broadinstitute/consent/http/db/DataAccessRequestDAO.java index 1e1dfd81fc..1d1948e740 100644 --- a/src/main/java/org/broadinstitute/consent/http/db/DataAccessRequestDAO.java +++ b/src/main/java/org/broadinstitute/consent/http/db/DataAccessRequestDAO.java @@ -103,22 +103,22 @@ AND dar.collection_id NOT IN ( List findApprovedDARsByDatasetId(@Bind("datasetId") Integer datasetId); /** - * Returns one {@link DarMetricsSummary} per DAR collection that contains at least one approved - * DAR or closeout supplement for the given dataset, ordered by DAR code ascending. Unlike {@link - * #findApprovedDARsByDatasetId(Integer) findApprovedDARsByDatasetId}, expired DARs are included - * so they appear in dataset usage metrics. + * Returns one {@link DarMetricsSummary} per DAR collection holding an approval on the given + * dataset, ordered by DAR code ascending. Unlike {@link #findApprovedDARsByDatasetId(Integer) + * findApprovedDARsByDatasetId}, expired DARs are included so they appear in dataset usage + * metrics. * - *

A collection is included when either of the following is true for the given dataset: + *

A collection is included when at least one submitted, non-archived DAR in it has a terminal + * {@code final} or {@code radar_approve} vote on this dataset whose last value is {@code TRUE}. + * The approval has to be on this dataset: one DAR can be granted some of the datasets it asks for + * and denied the rest, and a dataset it was denied has nothing to report. Follow-on submissions + * qualify a collection only on the same terms: a progress report gets its own election and counts + * once that election approves it, while a closeout has no election at all and so never does. + * Otherwise either could speak for an approval that was never given. * - *

- * - *

Each summary is sourced from the most recently submitted DAR in the collection that is - * linked to the given dataset. Only the fields needed for dataset usage metrics are selected. + *

Each summary is sourced from the most recently submitted DAR that qualified the collection, + * so it always describes a granted request. Only the fields needed for dataset usage metrics are + * selected, and the requester's institution is among them while their name is not. * * @param datasetId the dataset to filter by * @return list of {@link DarMetricsSummary}, one per qualifying collection, ordered by {@code @@ -127,8 +127,8 @@ AND dar.collection_id NOT IN ( @RegisterRowMapper(DarMetricsSummaryMapper.class) @SqlQuery( """ - WITH approved_collections AS ( - SELECT DISTINCT dar.collection_id + WITH qualifying_dars AS ( + SELECT DISTINCT dar.reference_id, dar.collection_id FROM data_access_request dar INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id INNER JOIN ( @@ -149,43 +149,197 @@ AND LOWER(v.type) IN ('final', 'radar_approve') AND dar.submission_date IS NOT NULL AND final_access_vote.last_vote = TRUE AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) - -- Pull in all closeouts for this dataset. Closeouts do not have elections, - -- but we want to include them in the dataset usage metrics. - UNION - SELECT DISTINCT dar.collection_id + ), approved_collections AS ( + -- One row per collection, carrying the day access to this dataset began + SELECT q.collection_id, MIN(dar.submission_date) AS first_submission + FROM qualifying_dars q + INNER JOIN data_access_request dar ON dar.reference_id = q.reference_id + GROUP BY q.collection_id + ), closeouts AS ( + -- A closeout revokes access the day it is filed, ending the grant before its term + -- runs out. It closes the whole collection rather than only the datasets its own + -- report names, which is how findApprovedDARsByDatasetId, findDatasetApprovalsByDar + -- and DatasetDAO.findApprovedDatasetsByUserId already treat one. + SELECT dar.collection_id, MAX(dar.submission_date) AS closeout_date FROM data_access_request dar - INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id - WHERE dd.dataset_id = :datasetId - AND dar.submission_date IS NOT NULL + WHERE dar.submission_date IS NOT NULL AND dar.data ->> 'closeoutSupplement' IS NOT NULL + GROUP BY dar.collection_id ) SELECT c.dar_code, - latest_dar.submission_date, + approved_collections.first_submission AS submission_date, + latest_dar.submission_date AS expiration_basis_date, + closeouts.closeout_date, latest_dar.reference_id, latest_dar.update_date, latest_dar.data ->> 'projectTitle' AS project_title, - latest_dar.data ->> 'nonTechRus' AS non_tech_rus + latest_dar.data ->> 'nonTechRus' AS non_tech_rus, + latest_dar.data ->> 'rus' AS rus, + -- The requester's institution, but never their name: the pages show where a grant + -- went, not who holds it. + i.institution_name FROM dar_collection c INNER JOIN approved_collections ON c.collection_id = approved_collections.collection_id - -- Source the summary from the most recently submitted DAR in the collection that is - -- linked to :datasetId. Constraining to the dataset here (rather than filtering after - -- picking the collection-wide latest) keeps a collection whose newest DAR targets a - -- different dataset from being dropped. + -- Source the summary from the most recently submitted DAR in the collection that itself + -- qualified, as the study-scoped query does. Constraining to the qualifying DARs, rather + -- than to anything submitted against :datasetId, keeps a collection whose newest DAR + -- targets a different dataset from being dropped and keeps a pending progress report + -- from standing in for the grant - which would have shown its submitter as the PI. INNER JOIN ( SELECT DISTINCT ON (dar.collection_id) dar.* FROM data_access_request dar - INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id - WHERE dar.submission_date IS NOT NULL - AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) - AND dd.dataset_id = :datasetId - ORDER BY dar.collection_id, dar.submission_date DESC + INNER JOIN qualifying_dars q ON q.reference_id = dar.reference_id + WHERE (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ORDER BY dar.collection_id, dar.submission_date DESC, dar.id DESC ) latest_dar ON latest_dar.collection_id = c.collection_id + LEFT JOIN closeouts ON closeouts.collection_id = c.collection_id + LEFT JOIN users u ON u.user_id = latest_dar.user_id + LEFT JOIN institution i ON i.institution_id = u.institution_id ORDER BY c.dar_code """) List findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( @Bind("datasetId") Integer datasetId); + /** + * The study-scoped counterpart of {@link + * #findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(Integer)}. A DAR qualifies when it was + * approved on at least one of the study's datasets; each summary is sourced from the most + * recently submitted qualifying DAR in the collection. A grant denied on one dataset but approved + * on another in the same study still appears here, having been granted access to the study, even + * though the denied dataset's own page omits it. Doing the whole study in one round trip avoids + * re-running this query once per dataset. + * + *

The display record has to come from a qualifying DAR rather than from whatever the + * collection's newest submission happens to be. This section presents each row as a granted + * request, and {@link DarMetricsSummaryMapper} derives the current/expired chip from the sourced + * DAR's submission date; a progress report still awaiting the outcome of its own election would + * otherwise overwrite the grant's title, RUS, and date and reset it to "current". + * + * @param studyId the study to filter by + * @return list of {@link DarMetricsSummary}, one per qualifying collection, newest grant first + */ + @RegisterRowMapper(DarMetricsSummaryMapper.class) + @SqlQuery( + """ + WITH study_datasets AS ( + SELECT dataset_id FROM dataset WHERE study_id = :studyId + ), qualifying_dars AS ( + SELECT DISTINCT dar.reference_id, dar.collection_id + FROM data_access_request dar + INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id + INNER JOIN study_datasets sd ON sd.dataset_id = dd.dataset_id + INNER JOIN ( + SELECT DISTINCT e.reference_id, e.dataset_id, + LAST_VALUE(v.vote) OVER( + PARTITION BY e.reference_id, e.dataset_id + ORDER BY v.create_date + RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) last_vote + FROM election e + -- Bound to the study's datasets inside the window, not after it. The outer join + -- to dd.dataset_id cannot be pushed in here, so without this the window sorts + -- and partitions every dataaccess election and vote in the table to answer for a + -- study with a handful of datasets. The partition is already per dataset, so + -- dropping other datasets' rows leaves every surviving partition untouched. + INNER JOIN study_datasets sds ON sds.dataset_id = e.dataset_id + INNER JOIN vote v ON e.election_id = v.election_id + AND v.vote IS NOT NULL + AND LOWER(e.election_type) = 'dataaccess' + AND LOWER(v.type) IN ('final', 'radar_approve') + ) final_access_vote ON final_access_vote.reference_id = dar.reference_id + AND final_access_vote.dataset_id = dd.dataset_id + WHERE dar.submission_date IS NOT NULL + AND final_access_vote.last_vote = TRUE + AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ), approved_collections AS ( + -- One row per collection, carrying the day access to this study began + SELECT q.collection_id, MIN(dar.submission_date) AS first_submission + FROM qualifying_dars q + INNER JOIN data_access_request dar ON dar.reference_id = q.reference_id + GROUP BY q.collection_id + ), closeouts AS ( + -- A closeout closes the whole collection, as in the dataset-scoped query above + SELECT dar.collection_id, MAX(dar.submission_date) AS closeout_date + FROM data_access_request dar + WHERE dar.submission_date IS NOT NULL + AND dar.data ->> 'closeoutSupplement' IS NOT NULL + GROUP BY dar.collection_id + ) + SELECT + c.dar_code, + approved_collections.first_submission AS submission_date, + latest_dar.submission_date AS expiration_basis_date, + closeouts.closeout_date, + latest_dar.reference_id, + latest_dar.update_date, + latest_dar.data ->> 'projectTitle' AS project_title, + latest_dar.data ->> 'nonTechRus' AS non_tech_rus, + latest_dar.data ->> 'rus' AS rus, + -- Institution, not name: see the dataset-scoped query above. + i.institution_name + FROM dar_collection c + INNER JOIN approved_collections ON c.collection_id = approved_collections.collection_id + -- Source the summary from the most recently submitted DAR in the collection that itself + -- qualified. Constraining to the study's datasets and to a grant/closeout here, rather + -- than filtering after picking the collection-wide latest, keeps a collection whose + -- newest DAR targets a different study from being dropped and keeps a pending + -- submission from standing in for the grant. + INNER JOIN ( + SELECT DISTINCT ON (dar.collection_id) dar.* + FROM data_access_request dar + INNER JOIN qualifying_dars q ON q.reference_id = dar.reference_id + WHERE (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ORDER BY dar.collection_id, dar.submission_date DESC, dar.id DESC + ) latest_dar ON latest_dar.collection_id = c.collection_id + LEFT JOIN closeouts ON closeouts.collection_id = c.collection_id + LEFT JOIN users u ON u.user_id = latest_dar.user_id + LEFT JOIN institution i ON i.institution_id = u.institution_id + -- Newest first by the date the row carries, which is when access began. Ordering by + -- the sourced DAR's own date instead would float an old grant to the top the moment it + -- was renewed, and the cards would read out of order. + ORDER BY approved_collections.first_submission DESC, c.dar_code + """) + List findSummaryMetricApprovedDARsByStudyIdIncludesExpired( + @Bind("studyId") Integer studyId); + + @UseRowReducer(DataAccessRequestReducer.class) + @SqlQuery( + """ + SELECT dar.id, dar.reference_id, dar.collection_id, dar.parent_id, dar.user_id, + dar.create_date, dar.submission_date, dar.update_date, dar.data, dd.dataset_id, + collection.dar_code, dar.era_commons_id, dar.admin_dar_notes, + dar.approving_so_id, dar.approving_so_timestamp, dar.requires_so_approval + FROM data_access_request dar + INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id + INNER JOIN dataset d ON d.dataset_id = dd.dataset_id + LEFT JOIN dar_collection collection ON collection.collection_id = dar.collection_id + WHERE d.study_id = :studyId + AND dar.parent_id IS NOT NULL + AND dar.submission_date IS NOT NULL + AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ORDER BY dar.submission_date DESC, dar.id DESC + """) + List findProgressReportsByStudyId(@Bind("studyId") Integer studyId); + + @UseRowReducer(DataAccessRequestReducer.class) + @SqlQuery( + """ + SELECT dar.id, dar.reference_id, dar.collection_id, dar.parent_id, dar.user_id, + dar.create_date, dar.submission_date, dar.update_date, dar.data, dd.dataset_id, + collection.dar_code, dar.era_commons_id, dar.admin_dar_notes, + dar.approving_so_id, dar.approving_so_timestamp, dar.requires_so_approval + FROM data_access_request dar + INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id + LEFT JOIN dar_collection collection ON collection.collection_id = dar.collection_id + WHERE dd.dataset_id = :datasetId + AND dar.parent_id IS NOT NULL + AND dar.submission_date IS NOT NULL + AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ORDER BY dar.submission_date DESC, dar.id DESC + """) + List findProgressReportsByDatasetId(@Bind("datasetId") Integer datasetId); + /** * This query finds dataset ids on dar-dataset combinations where the most recent vote is true. * This includes datasets that are a part of expired DARs, UNLIKE findApprovedDARsByDatasetId. The diff --git a/src/main/java/org/broadinstitute/consent/http/db/DatasetDAO.java b/src/main/java/org/broadinstitute/consent/http/db/DatasetDAO.java index 5d257ee81c..11cdceb980 100644 --- a/src/main/java/org/broadinstitute/consent/http/db/DatasetDAO.java +++ b/src/main/java/org/broadinstitute/consent/http/db/DatasetDAO.java @@ -328,6 +328,19 @@ LEFT JOIN (SELECT DISTINCT dataset_id AS id FROM dar_dataset) dar_ds_ids s.update_date AS s_update_date, s.public_visibility AS s_public_visibility, s.uuid AS s_uuid, + -- The PI's profile links are columns on the study row, so they carry the s_ prefix the + -- Study bean mapper is registered with. Without them this route reported them null even + -- when stored, the same way it reported no institution. + s.pi_orcid AS s_pi_orcid, + s.pi_linkedin_url AS s_pi_linkedin_url, + s.pi_website_url AS s_pi_website_url, + -- StudyReducer reads the institution columns unprefixed, as StudyDAO's queries alias + -- them. Without these the study on this route reported no PI institution at all, though + -- Dataset.yaml reuses Study.yaml and documents the field. + s.pi_institution_id AS pi_institution_id, + i.institution_name AS pi_institution_name, + i.create_date AS pi_institution_create_date, + i.update_date AS pi_institution_update_date, sp.study_property_id AS sp_study_property_id, sp.study_id AS sp_study_id, sp.key AS sp_key, @@ -335,6 +348,7 @@ LEFT JOIN (SELECT DISTINCT dataset_id AS id FROM dar_dataset) dar_ds_ids sp.type AS sp_type FROM dataset d LEFT JOIN study s ON s.study_id = d.study_id + LEFT JOIN institution i ON i.institution_id = s.pi_institution_id LEFT JOIN study_property sp ON sp.study_id = s.study_id WHERE d.dataset_id = :datasetId """) diff --git a/src/main/java/org/broadinstitute/consent/http/db/StudyCommentDAO.java b/src/main/java/org/broadinstitute/consent/http/db/StudyCommentDAO.java new file mode 100644 index 0000000000..d9cd1e0839 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/db/StudyCommentDAO.java @@ -0,0 +1,104 @@ +package org.broadinstitute.consent.http.db; + +import java.util.List; +import org.broadinstitute.consent.http.models.StudyComment; +import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.jdbi.v3.sqlobject.statement.SqlUpdate; + +public interface StudyCommentDAO { + @RegisterConstructorMapper(StudyComment.class) + @SqlQuery( + """ + SELECT sc.*, u.display_name, i.institution_name + FROM study_comment sc + INNER JOIN users u ON u.user_id = sc.user_id + LEFT JOIN institution i ON i.institution_id = u.institution_id + WHERE sc.study_id = :studyId + ORDER BY sc.create_date DESC, sc.study_comment_id DESC + LIMIT :limit OFFSET :offset + """) + List findByStudyId( + @Bind("studyId") Integer studyId, @Bind("limit") int limit, @Bind("offset") int offset); + + /** One comment by id, scoped to its study so it cannot be read through another study's route. */ + @RegisterConstructorMapper(StudyComment.class) + @SqlQuery( + """ + SELECT sc.*, u.display_name, i.institution_name + FROM study_comment sc + INNER JOIN users u ON u.user_id = sc.user_id + LEFT JOIN institution i ON i.institution_id = u.institution_id + WHERE sc.study_id = :studyId AND sc.study_comment_id = :commentId + """) + StudyComment findById(@Bind("studyId") Integer studyId, @Bind("commentId") Integer commentId); + + /** + * The comment a user holds on a study, if any. study_comment is unique on (study_id, user_id), so + * there is at most one. + * + *

Returned alongside a page so a reader's own comment is always available: paging means it may + * sit on any page, and the composer has to know whether it exists to say whether saving adds or + * revises. + */ + @RegisterConstructorMapper(StudyComment.class) + @SqlQuery( + """ + SELECT sc.*, u.display_name, i.institution_name + FROM study_comment sc + INNER JOIN users u ON u.user_id = sc.user_id + LEFT JOIN institution i ON i.institution_id = u.institution_id + WHERE sc.study_id = :studyId AND sc.user_id = :userId + """) + StudyComment findByStudyIdAndUserId( + @Bind("studyId") Integer studyId, @Bind("userId") Integer userId); + + @SqlQuery("SELECT count(*) FROM study_comment WHERE study_id = :studyId") + int countByStudyId(@Bind("studyId") Integer studyId); + + /** + * The mean rating over every comment on the study, not only the page being returned, so the + * average a caller sees does not change as they page through. Null when there are none. + */ + @SqlQuery("SELECT avg(rating) FROM study_comment WHERE study_id = :studyId") + Double averageRatingByStudyId(@Bind("studyId") Integer studyId); + + @SqlUpdate( + """ + INSERT INTO study_comment (study_id, user_id, rating, comment_text) + VALUES (:studyId, :userId, :rating, :commentText) + ON CONFLICT (study_id, user_id) DO UPDATE SET + rating = EXCLUDED.rating, comment_text = EXCLUDED.comment_text, update_date = now() + RETURNING study_comment_id + """) + @GetGeneratedKeys + Integer upsert( + @Bind("studyId") Integer studyId, + @Bind("userId") Integer userId, + @Bind("rating") Integer rating, + @Bind("commentText") String commentText); + + @SqlUpdate( + """ + DELETE FROM study_comment + WHERE study_comment_id = :commentId AND study_id = :studyId AND user_id = :userId + """) + int deleteOwn( + @Bind("studyId") Integer studyId, + @Bind("commentId") Integer commentId, + @Bind("userId") Integer userId); + + /** + * Deletes a comment whoever wrote it. Separate from {@link #deleteOwn} rather than a nullable + * user id, so a moderation delete has to be asked for explicitly and cannot be reached by passing + * a null through the author path. + */ + @SqlUpdate( + """ + DELETE FROM study_comment + WHERE study_comment_id = :commentId AND study_id = :studyId + """) + int deleteAny(@Bind("studyId") Integer studyId, @Bind("commentId") Integer commentId); +} diff --git a/src/main/java/org/broadinstitute/consent/http/db/StudyDAO.java b/src/main/java/org/broadinstitute/consent/http/db/StudyDAO.java index 0ad5ad64f8..05f1c3a56d 100644 --- a/src/main/java/org/broadinstitute/consent/http/db/StudyDAO.java +++ b/src/main/java/org/broadinstitute/consent/http/db/StudyDAO.java @@ -61,6 +61,9 @@ private Study assembleStudy(StudyDAO dao, Integer studyId) { """ SELECT s.*, + i.institution_name AS pi_institution_name, + i.create_date AS pi_institution_create_date, + i.update_date AS pi_institution_update_date, sp.study_property_id AS sp_study_property_id, sp.study_id AS sp_study_id, sp.key AS sp_key, @@ -68,6 +71,7 @@ private Study assembleStudy(StudyDAO dao, Integer studyId) { sp.type AS sp_type FROM study s + LEFT JOIN institution i ON i.institution_id = s.pi_institution_id LEFT JOIN study_property sp ON sp.study_id = s.study_id WHERE s.study_id = :studyId """) @@ -133,6 +137,16 @@ Integer insertStudy( @Bind("createDate") Instant createDate, @Bind("uuid") UUID uuid); + /** + * Sets the PI institution on a newly registered study. Registration collects the PI institution + * as the numeric `piInstitution` field, but the study page reads the column, so the create path + * records both. Separate from {@link #insertStudy} so the many callers that register a study + * without one are unaffected. + */ + @SqlUpdate("UPDATE study SET pi_institution_id = :piInstitutionId WHERE study_id = :studyId") + void updateStudyPiInstitutionId( + @Bind("studyId") Integer studyId, @Bind("piInstitutionId") Integer piInstitutionId); + @SqlUpdate( """ UPDATE study @@ -140,6 +154,10 @@ Integer insertStudy( description = :description, pi_name = :piName, pi_email = :piEmail, + pi_institution_id = :piInstitutionId, + pi_orcid = :piOrcid, + pi_linkedin_url = :piLinkedinUrl, + pi_website_url = :piWebsiteUrl, data_types = :dataTypes, public_visibility = :publicVisibility, update_user_id = :updateUserId, @@ -152,6 +170,10 @@ void updateStudy( @Bind("description") String description, @Bind("piName") String piName, @Bind("piEmail") String piEmail, + @Bind("piInstitutionId") Integer piInstitutionId, + @Bind("piOrcid") String piOrcid, + @Bind("piLinkedinUrl") String piLinkedinUrl, + @Bind("piWebsiteUrl") String piWebsiteUrl, @Bind("dataTypes") List dataTypes, @Bind("publicVisibility") Boolean publicVisibility, @Bind("updateUserId") Integer updateUserId, @@ -212,7 +234,12 @@ DELETE FROM study WHERE study_id in (select study_id from property_deletes) @UseRowReducer(StudyReducer.class) @SqlQuery( """ - SELECT * FROM study WHERE name = :name + SELECT s.*, i.institution_name AS pi_institution_name, + i.create_date AS pi_institution_create_date, + i.update_date AS pi_institution_update_date + FROM study s + LEFT JOIN institution i ON i.institution_id = s.pi_institution_id + WHERE s.name = :name """) Study findStudyByName(@Bind("name") String name); diff --git a/src/main/java/org/broadinstitute/consent/http/db/StudyRecommendationDAO.java b/src/main/java/org/broadinstitute/consent/http/db/StudyRecommendationDAO.java new file mode 100644 index 0000000000..7fc3afaf02 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/db/StudyRecommendationDAO.java @@ -0,0 +1,78 @@ +package org.broadinstitute.consent.http.db; + +import java.util.List; +import org.broadinstitute.consent.http.models.StudyRecommendation; +import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.statement.SqlQuery; + +public interface StudyRecommendationDAO { + + /** + * Publicly visible studies that share the source study's PI or at least one data type, best + * matches first. A blank pi_name is not an identity, so it never matches another blank one. + */ + @RegisterConstructorMapper(StudyRecommendation.class) + @SqlQuery( + """ + WITH source AS (SELECT data_types, NULLIF(pi_name, '') AS pi_name FROM study WHERE study_id = :studyId) + SELECT s.study_id, s.name AS study_name, s.description AS study_description, s.pi_name, + COUNT(DISTINCT d.dataset_id) AS dataset_count, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT d.dataset_id), NULL) AS dataset_ids + FROM study s + CROSS JOIN source + LEFT JOIN dataset d ON d.study_id = s.study_id + WHERE s.study_id <> :studyId AND s.public_visibility = TRUE + AND (NULLIF(s.pi_name, '') = source.pi_name OR s.data_types && source.data_types) + GROUP BY s.study_id, source.data_types, source.pi_name + ORDER BY ( + SELECT COUNT(*) FROM ( + SELECT UNNEST(COALESCE(s.data_types, ARRAY[]::text[])) + INTERSECT SELECT UNNEST(COALESCE(source.data_types, ARRAY[]::text[])) + ) overlap + ) + CASE WHEN NULLIF(s.pi_name, '') = source.pi_name THEN 1 ELSE 0 END DESC, + s.study_id + LIMIT 12 + """) + List findSimilar(@Bind("studyId") Integer studyId); + + /** + * Publicly visible studies most often requested in the same data access request as the source + * study. Only submitted, non-archived, non-progress-report DARs count towards a score: a draft + * cart is not a request, an archived DAR should stop counting, and a progress report carries its + * own reference_id, so counting one would score its parent DAR more than once. + */ + @RegisterConstructorMapper(StudyRecommendation.class) + @SqlQuery( + """ + WITH source_references AS ( + SELECT DISTINCT dd.reference_id + FROM data_access_request dar + INNER JOIN dar_dataset dd ON dd.reference_id = dar.reference_id + INNER JOIN dataset d ON d.dataset_id = dd.dataset_id + WHERE d.study_id = :studyId + AND dar.submission_date IS NOT NULL + AND dar.parent_id IS NULL + AND (LOWER(dar.data->>'status') != 'archived' OR dar.data->>'status' IS NULL) + ), candidate_scores AS ( + -- A reference_id in source_references is by construction a qualifying DAR, so no + -- second pass over data_access_request is needed here. + SELECT d.study_id, COUNT(DISTINCT dd.reference_id) AS score + FROM source_references sr + INNER JOIN dar_dataset dd ON dd.reference_id = sr.reference_id + INNER JOIN dataset d ON d.dataset_id = dd.dataset_id + WHERE d.study_id <> :studyId + GROUP BY d.study_id + ) + SELECT s.study_id, s.name AS study_name, s.description AS study_description, s.pi_name, + COUNT(DISTINCT d.dataset_id) AS dataset_count, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT d.dataset_id), NULL) AS dataset_ids + FROM candidate_scores cs + INNER JOIN study s ON s.study_id = cs.study_id AND s.public_visibility = TRUE + LEFT JOIN dataset d ON d.study_id = s.study_id + GROUP BY s.study_id, cs.score + ORDER BY cs.score DESC, s.study_id + LIMIT 12 + """) + List findFrequentlyRequestedWith(@Bind("studyId") Integer studyId); +} diff --git a/src/main/java/org/broadinstitute/consent/http/db/mapper/DarMetricsSummaryMapper.java b/src/main/java/org/broadinstitute/consent/http/db/mapper/DarMetricsSummaryMapper.java index 10517b3ae2..3cd744961e 100644 --- a/src/main/java/org/broadinstitute/consent/http/db/mapper/DarMetricsSummaryMapper.java +++ b/src/main/java/org/broadinstitute/consent/http/db/mapper/DarMetricsSummaryMapper.java @@ -12,17 +12,32 @@ public class DarMetricsSummaryMapper implements RowMapper { @Override public DarMetricsSummary map(ResultSet rs, StatementContext ctx) throws SQLException { + // When access began, which is the whole collection's earliest approved submission rather than + // the latest renewal's: the section reports how long a dataset has been in use. Timestamp submissionDate = rs.getTimestamp("submission_date"); - boolean expired = - submissionDate != null - && submissionDate.getTime() + DataAccessRequest.EXPIRATION_DURATION_MILLIS - < System.currentTimeMillis(); return new DarMetricsSummary( rs.getTimestamp("update_date"), + submissionDate, rs.getString("project_title"), rs.getString("dar_code"), rs.getString("non_tech_rus"), + rs.getString("rus"), rs.getString("reference_id"), - expired); + rs.getString("institution_name"), + hasLapsed(rs.getTimestamp("expiration_basis_date"), rs.getTimestamp("closeout_date"))); + } + + /** + * Access ends either when its term runs out or when the researcher closes it out, whichever + * actually happened. A closeout revokes access the day it is filed, so it ends the grant even + * though the term it cut short may still have had time left to run. + */ + private boolean hasLapsed(Timestamp latestRenewal, Timestamp closeoutDate) { + if (closeoutDate != null) { + return closeoutDate.getTime() < System.currentTimeMillis(); + } + return latestRenewal != null + && latestRenewal.getTime() + DataAccessRequest.EXPIRATION_DURATION_MILLIS + < System.currentTimeMillis(); } } diff --git a/src/main/java/org/broadinstitute/consent/http/db/mapper/StudyReducer.java b/src/main/java/org/broadinstitute/consent/http/db/mapper/StudyReducer.java index 253438a73e..92ea0bd051 100644 --- a/src/main/java/org/broadinstitute/consent/http/db/mapper/StudyReducer.java +++ b/src/main/java/org/broadinstitute/consent/http/db/mapper/StudyReducer.java @@ -1,8 +1,10 @@ package org.broadinstitute.consent.http.db.mapper; +import java.sql.Timestamp; import java.util.Map; import java.util.Objects; import org.broadinstitute.consent.http.enumeration.PropertyType; +import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; import org.broadinstitute.consent.http.models.StudyProperty; import org.jdbi.v3.core.result.LinkedHashMapRowReducer; @@ -21,6 +23,26 @@ public void accumulate(Map map, RowView rowView) { public void reduceStudy(Study study, RowView rowView) { + if (study.getPiInstitution() == null && hasNonZeroColumn(rowView, "pi_institution_id")) { + Institution institution = new Institution(); + institution.setId(rowView.getColumn("pi_institution_id", Integer.class)); + // Not every query that selects the study's columns joins in the institution, so each of + // these reads is optional. The Institution constructor seeds createDate with "now", which + // would surface as a fabricated timestamp that changes on every read, so it is always + // overwritten here - with the stored value when the join supplied it, null when it did not. + institution.setName( + hasOptionalColumn(rowView, "pi_institution_name", String.class).orElse(null)); + // Read as Timestamp, not java.util.Date: jdbi registers no mapper for java.util.Date, so + // asking for one threw NoSuchMapperException, hasOptionalColumn swallowed it, and these + // came back null even when the query did join the institution. Timestamp extends Date, so + // the setters take it as-is. + institution.setCreateDate( + hasOptionalColumn(rowView, "pi_institution_create_date", Timestamp.class).orElse(null)); + institution.setUpdateDate( + hasOptionalColumn(rowView, "pi_institution_update_date", Timestamp.class).orElse(null)); + study.setPiInstitution(institution); + } + if (hasNonZeroColumn(rowView, "sp_study_property_id")) { Integer studyPropertyId = rowView.getColumn("sp_study_property_id", Integer.class); String keyName = rowView.getColumn("sp_key", String.class); diff --git a/src/main/java/org/broadinstitute/consent/http/models/DarMetricsSummary.java b/src/main/java/org/broadinstitute/consent/http/models/DarMetricsSummary.java index e855fb88ef..b885c65e2c 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/DarMetricsSummary.java +++ b/src/main/java/org/broadinstitute/consent/http/models/DarMetricsSummary.java @@ -4,8 +4,45 @@ public record DarMetricsSummary( Timestamp updateDate, + Timestamp submissionDate, String projectTitle, String darCode, String nonTechRus, + String rus, String referenceId, - Boolean expired) {} + String institutionName, + Boolean expired) { + + /** + * The same summary with no requester identity on it. + * + *

A record's canonical constructor is positional, so reordering two same-typed components + * still compiles and quietly moves values into the wrong slots. This does not escape that - the + * call below is positional too - it confines it: callers say what they want instead of rebuilding + * the record, so there is one such call rather than one per call site, and {@code + * MetricsServiceTest#testWithoutRequesterIdentityKeepsEverythingElse} asserts every surviving + * field, which is what would actually catch a reorder. + */ + public DarMetricsSummary withoutRequesterIdentity() { + return new DarMetricsSummary( + updateDate, + submissionDate, + projectTitle, + darCode, + nonTechRus, + rus, + referenceId, + null, + expired); + } + + public DarMetricsSummary( + Timestamp updateDate, + String projectTitle, + String darCode, + String nonTechRus, + String referenceId, + Boolean expired) { + this(updateDate, null, projectTitle, darCode, nonTechRus, null, referenceId, null, expired); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/models/Study.java b/src/main/java/org/broadinstitute/consent/http/models/Study.java index 9f56b66300..2707e3195a 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/Study.java +++ b/src/main/java/org/broadinstitute/consent/http/models/Study.java @@ -14,6 +14,10 @@ public class Study { private Boolean publicVisibility; private String piName; private String piEmail; + private Institution piInstitution; + private String piOrcid; + private String piLinkedinUrl; + private String piWebsiteUrl; private List dataTypes; private final Set datasetIds = new HashSet<>(); private Set datasets; @@ -74,6 +78,38 @@ public void setPiEmail(String piEmail) { this.piEmail = piEmail; } + public Institution getPiInstitution() { + return piInstitution; + } + + public void setPiInstitution(Institution piInstitution) { + this.piInstitution = piInstitution; + } + + public String getPiOrcid() { + return piOrcid; + } + + public void setPiOrcid(String piOrcid) { + this.piOrcid = piOrcid; + } + + public String getPiLinkedinUrl() { + return piLinkedinUrl; + } + + public void setPiLinkedinUrl(String piLinkedinUrl) { + this.piLinkedinUrl = piLinkedinUrl; + } + + public String getPiWebsiteUrl() { + return piWebsiteUrl; + } + + public void setPiWebsiteUrl(String piWebsiteUrl) { + this.piWebsiteUrl = piWebsiteUrl; + } + public List getDataTypes() { return dataTypes; } diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyAssets.java b/src/main/java/org/broadinstitute/consent/http/models/StudyAssets.java new file mode 100644 index 0000000000..d8ad3ba85d --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyAssets.java @@ -0,0 +1,222 @@ +package org.broadinstitute.consent.http.models; + +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.broadinstitute.consent.http.util.ConsentLogger; +import org.broadinstitute.consent.http.util.gson.GsonUtil; + +/** + * The study registration asset lists that backend behavior depends on. + * + *

These lists used to live as keys inside the client-managed {@code assets} object, which + * DatasetRegistrationSchemaV1.yaml declares is "preserved and returned as-is by the backend; not + * backend-validated". Once endpoints and dashboard counts began reading individual keys out of it, + * that contract no longer held, so each key below is now a first-class registration field stored in + * its own {@code study_property} row. + * + *

The {@code assets} object itself remains, deprecated, for any key that has not been promoted. + * Until clients move to the top-level fields, reads fall back to it and writes are accepted through + * it; see {@link #findAssetList} and {@link #stripPromoted}. + */ +public class StudyAssets implements ConsentLogger { + + public static final String MODELS = "models"; + public static final String WORKSPACES = "workspaces"; + public static final String PRESENTATIONS = "presentations"; + public static final String PUBLICATIONS = "publications"; + public static final String CLINICAL_TRIALS = "clinicalTrials"; + public static final String INTELLECTUAL_PROPERTIES = "intellectualProperties"; + public static final String BIOSPECIMENS = "biospecimens"; + public static final String FUNDING = "funding"; + + /** The legacy client-managed object these lists were promoted out of. */ + public static final String ASSETS = "assets"; + + public static final List PROMOTED_KEYS = + List.of( + MODELS, + WORKSPACES, + PRESENTATIONS, + PUBLICATIONS, + CLINICAL_TRIALS, + INTELLECTUAL_PROPERTIES, + BIOSPECIMENS, + FUNDING); + + private static final Type LIST_TYPE = new TypeToken>() {}.getType(); + private static final Type MAP_TYPE = new TypeToken>() {}.getType(); + + /** + * The promoted property for a key, when the study has one that parses as a list. + * + *

Distinguishing "no such property" from "a property holding an empty list" is what lets + * {@link #assemble} tell an authoritative empty list, which must clear a stale legacy copy, from + * the absence of any promoted value, which must leave the legacy copy alone. + */ + private Optional> promotedProperty( + Collection properties, String key) { + if (properties == null) { + return Optional.empty(); + } + return properties.stream() + .filter(property -> key.equalsIgnoreCase(property.getKey())) + .map(StudyProperty::getValue) + .>map(value -> parse(key, value, LIST_TYPE)) + .filter(Objects::nonNull) + .findFirst(); + } + + /** + * The assets of one type recorded for a study. Reads the promoted property, falling back to the + * legacy {@code assets} object for a study whose registration has not been rewritten since the + * promotion. An absent or malformed value reads as "no assets of that type". + * + *

A promoted property that is present and parses is authoritative even when it holds an empty + * list: a submitter who removed the last asset of a type must not have it restored from a stale + * legacy copy. + */ + public List findAssetList(Collection properties, String key) { + Optional> promoted = promotedProperty(properties, key); + if (promoted.isPresent()) { + return promoted.get(); + } + Object legacy = legacyValue(findLegacyAssets(properties), key); + return legacy instanceof Collection collection + ? collection.stream().map(Object.class::cast).toList() + : List.of(); + } + + /** + * The full assets object for a study: every promoted list, plus whatever keys remain in the + * legacy object. This is the shape clients and the search index still expect. + * + *

A legacy value under a promoted name that is not a list is left exactly as stored. The + * promotion cannot take it and the migration deliberately kept it, so dropping it here would + * delete it in effect: it would vanish from registration and search reads, and the next + * registration write would then store the assets object without it. + * + *

The one case still not representable is a study holding both a promoted property and a + * non-list legacy value under the same name - one key cannot carry both. The promoted list wins, + * since it is the shape clients expect and the newer intent, and the legacy value stays on disk + * rather than being stripped. The migration does not produce that combination; only a client + * sending both at once does. + */ + public Map assemble(Collection properties) { + Map assets = new LinkedHashMap<>(findLegacyAssets(properties)); + for (String key : PROMOTED_KEYS) { + Optional> promoted = promotedProperty(properties, key); + if (promoted.isEmpty() && !(legacyValue(assets, key) instanceof Collection)) { + continue; + } + // Remove any legacy copy first. In particular, an authoritative empty promoted property + // must remove a stale legacy list rather than accidentally resurrecting it. + assets.keySet().removeIf(key::equalsIgnoreCase); + List values = promoted.orElseGet(() -> findAssetList(properties, key)); + if (!values.isEmpty()) { + assets.put(key, values); + } + } + return assets; + } + + /** + * The legacy object's value for a key, ignoring case. + * + *

Every lookup and removal of a promoted name goes through this, because the legacy object is + * client-supplied and its casing is not guaranteed. Matching case-insensitively in one place and + * exactly in another is what loses data: a value under "Models" would not be promoted, because + * the lookup missed it, but would still be stripped from the assets object, because the removal + * matched it. + */ + private static Object legacyValue(Map assets, String key) { + return assets.entrySet().stream() + .filter(entry -> key.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + + /** + * A client-supplied assets object with the promoted keys removed, so they are not stored twice. + * Empty when nothing is left to store. + * + *

Only a value the promotion can actually take is removed. {@link #promotedValue} stores a + * legacy value under a promoted name only when it is a list, so stripping the others would delete + * them: the value would be in neither the promoted property nor the assets object. + */ + public static Map stripPromoted(Map assets) { + if (assets == null) { + return Map.of(); + } + Map remaining = new LinkedHashMap<>(assets); + PROMOTED_KEYS.forEach( + key -> + remaining + .entrySet() + .removeIf( + entry -> + key.equalsIgnoreCase(entry.getKey()) + && entry.getValue() instanceof Collection)); + return remaining; + } + + /** + * The value a promoted field should be stored with: the top-level registration field when the + * client sent one, otherwise the same key read out of the legacy assets object. + * + *

"Sent one" means present, not non-empty. Registration reads return every promoted list both + * top-level and inside the legacy {@code assets} object, so an edit that clears the top-level + * list arrives alongside the pre-edit legacy copy; treating {@code []} as "not provided" would + * resurrect what the submitter just removed. + */ + public static List promotedValue( + List topLevel, Map assets, String key) { + if (topLevel != null) { + return topLevel; + } + Object legacy = assets == null ? null : legacyValue(assets, key); + return legacy instanceof Collection collection + ? collection.stream().map(Object.class::cast).toList() + : null; + } + + private Map findLegacyAssets(Collection properties) { + if (properties == null) { + return Map.of(); + } + return properties.stream() + .filter(property -> ASSETS.equalsIgnoreCase(property.getKey())) + .map(StudyProperty::getValue) + .map(this::parseMap) + .filter(assets -> !assets.isEmpty()) + .findFirst() + .orElseGet(Map::of); + } + + private Map parseMap(Object value) { + Map parsed = parse(ASSETS, value, MAP_TYPE); + return parsed == null ? Map.of() : parsed; + } + + /** + * Study property values are JSON written by the client. A value that is malformed or of the wrong + * shape is treated as absent rather than surfacing as a server error. + */ + private T parse(String key, Object value, Type type) { + if (Objects.isNull(value)) { + return null; + } + try { + return GsonUtil.getInstance().fromJson(value.toString(), type); + } catch (Exception e) { + logWarn("Unable to parse the %s study property: %s".formatted(key, e.getMessage())); + return null; + } + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyComment.java b/src/main/java/org/broadinstitute/consent/http/models/StudyComment.java new file mode 100644 index 0000000000..7d90945571 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyComment.java @@ -0,0 +1,14 @@ +package org.broadinstitute.consent.http.models; + +import java.sql.Timestamp; + +public record StudyComment( + Integer studyCommentId, + Integer studyId, + Integer userId, + Integer rating, + String commentText, + Timestamp createDate, + Timestamp updateDate, + String displayName, + String institutionName) {} diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyCommentsSummary.java b/src/main/java/org/broadinstitute/consent/http/models/StudyCommentsSummary.java new file mode 100644 index 0000000000..0a27f9c45a --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyCommentsSummary.java @@ -0,0 +1,17 @@ +package org.broadinstitute.consent.http.models; + +import java.util.List; + +/** + * A page of a study's comments. + * + * @param comments the requested page, newest first by creation + * @param averageRating the mean rating across every comment on the study, not just this page, so it + * does not drift as a caller pages through. Null when the study has no comments. + * @param total how many comments the study has, so a caller knows whether more pages exist + * @param yourComment the requesting user's own comment, or null when they have none. Carried + * separately because paging puts it on an unpredictable page, and a client needs it to know + * whether saving adds a comment or revises one. + */ +public record StudyCommentsSummary( + List comments, Double averageRating, Integer total, StudyComment yourComment) {} diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyPatch.java b/src/main/java/org/broadinstitute/consent/http/models/StudyPatch.java index e9cc80470d..744acb15c7 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/StudyPatch.java +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyPatch.java @@ -1,10 +1,12 @@ package org.broadinstitute.consent.http.models; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.gson.reflect.TypeToken; @@ -13,7 +15,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1.StudyType; import org.broadinstitute.consent.http.util.gson.GsonUtil; @@ -27,12 +32,68 @@ public record StudyPatch( String species, String piName, String piEmail, + Integer piInstitutionId, + String piOrcid, + String piLinkedinUrl, + String piWebsiteUrl, List dataCustodianEmail, String alternativeDataSharingPlanTargetDeliveryDate, String alternativeDataSharingPlanTargetPublicReleaseDate, Boolean publicVisibility, String externalIdentifier, - String externalIdentifierType) { + String externalIdentifierType, + // Field names the client sent as an explicit JSON null. Jackson cannot tell an absent field + // from an explicit null, so fromJson records them here; see the PI column convention below. + @JsonIgnore Set explicitNulls) { + + /** + * Convenience constructor for callers that build a patch directly rather than from a request + * body. Such a patch has no wire representation, so no field was explicitly nulled. + */ + public StudyPatch( + String name, + StudyType studyType, + String description, + List dataTypes, + String phenotypeIndication, + String species, + String piName, + String piEmail, + Integer piInstitutionId, + String piOrcid, + String piLinkedinUrl, + String piWebsiteUrl, + List dataCustodianEmail, + String alternativeDataSharingPlanTargetDeliveryDate, + String alternativeDataSharingPlanTargetPublicReleaseDate, + Boolean publicVisibility, + String externalIdentifier, + String externalIdentifierType) { + this( + name, + studyType, + description, + dataTypes, + phenotypeIndication, + species, + piName, + piEmail, + piInstitutionId, + piOrcid, + piLinkedinUrl, + piWebsiteUrl, + dataCustodianEmail, + alternativeDataSharingPlanTargetDeliveryDate, + alternativeDataSharingPlanTargetPublicReleaseDate, + publicVisibility, + externalIdentifier, + externalIdentifierType, + Set.of()); + } + + public StudyPatch { + explicitNulls = explicitNulls == null ? Set.of() : Set.copyOf(explicitNulls); + } public static final String STUDY_TYPE = "studyType"; public static final String PHENOTYPE_INDICATION = "phenotypeIndication"; @@ -46,6 +107,22 @@ public record StudyPatch( public static final String EXTERNAL_IDENTIFIER = "externalIdentifier"; public static final String EXTERNAL_IDENTIFIER_TYPE = "externalIdentifierType"; + /** + * The PI detail columns. Unlike the patchable study properties — where a blank string signals a + * delete — these are columns on the study row, so they follow the JSON convention: an absent + * field is a no-op, an explicit {@code null} clears the column, and a value sets it. A blank + * string is normalized to a clear rather than stored, since an empty ORCID or URL is never + * meaningful. + */ + public static final String PI_INSTITUTION_ID = "piInstitutionId"; + + /** The legacy study property that predates the pi_institution_id column. */ + private static final String PI_INSTITUTION_PROPERTY = "piInstitution"; + + public static final String PI_ORCID = "piOrcid"; + public static final String PI_LINKEDIN_URL = "piLinkedinUrl"; + public static final String PI_WEBSITE_URL = "piWebsiteUrl"; + public static StudyPatch fromJson(String json) { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true); @@ -53,14 +130,55 @@ public static StudyPatch fromJson(String json) { SimpleModule module = new SimpleModule(); module.addDeserializer(String.class, new ForceStringDeserializer()); module.addDeserializer(Boolean.class, new ForceBooleanDeserializer()); + module.addDeserializer(Integer.class, new ForceIntegerDeserializer()); mapper.registerModule(module); try { - return mapper.readValue(json, StudyPatch.class); + StudyPatch patch = mapper.readValue(json, StudyPatch.class); + return patch.withExplicitNulls(explicitNullFields(mapper, json)); } catch (Exception e) { throw new IllegalArgumentException(e.getMessage()); } } + /** + * The names of the fields the body set to a literal JSON null. Binding alone cannot report these, + * because Jackson leaves both an absent field and an explicit null as a null component. + */ + private static Set explicitNullFields(ObjectMapper mapper, String json) + throws IOException { + JsonNode root = mapper.readTree(json); + if (root == null || !root.isObject()) { + return Set.of(); + } + return root.properties().stream() + .filter(entry -> entry.getValue().isNull()) + .map(Map.Entry::getKey) + .collect(Collectors.toUnmodifiableSet()); + } + + private StudyPatch withExplicitNulls(Set fields) { + return new StudyPatch( + name, + studyType, + description, + dataTypes, + phenotypeIndication, + species, + piName, + piEmail, + piInstitutionId, + piOrcid, + piLinkedinUrl, + piWebsiteUrl, + dataCustodianEmail, + alternativeDataSharingPlanTargetDeliveryDate, + alternativeDataSharingPlanTargetPublicReleaseDate, + publicVisibility, + externalIdentifier, + externalIdentifierType, + fields); + } + // Jackson, by default, allows coercion from numbers to strings. // This custom deserializer forbids that behavior for all String fields. private static class ForceStringDeserializer extends JsonDeserializer { @@ -95,6 +213,24 @@ public Boolean deserialize(JsonParser jsonParser, DeserializationContext deseria } } + // Jackson, by default, allows coercion from strings to numbers. + // This custom deserializer forbids that behavior for all Integer fields, so piInstitutionId is + // as strict about "123" as the String and Boolean fields beside it are about their own coercions. + private static class ForceIntegerDeserializer extends JsonDeserializer { + @Override + public Integer deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + if (jsonParser.getCurrentToken() != JsonToken.VALUE_NUMBER_INT) { + throw deserializationContext.wrongTokenException( + jsonParser, + Integer.class, + JsonToken.VALUE_NUMBER_INT, + "Attempted to parse a non-integer to an integer but this is not allowed"); + } + return jsonParser.getIntValue(); + } + } + // Utility method to determine if any patch values differ from the provided Study entity public boolean isPatchable(Study study) { List checks = new ArrayList<>(); @@ -106,6 +242,10 @@ public boolean isPatchable(Study study) { checks.add(checkSpecies(study)); checks.add(checkPiName(study)); checks.add(checkPiEmail(study)); + checks.add(checkPiInstitutionId(study)); + checks.add(checkNullableColumn(PI_ORCID, piOrcid(), study.getPiOrcid())); + checks.add(checkNullableColumn(PI_LINKEDIN_URL, piLinkedinUrl(), study.getPiLinkedinUrl())); + checks.add(checkNullableColumn(PI_WEBSITE_URL, piWebsiteUrl(), study.getPiWebsiteUrl())); checks.add(checkDataCustodians(study)); checks.add(checkTargetDate(study)); checks.add(checkTargetReleaseDate(study)); @@ -157,6 +297,81 @@ private boolean checkPiEmail(Study study) { return piEmail() != null && !piEmail().equals(study.getPiEmail()) && !piEmail().isBlank(); } + private boolean checkPiInstitutionId(Study study) { + Integer existing = study.getPiInstitution() == null ? null : study.getPiInstitution().getId(); + return !Objects.equals(resolvePiInstitutionId(existing), existing); + } + + private boolean checkNullableColumn(String field, String patchValue, String existing) { + return !Objects.equals(resolveColumn(field, patchValue, existing), existing); + } + + /** + * Whether this patch touches the PI institution at all, either setting it or clearing it. + * + *

Distinct from {@link #resolvePiInstitutionId(Integer)} resolving to the stored value: a + * patch that sets the institution to what it already was still touched it, and the caller needs + * to know so it can retire the legacy study property beside the column. + */ + public boolean patchesPiInstitutionId() { + return piInstitutionId() != null || explicitNulls().contains(PI_INSTITUTION_ID); + } + + /** + * Whether this patch has legacy cleanup to do even though it changes no effective value. + * + *

study.pi_institution_id is authoritative, but a study registered before that column existed + * also carries the institution as the legacy {@code piInstitution} property, and {@code + * SchemaFromStudy} falls back to the property whenever the column is null. The column can become + * null without any patch: its foreign key is ON DELETE SET NULL, so deleting an institution + * clears it and leaves the property behind to resurface in the next registration payload. + * + *

Clearing that property is exactly a patch of {@code {"piInstitutionId": null}}, which + * changes no effective value and so is not {@link #isPatchable(Study) patchable}. Reported + * separately rather than folded into that answer, which means "does this change a stored value" + * and is relied on to answer 304. + */ + public boolean retiresLegacyPiInstitution(Study study) { + return patchesPiInstitutionId() + && study.getProperties() != null + && study.getProperties().stream() + .anyMatch(property -> PI_INSTITUTION_PROPERTY.equals(property.getKey())); + } + + /** + * Resolves the PI institution id against its stored value: absent=keep existing, explicit + * null=clear, a value=set. + */ + public Integer resolvePiInstitutionId(Integer existing) { + if (piInstitutionId() != null) { + return piInstitutionId(); + } + return explicitNulls().contains(PI_INSTITUTION_ID) ? null : existing; + } + + public String resolvePiOrcid(String existing) { + return resolveColumn(PI_ORCID, piOrcid(), existing); + } + + public String resolvePiLinkedinUrl(String existing) { + return resolveColumn(PI_LINKEDIN_URL, piLinkedinUrl(), existing); + } + + public String resolvePiWebsiteUrl(String existing) { + return resolveColumn(PI_WEBSITE_URL, piWebsiteUrl(), existing); + } + + /** + * Resolves a nullable string column against its stored value: absent=keep existing, explicit + * null=clear, blank=clear (an empty value is never stored), any other value=set. + */ + private String resolveColumn(String field, String patchValue, String existing) { + if (patchValue == null) { + return explicitNulls().contains(field) ? null : existing; + } + return patchValue.isBlank() ? null : patchValue; + } + private boolean checkDataCustodians(Study study) { Optional custodiansProp = study.getProperties().stream() diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyRecommendation.java b/src/main/java/org/broadinstitute/consent/http/models/StudyRecommendation.java new file mode 100644 index 0000000000..c5810b8440 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyRecommendation.java @@ -0,0 +1,11 @@ +package org.broadinstitute.consent.http.models; + +import java.util.List; + +public record StudyRecommendation( + Integer studyId, + String studyName, + String studyDescription, + String piName, + Long datasetCount, + List datasetIds) {} diff --git a/src/main/java/org/broadinstitute/consent/http/models/StudyResearchOutputs.java b/src/main/java/org/broadinstitute/consent/http/models/StudyResearchOutputs.java new file mode 100644 index 0000000000..ec2ea57778 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/models/StudyResearchOutputs.java @@ -0,0 +1,8 @@ +package org.broadinstitute.consent.http.models; + +import java.util.List; + +public record StudyResearchOutputs( + List presentations, + List publications, + List intellectualProperties) {} diff --git a/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/DatasetRegistrationSchemaV1.java b/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/DatasetRegistrationSchemaV1.java index 0dc312389a..a9285190b0 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/DatasetRegistrationSchemaV1.java +++ b/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/DatasetRegistrationSchemaV1.java @@ -58,6 +58,14 @@ "alternativeDataSharingPlanTargetPublicReleaseDate", "alternativeDataSharingPlanAccessManagement", "consentGroups", + "models", + "workspaces", + "presentations", + "publications", + "clinicalTrials", + "intellectualProperties", + "biospecimens", + "funding", "assets", "data", "externalIdentifier", @@ -287,6 +295,46 @@ public class DatasetRegistrationSchemaV1 { @JsonPropertyDescription("Consent Groups") private List consentGroups = new ArrayList(); + /** Models produced by or associated with this study */ + @JsonProperty("models") + @JsonPropertyDescription("Models produced by or associated with this study") + private List models = new ArrayList<>(); + + /** Analysis workspaces associated with this study */ + @JsonProperty("workspaces") + @JsonPropertyDescription("Analysis workspaces associated with this study") + private List workspaces = new ArrayList<>(); + + /** Presentations produced by or associated with this study */ + @JsonProperty("presentations") + @JsonPropertyDescription("Presentations produced by or associated with this study") + private List presentations = new ArrayList<>(); + + /** Publications produced by or associated with this study */ + @JsonProperty("publications") + @JsonPropertyDescription("Publications produced by or associated with this study") + private List publications = new ArrayList<>(); + + /** Clinical trials associated with this study */ + @JsonProperty("clinicalTrials") + @JsonPropertyDescription("Clinical trials associated with this study") + private List clinicalTrials = new ArrayList<>(); + + /** Intellectual property produced by or associated with this study */ + @JsonProperty("intellectualProperties") + @JsonPropertyDescription("Intellectual property produced by or associated with this study") + private List intellectualProperties = new ArrayList<>(); + + /** Biospecimens associated with this study */ + @JsonProperty("biospecimens") + @JsonPropertyDescription("Biospecimens associated with this study") + private List biospecimens = new ArrayList<>(); + + /** Funding sources for this study */ + @JsonProperty("funding") + @JsonPropertyDescription("Funding sources for this study") + private List funding = new ArrayList<>(); + /** Additional assets or metadata associated with this study registration */ @JsonProperty("assets") @JsonPropertyDescription("Additional assets or metadata associated with this study registration") @@ -825,6 +873,102 @@ public void setConsentGroups(List consentGroups) { this.consentGroups = consentGroups; } + /** Models produced by or associated with this study */ + @JsonProperty("models") + public List getModels() { + return models; + } + + /** Models produced by or associated with this study */ + @JsonProperty("models") + public void setModels(List models) { + this.models = models; + } + + /** Analysis workspaces associated with this study */ + @JsonProperty("workspaces") + public List getWorkspaces() { + return workspaces; + } + + /** Analysis workspaces associated with this study */ + @JsonProperty("workspaces") + public void setWorkspaces(List workspaces) { + this.workspaces = workspaces; + } + + /** Presentations produced by or associated with this study */ + @JsonProperty("presentations") + public List getPresentations() { + return presentations; + } + + /** Presentations produced by or associated with this study */ + @JsonProperty("presentations") + public void setPresentations(List presentations) { + this.presentations = presentations; + } + + /** Publications produced by or associated with this study */ + @JsonProperty("publications") + public List getPublications() { + return publications; + } + + /** Publications produced by or associated with this study */ + @JsonProperty("publications") + public void setPublications(List publications) { + this.publications = publications; + } + + /** Clinical trials associated with this study */ + @JsonProperty("clinicalTrials") + public List getClinicalTrials() { + return clinicalTrials; + } + + /** Clinical trials associated with this study */ + @JsonProperty("clinicalTrials") + public void setClinicalTrials(List clinicalTrials) { + this.clinicalTrials = clinicalTrials; + } + + /** Intellectual property produced by or associated with this study */ + @JsonProperty("intellectualProperties") + public List getIntellectualProperties() { + return intellectualProperties; + } + + /** Intellectual property produced by or associated with this study */ + @JsonProperty("intellectualProperties") + public void setIntellectualProperties(List intellectualProperties) { + this.intellectualProperties = intellectualProperties; + } + + /** Biospecimens associated with this study */ + @JsonProperty("biospecimens") + public List getBiospecimens() { + return biospecimens; + } + + /** Biospecimens associated with this study */ + @JsonProperty("biospecimens") + public void setBiospecimens(List biospecimens) { + this.biospecimens = biospecimens; + } + + /** Funding sources for this study */ + @JsonProperty("funding") + public List getFunding() { + return funding; + } + + /** Funding sources for this study */ + @JsonProperty("funding") + public void setFunding(List funding) { + this.funding = funding; + } + /** Additional assets or metadata associated with this study registration */ @JsonProperty("assets") public Map getAssets() { diff --git a/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/builder/SchemaFromStudy.java b/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/builder/SchemaFromStudy.java index 4bec82c66a..036f8340b8 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/builder/SchemaFromStudy.java +++ b/src/main/java/org/broadinstitute/consent/http/models/dataset_registration_v1/builder/SchemaFromStudy.java @@ -8,7 +8,6 @@ import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.alternativeDataSharingPlanReasons; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.alternativeDataSharingPlanTargetDeliveryDate; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.alternativeDataSharingPlanTargetPublicReleaseDate; -import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.assets; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.collaboratingSites; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.controlledAccessRequiredForGenomicSummaryResultsGSR; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.controlledAccessRequiredForGenomicSummaryResultsGSRRequiredExplanation; @@ -41,6 +40,7 @@ import java.util.Set; import javax.annotation.Nullable; import org.broadinstitute.consent.http.models.Study; +import org.broadinstitute.consent.http.models.StudyAssets; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.dataset_registration_v1.AlternativeDataSharingPlanReason; import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1; @@ -53,99 +53,127 @@ public class SchemaFromStudy { + private final StudyAssets studyAssets = new StudyAssets(); + public DatasetRegistrationSchemaV1 build(Study study) { DatasetRegistrationSchemaV1 schemaV1 = new DatasetRegistrationSchemaV1(); - if (Objects.nonNull(study)) { - schemaV1.setStudyId(study.getStudyId()); - schemaV1.setStudyName(study.getName()); - String studyTypeVal = findStringPropValue(study.getProperties(), studyType); - if (Objects.nonNull(studyTypeVal)) { - schemaV1.setStudyType(DatasetRegistrationSchemaV1.StudyType.fromValue(studyTypeVal)); - } - schemaV1.setStudyDescription(study.getDescription()); - schemaV1.setDataTypes(study.getDataTypes()); - schemaV1.setPhenotypeIndication( - findStringPropValue(study.getProperties(), phenotypeIndication)); - schemaV1.setSpecies(findStringPropValue(study.getProperties(), species)); - schemaV1.setPiName(study.getPiName()); - schemaV1.setPiEmail(study.getPiEmail()); - schemaV1.setDataSubmitterUserId(study.getCreateUserId()); - schemaV1.setDataCustodianEmail( - findListStringPropValue(study.getProperties(), dataCustodianEmail)); - schemaV1.setPublicVisibility(study.getPublicVisibility()); - schemaV1.setThroughBioId(findStringPropValue(study.getProperties(), throughBioId)); - String nihAnvilUseVal = findStringPropValue(study.getProperties(), nihAnvilUse); - if (Objects.nonNull(nihAnvilUseVal)) { - schemaV1.setNihAnvilUse(NihAnvilUse.fromValue(nihAnvilUseVal)); - } - schemaV1.setSubmittingToAnvil(findBooleanPropValue(study.getProperties(), submittingToAnvil)); - schemaV1.setDbGaPPhsID(findStringPropValue(study.getProperties(), dbGaPPhsID)); - schemaV1.setDbGaPStudyRegistrationName( - findStringPropValue(study.getProperties(), dbGaPStudyRegistrationName)); - schemaV1.setEmbargoReleaseDate( - findStringPropValue(study.getProperties(), embargoReleaseDate)); - schemaV1.setSequencingCenter(findStringPropValue(study.getProperties(), sequencingCenter)); - schemaV1.setPiInstitution(findIntegerPropValue(study.getProperties(), piInstitution)); - schemaV1.setNihGrantContractNumber( - findStringPropValue(study.getProperties(), nihGrantContractNumber)); - schemaV1.setNihICsSupportingStudy(findListNICSSPropValue(study.getProperties())); - schemaV1.setNihProgramOfficerName( - findStringPropValue(study.getProperties(), nihProgramOfficerName)); - String nihInstitutionCenterSubmissionVal = - findStringPropValue(study.getProperties(), nihInstitutionCenterSubmission); - if (Objects.nonNull(nihInstitutionCenterSubmissionVal)) { - schemaV1.setNihInstitutionCenterSubmission( - NihInstitutionCenterSubmission.fromValue(nihInstitutionCenterSubmissionVal)); - } - schemaV1.setNihGenomicProgramAdministratorName( - findStringPropValue(study.getProperties(), nihGenomicProgramAdministratorName)); - schemaV1.setMultiCenterStudy(findBooleanPropValue(study.getProperties(), multiCenterStudy)); - schemaV1.setCollaboratingSites( - findListStringPropValue(study.getProperties(), collaboratingSites)); - schemaV1.setControlledAccessRequiredForGenomicSummaryResultsGSR( - findBooleanPropValue( - study.getProperties(), controlledAccessRequiredForGenomicSummaryResultsGSR)); - schemaV1.setControlledAccessRequiredForGenomicSummaryResultsGSRRequiredExplanation( - findStringPropValue( - study.getProperties(), - controlledAccessRequiredForGenomicSummaryResultsGSRRequiredExplanation)); - if (Objects.nonNull(study.getAlternativeDataSharingPlan())) { - schemaV1.setAlternativeDataSharingPlan(Boolean.TRUE); - } - schemaV1.setAlternativeDataSharingPlanReasons(findListADSPRPropValue(study.getProperties())); - schemaV1.setAlternativeDataSharingPlanExplanation( - findStringPropValue(study.getProperties(), alternativeDataSharingPlanExplanation)); - schemaV1.setAlternativeDataSharingPlanFileName( - findStringPropValue(study.getProperties(), alternativeDataSharingPlanFileName)); - String alternativeDataSharingPlanDataSubmittedVal = - findStringPropValue(study.getProperties(), alternativeDataSharingPlanDataSubmitted); - if (Objects.nonNull(alternativeDataSharingPlanDataSubmittedVal)) { - schemaV1.setAlternativeDataSharingPlanDataSubmitted( - AlternativeDataSharingPlanDataSubmitted.fromValue( - alternativeDataSharingPlanDataSubmittedVal)); - } - schemaV1.setAlternativeDataSharingPlanDataReleased( - findBooleanPropValue(study.getProperties(), alternativeDataSharingPlanDataReleased)); - schemaV1.setAlternativeDataSharingPlanTargetDeliveryDate( - findStringPropValue(study.getProperties(), alternativeDataSharingPlanTargetDeliveryDate)); - schemaV1.setAlternativeDataSharingPlanTargetPublicReleaseDate( - findStringPropValue( - study.getProperties(), alternativeDataSharingPlanTargetPublicReleaseDate)); - String alternativeDataSharingPlanAccessManagementVal = - findStringPropValue(study.getProperties(), alternativeDataSharingPlanAccessManagement); - if (Objects.nonNull(alternativeDataSharingPlanAccessManagementVal)) { - schemaV1.setAlternativeDataSharingPlanAccessManagement( - AlternativeDataSharingPlanAccessManagement.fromValue( - alternativeDataSharingPlanAccessManagementVal)); - } - schemaV1.setAssets(findMapPropValue(study.getProperties(), assets)); - schemaV1.setData(findMapPropValue(study.getProperties(), data)); - schemaV1.setExternalIdentifier( - findStringPropValue(study.getProperties(), externalIdentifier)); - schemaV1.setExternalIdentifierType( - findStringPropValue(study.getProperties(), externalIdentifierType)); + // Guard first rather than wrapping the whole build: holding every assignment one level + // deeper is what put this method over the cognitive-complexity limit, and the six enum + // conversions below each cost double at that depth. + if (Objects.isNull(study)) { + return schemaV1; + } + + schemaV1.setStudyId(study.getStudyId()); + schemaV1.setStudyName(study.getName()); + String studyTypeVal = findStringPropValue(study.getProperties(), studyType); + if (Objects.nonNull(studyTypeVal)) { + schemaV1.setStudyType(DatasetRegistrationSchemaV1.StudyType.fromValue(studyTypeVal)); + } + schemaV1.setStudyDescription(study.getDescription()); + schemaV1.setDataTypes(study.getDataTypes()); + schemaV1.setPhenotypeIndication( + findStringPropValue(study.getProperties(), phenotypeIndication)); + schemaV1.setSpecies(findStringPropValue(study.getProperties(), species)); + schemaV1.setPiName(study.getPiName()); + schemaV1.setPiEmail(study.getPiEmail()); + schemaV1.setDataSubmitterUserId(study.getCreateUserId()); + schemaV1.setDataCustodianEmail( + findListStringPropValue(study.getProperties(), dataCustodianEmail)); + schemaV1.setPublicVisibility(study.getPublicVisibility()); + schemaV1.setThroughBioId(findStringPropValue(study.getProperties(), throughBioId)); + String nihAnvilUseVal = findStringPropValue(study.getProperties(), nihAnvilUse); + if (Objects.nonNull(nihAnvilUseVal)) { + schemaV1.setNihAnvilUse(NihAnvilUse.fromValue(nihAnvilUseVal)); + } + schemaV1.setSubmittingToAnvil(findBooleanPropValue(study.getProperties(), submittingToAnvil)); + schemaV1.setDbGaPPhsID(findStringPropValue(study.getProperties(), dbGaPPhsID)); + schemaV1.setDbGaPStudyRegistrationName( + findStringPropValue(study.getProperties(), dbGaPStudyRegistrationName)); + schemaV1.setEmbargoReleaseDate(findStringPropValue(study.getProperties(), embargoReleaseDate)); + schemaV1.setSequencingCenter(findStringPropValue(study.getProperties(), sequencingCenter)); + // The study.pi_institution_id column is authoritative: it is what PATCH writes and what the + // study page reads. The legacy piInstitution study property is only consulted for a study + // whose column is still null - the backfill leaves it null when the recorded id matched no + // institution row - so that such a study keeps reporting what it reported before. + schemaV1.setPiInstitution( + study.getPiInstitution() != null && study.getPiInstitution().getId() != null + ? study.getPiInstitution().getId() + : findIntegerPropValue(study.getProperties(), piInstitution)); + schemaV1.setNihGrantContractNumber( + findStringPropValue(study.getProperties(), nihGrantContractNumber)); + schemaV1.setNihICsSupportingStudy(findListNICSSPropValue(study.getProperties())); + schemaV1.setNihProgramOfficerName( + findStringPropValue(study.getProperties(), nihProgramOfficerName)); + String nihInstitutionCenterSubmissionVal = + findStringPropValue(study.getProperties(), nihInstitutionCenterSubmission); + if (Objects.nonNull(nihInstitutionCenterSubmissionVal)) { + schemaV1.setNihInstitutionCenterSubmission( + NihInstitutionCenterSubmission.fromValue(nihInstitutionCenterSubmissionVal)); + } + schemaV1.setNihGenomicProgramAdministratorName( + findStringPropValue(study.getProperties(), nihGenomicProgramAdministratorName)); + schemaV1.setMultiCenterStudy(findBooleanPropValue(study.getProperties(), multiCenterStudy)); + schemaV1.setCollaboratingSites( + findListStringPropValue(study.getProperties(), collaboratingSites)); + schemaV1.setControlledAccessRequiredForGenomicSummaryResultsGSR( + findBooleanPropValue( + study.getProperties(), controlledAccessRequiredForGenomicSummaryResultsGSR)); + schemaV1.setControlledAccessRequiredForGenomicSummaryResultsGSRRequiredExplanation( + findStringPropValue( + study.getProperties(), + controlledAccessRequiredForGenomicSummaryResultsGSRRequiredExplanation)); + if (Objects.nonNull(study.getAlternativeDataSharingPlan())) { + schemaV1.setAlternativeDataSharingPlan(Boolean.TRUE); + } + schemaV1.setAlternativeDataSharingPlanReasons(findListADSPRPropValue(study.getProperties())); + schemaV1.setAlternativeDataSharingPlanExplanation( + findStringPropValue(study.getProperties(), alternativeDataSharingPlanExplanation)); + schemaV1.setAlternativeDataSharingPlanFileName( + findStringPropValue(study.getProperties(), alternativeDataSharingPlanFileName)); + String alternativeDataSharingPlanDataSubmittedVal = + findStringPropValue(study.getProperties(), alternativeDataSharingPlanDataSubmitted); + if (Objects.nonNull(alternativeDataSharingPlanDataSubmittedVal)) { + schemaV1.setAlternativeDataSharingPlanDataSubmitted( + AlternativeDataSharingPlanDataSubmitted.fromValue( + alternativeDataSharingPlanDataSubmittedVal)); + } + schemaV1.setAlternativeDataSharingPlanDataReleased( + findBooleanPropValue(study.getProperties(), alternativeDataSharingPlanDataReleased)); + schemaV1.setAlternativeDataSharingPlanTargetDeliveryDate( + findStringPropValue(study.getProperties(), alternativeDataSharingPlanTargetDeliveryDate)); + schemaV1.setAlternativeDataSharingPlanTargetPublicReleaseDate( + findStringPropValue( + study.getProperties(), alternativeDataSharingPlanTargetPublicReleaseDate)); + String alternativeDataSharingPlanAccessManagementVal = + findStringPropValue(study.getProperties(), alternativeDataSharingPlanAccessManagement); + if (Objects.nonNull(alternativeDataSharingPlanAccessManagementVal)) { + schemaV1.setAlternativeDataSharingPlanAccessManagement( + AlternativeDataSharingPlanAccessManagement.fromValue( + alternativeDataSharingPlanAccessManagementVal)); } + schemaV1.setModels(studyAssets.findAssetList(study.getProperties(), StudyAssets.MODELS)); + schemaV1.setWorkspaces( + studyAssets.findAssetList(study.getProperties(), StudyAssets.WORKSPACES)); + schemaV1.setPresentations( + studyAssets.findAssetList(study.getProperties(), StudyAssets.PRESENTATIONS)); + schemaV1.setPublications( + studyAssets.findAssetList(study.getProperties(), StudyAssets.PUBLICATIONS)); + schemaV1.setClinicalTrials( + studyAssets.findAssetList(study.getProperties(), StudyAssets.CLINICAL_TRIALS)); + schemaV1.setIntellectualProperties( + studyAssets.findAssetList(study.getProperties(), StudyAssets.INTELLECTUAL_PROPERTIES)); + schemaV1.setBiospecimens( + studyAssets.findAssetList(study.getProperties(), StudyAssets.BIOSPECIMENS)); + schemaV1.setFunding(studyAssets.findAssetList(study.getProperties(), StudyAssets.FUNDING)); + // The deprecated assets object still round-trips every promoted list alongside whatever + // unpromoted keys remain, so a client editing this payload does not lose them. + schemaV1.setAssets(studyAssets.assemble(study.getProperties())); + schemaV1.setData(findMapPropValue(study.getProperties(), data)); + schemaV1.setExternalIdentifier(findStringPropValue(study.getProperties(), externalIdentifier)); + schemaV1.setExternalIdentifierType( + findStringPropValue(study.getProperties(), externalIdentifierType)); return schemaV1; } diff --git a/src/main/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapper.java b/src/main/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapper.java index 692f1ee449..8ef0bb2c0a 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapper.java +++ b/src/main/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapper.java @@ -3,12 +3,14 @@ import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.data; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Function; import org.broadinstitute.consent.http.enumeration.PropertyType; import org.broadinstitute.consent.http.models.DataUse; import org.broadinstitute.consent.http.models.DatasetProperty; +import org.broadinstitute.consent.http.models.StudyAssets; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.dataset_registration_v1.AlternativeDataSharingPlanReason; import org.broadinstitute.consent.http.models.dataset_registration_v1.NihICsSupportingStudy; @@ -100,6 +102,18 @@ Optional extract(StudyRegistrationRequest request) { } } + private static StudyPropertyExtractor promotedAsset( + String key, Function> getTopLevelValue) { + return new StudyPropertyExtractor( + key, + PropertyType.Json, + request -> { + List values = + StudyAssets.promotedValue(getTopLevelValue.apply(request), request.getAssets(), key); + return values == null ? null : GsonUtil.getInstance().toJson(values); + }); + } + /** * Extracts an individual field as a dataset property. * @@ -306,14 +320,24 @@ Optional extract(ConsentGroupRequest group, ConsentGroupContext } return null; }), - new StudyPropertyExtractor( - "assets", + promotedAsset(StudyAssets.MODELS, StudyRegistrationRequest::getModels), + promotedAsset(StudyAssets.WORKSPACES, StudyRegistrationRequest::getWorkspaces), + promotedAsset(StudyAssets.PRESENTATIONS, StudyRegistrationRequest::getPresentations), + promotedAsset(StudyAssets.PUBLICATIONS, StudyRegistrationRequest::getPublications), + promotedAsset(StudyAssets.CLINICAL_TRIALS, StudyRegistrationRequest::getClinicalTrials), + promotedAsset( + StudyAssets.INTELLECTUAL_PROPERTIES, + StudyRegistrationRequest::getIntellectualProperties), + promotedAsset(StudyAssets.BIOSPECIMENS, StudyRegistrationRequest::getBiospecimens), + promotedAsset(StudyAssets.FUNDING, StudyRegistrationRequest::getFunding), + // Whatever the client sends under the deprecated assets object that has not been + // promoted to a first-class field is still round-tripped as-is. + new StudyPropertyExtractor( + StudyAssets.ASSETS, PropertyType.Json, request -> { - if (Objects.nonNull(request.getAssets()) && !request.getAssets().isEmpty()) { - return GsonUtil.getInstance().toJson(request.getAssets()); - } - return null; + Map remaining = StudyAssets.stripPromoted(request.getAssets()); + return remaining.isEmpty() ? null : GsonUtil.getInstance().toJson(remaining); }), new StudyPropertyExtractor( data, diff --git a/src/main/java/org/broadinstitute/consent/http/models/dto/registration/StudyRegistrationRequest.java b/src/main/java/org/broadinstitute/consent/http/models/dto/registration/StudyRegistrationRequest.java index 508bd2678b..604f9bd634 100644 --- a/src/main/java/org/broadinstitute/consent/http/models/dto/registration/StudyRegistrationRequest.java +++ b/src/main/java/org/broadinstitute/consent/http/models/dto/registration/StudyRegistrationRequest.java @@ -50,6 +50,14 @@ "alternativeDataSharingPlanTargetPublicReleaseDate", "alternativeDataSharingPlanAccessManagement", "consentGroups", + "models", + "workspaces", + "presentations", + "publications", + "clinicalTrials", + "intellectualProperties", + "biospecimens", + "funding", "assets", "data", "externalIdentifier", @@ -170,6 +178,30 @@ public class StudyRegistrationRequest { @JsonProperty("consentGroups") private List consentGroups; + @JsonProperty("models") + private List models; + + @JsonProperty("workspaces") + private List workspaces; + + @JsonProperty("presentations") + private List presentations; + + @JsonProperty("publications") + private List publications; + + @JsonProperty("clinicalTrials") + private List clinicalTrials; + + @JsonProperty("intellectualProperties") + private List intellectualProperties; + + @JsonProperty("biospecimens") + private List biospecimens; + + @JsonProperty("funding") + private List funding; + @JsonProperty("assets") private Map assets; @@ -497,6 +529,70 @@ public void setConsentGroups(List consentGroups) { this.consentGroups = consentGroups; } + public List getModels() { + return models; + } + + public void setModels(List models) { + this.models = models; + } + + public List getWorkspaces() { + return workspaces; + } + + public void setWorkspaces(List workspaces) { + this.workspaces = workspaces; + } + + public List getPresentations() { + return presentations; + } + + public void setPresentations(List presentations) { + this.presentations = presentations; + } + + public List getPublications() { + return publications; + } + + public void setPublications(List publications) { + this.publications = publications; + } + + public List getClinicalTrials() { + return clinicalTrials; + } + + public void setClinicalTrials(List clinicalTrials) { + this.clinicalTrials = clinicalTrials; + } + + public List getIntellectualProperties() { + return intellectualProperties; + } + + public void setIntellectualProperties(List intellectualProperties) { + this.intellectualProperties = intellectualProperties; + } + + public List getBiospecimens() { + return biospecimens; + } + + public void setBiospecimens(List biospecimens) { + this.biospecimens = biospecimens; + } + + public List getFunding() { + return funding; + } + + public void setFunding(List funding) { + this.funding = funding; + } + public Map getAssets() { return assets; } diff --git a/src/main/java/org/broadinstitute/consent/http/resources/MetricsResource.java b/src/main/java/org/broadinstitute/consent/http/resources/MetricsResource.java index 32f54d45a8..e2ee983b0e 100644 --- a/src/main/java/org/broadinstitute/consent/http/resources/MetricsResource.java +++ b/src/main/java/org/broadinstitute/consent/http/resources/MetricsResource.java @@ -11,6 +11,7 @@ import java.util.List; import org.broadinstitute.consent.http.models.DarMetricsSummary; import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.models.StudyResearchOutputs; import org.broadinstitute.consent.http.service.MetricsService; @Path("api/metrics") @@ -23,7 +24,6 @@ public MetricsResource(MetricsService metricsService) { this.metricsService = metricsService; } - @SuppressWarnings("unused") @GET @Path("/dar-summaries/{datasetId}") @Produces("application/json") @@ -31,10 +31,65 @@ public MetricsResource(MetricsService metricsService) { public Response getDarSummaryData( @Auth DuosUser user, @PathParam("datasetId") Integer datasetId) { try { - List summaries = metricsService.generateDarSummaries(datasetId); + List summaries = + metricsService.generateDarSummaries(datasetId, user.getUser()); return Response.ok().entity(summaries).build(); } catch (Exception e) { return createExceptionResponse(e); } } + + @GET + @Path("/dar-summaries/study/{studyId}") + @Produces("application/json") + @PermitAll + public Response getStudyDarSummaryData( + @Auth DuosUser user, @PathParam("studyId") Integer studyId) { + try { + return Response.ok(metricsService.generateStudyDarSummaries(studyId, user.getUser())).build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } + + @GET + @Path("/research-outputs/study/{studyId}") + @Produces("application/json") + @PermitAll + public Response getStudyResearchOutputs( + @Auth DuosUser user, @PathParam("studyId") Integer studyId) { + try { + StudyResearchOutputs outputs = + metricsService.generateStudyResearchOutputs(studyId, user.getUser()); + return Response.ok(outputs).build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } + + @GET + @Path("/study-recommendations/{studyId}/similar") + @Produces("application/json") + @PermitAll + public Response getSimilarStudies(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + try { + return Response.ok(metricsService.getSimilarStudies(studyId, user.getUser())).build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } + + @GET + @Path("/study-recommendations/{studyId}/frequently-requested-with") + @Produces("application/json") + @PermitAll + public Response getFrequentlyRequestedWith( + @Auth DuosUser user, @PathParam("studyId") Integer studyId) { + try { + return Response.ok(metricsService.getFrequentlyRequestedWith(studyId, user.getUser())) + .build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } } diff --git a/src/main/java/org/broadinstitute/consent/http/resources/Resource.java b/src/main/java/org/broadinstitute/consent/http/resources/Resource.java index 51efcf35eb..d7f9d359a2 100644 --- a/src/main/java/org/broadinstitute/consent/http/resources/Resource.java +++ b/src/main/java/org/broadinstitute/consent/http/resources/Resource.java @@ -322,10 +322,10 @@ protected void validateFileDetails(ContentDisposition contentDisposition) { */ void validateAuthedRoleUser( final List privilegedRoles, final User authedUser, final Integer userId) { - List authedRoleIds = privilegedRoles.stream().map(UserRoles::getRoleId).toList(); - boolean authedUserHasRole = - authedUser.getRoles().stream() - .anyMatch(userRole -> authedRoleIds.contains(userRole.getRoleId())); + // Through User rather than over getRoles() directly: a user with no user_role rows has a null + // role list, not an empty one, and streaming it threw a NullPointerException that Jersey turned + // into a 500 where a plain denial was meant. hasAnyUserRole matches on the same role ids. + boolean authedUserHasRole = authedUser.hasAnyUserRole(privilegedRoles); if (!authedUserHasRole && !authedUser.getUserId().equals(userId)) { throw new ForbiddenException("User does not have permission"); } diff --git a/src/main/java/org/broadinstitute/consent/http/resources/StudyAssetResource.java b/src/main/java/org/broadinstitute/consent/http/resources/StudyAssetResource.java new file mode 100644 index 0000000000..4c5dfaf254 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/resources/StudyAssetResource.java @@ -0,0 +1,82 @@ +package org.broadinstitute.consent.http.resources; + +import com.google.inject.Inject; +import io.dropwizard.auth.Auth; +import jakarta.annotation.security.PermitAll; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.models.StudyAssets; +import org.broadinstitute.consent.http.service.StudyAssetService; + +@Path("api/dataset/study/{studyId}/assets") +@Produces(MediaType.APPLICATION_JSON) +public class StudyAssetResource extends Resource { + private final StudyAssetService service; + + @Inject + public StudyAssetResource(StudyAssetService service) { + this.service = service; + } + + @GET + @Path("/publications") + @PermitAll + public Response publications(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.PUBLICATIONS); + } + + @GET + @Path("/models") + @PermitAll + public Response models(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.MODELS); + } + + @GET + @Path("/workspaces") + @PermitAll + public Response workspaces(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.WORKSPACES); + } + + @GET + @Path("/presentations") + @PermitAll + public Response presentations(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.PRESENTATIONS); + } + + @GET + @Path("/clinicalTrials") + @PermitAll + public Response clinicalTrials(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.CLINICAL_TRIALS); + } + + @GET + @Path("/intellectualProperty") + @PermitAll + public Response intellectualProperty(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.INTELLECTUAL_PROPERTIES); + } + + @GET + @Path("/fundingResources") + @PermitAll + public Response fundingResources(@Auth DuosUser user, @PathParam("studyId") Integer studyId) { + return assets(user, studyId, StudyAssets.FUNDING); + } + + private Response assets(DuosUser user, Integer studyId, String key) { + try { + return Response.ok(service.getAssetsByType(studyId, user.getUser(), key)).build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/resources/StudyCommentResource.java b/src/main/java/org/broadinstitute/consent/http/resources/StudyCommentResource.java new file mode 100644 index 0000000000..dccc8eba18 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/resources/StudyCommentResource.java @@ -0,0 +1,173 @@ +package org.broadinstitute.consent.http.resources; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.inject.Inject; +import io.dropwizard.auth.Auth; +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.service.StudyCommentService; + +@Path("api/dataset/study/{studyId}/comments") +@Produces(MediaType.APPLICATION_JSON) +public class StudyCommentResource extends Resource { + private static final String PAYLOAD_ERROR = "Comment payload must be a JSON object"; + private static final String RATING_TYPE_ERROR = "Rating must be a whole number between 1 and 5."; + private static final String COMMENT_TYPE_ERROR = "Comment text must be a string."; + + private final StudyCommentService service; + + private record CommentPayload(Integer rating, String commentText) {} + + @Inject + public StudyCommentResource(StudyCommentService service) { + this.service = service; + } + + /** + * A page of the study's comments, newest first by creation. + * + *

Bounded by default: a study's comment count grows with its readers, so an unpaged list would + * grow without limit. The average and total are computed across every comment, not the page, so + * neither moves as a caller pages through. + */ + @GET + @PermitAll + public Response list( + @Auth DuosUser user, + @PathParam("studyId") Integer studyId, + @QueryParam("limit") Integer limit, + @QueryParam("offset") Integer offset) { + try { + return Response.ok(service.list(studyId, user.getUser(), pageSize(limit), startingAt(offset))) + .build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } + + private int pageSize(Integer limit) { + if (limit == null) { + return StudyCommentService.DEFAULT_PAGE_SIZE; + } + if (limit < 1 || limit > StudyCommentService.MAX_PAGE_SIZE) { + throw new BadRequestException( + "limit must be between 1 and %d".formatted(StudyCommentService.MAX_PAGE_SIZE)); + } + return limit; + } + + private int startingAt(Integer offset) { + if (offset == null) { + return 0; + } + if (offset < 0) { + throw new BadRequestException("offset must not be negative"); + } + return offset; + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @RolesAllowed({RESEARCHER}) + public Response post(@Auth DuosUser user, @PathParam("studyId") Integer studyId, String json) { + try { + CommentPayload payload = parsePayload(json); + return Response.ok( + service.post(studyId, user.getUser(), payload.rating(), payload.commentText())) + .build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } + + /** + * Reads the body as a JSON object. Deserializing straight into the record would let Gson coerce + * whatever it was given - a string "4" becomes the integer 4, a fraction is truncated - so the + * rating is read out by hand and its JSON type asserted. Everything downstream sees an Integer, + * and cannot tell a coerced value from one the client actually sent as a number. + */ + private CommentPayload parsePayload(String json) { + JsonElement element; + try { + element = JsonParser.parseString(json == null ? "" : json); + } catch (JsonSyntaxException e) { + throw new BadRequestException(PAYLOAD_ERROR); + } + if (element == null || !element.isJsonObject()) { + throw new BadRequestException(PAYLOAD_ERROR); + } + JsonObject object = element.getAsJsonObject(); + return new CommentPayload(rating(object), commentText(object)); + } + + /** + * A rating must be sent as a JSON number with no fractional part. An absent or null rating is + * passed through as null so the service produces its own range message. + */ + private Integer rating(JsonObject object) { + JsonElement element = object.get("rating"); + if (element == null || element.isJsonNull()) { + return null; + } + if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) { + throw new BadRequestException(RATING_TYPE_ERROR); + } + try { + return element.getAsBigDecimal().intValueExact(); + } catch (ArithmeticException | NumberFormatException e) { + // Fractional, or too large to be an int. + throw new BadRequestException(RATING_TYPE_ERROR); + } + } + + /** + * Comment text must be sent as a JSON string. isJsonPrimitive alone is also true of numbers and + * booleans, whose getAsString stored 123 as "123" and false as "false", so the type is asserted + * the same way the rating's is. + */ + private String commentText(JsonObject object) { + JsonElement element = object.get("commentText"); + if (element == null || element.isJsonNull()) { + return null; + } + if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString()) { + throw new BadRequestException(COMMENT_TYPE_ERROR); + } + return element.getAsString(); + } + + /** + * Removes a comment: the caller's own, or anyone's when the caller is an admin. Admins are the + * moderation path over comments that every authenticated user can read. A user who has lost the + * Researcher role can no longer delete, their own comment included. + */ + @DELETE + @Path("/{commentId}") + @RolesAllowed({RESEARCHER, ADMIN}) + public Response delete( + @Auth DuosUser user, + @PathParam("studyId") Integer studyId, + @PathParam("commentId") Integer commentId) { + try { + service.delete(studyId, commentId, user.getUser()); + return Response.noContent().build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/resources/StudyResource.java b/src/main/java/org/broadinstitute/consent/http/resources/StudyResource.java index 0c0aaa310b..60afa3eeca 100644 --- a/src/main/java/org/broadinstitute/consent/http/resources/StudyResource.java +++ b/src/main/java/org/broadinstitute/consent/http/resources/StudyResource.java @@ -140,7 +140,7 @@ public Response updateCustodians( public Response getStudyById(@Auth DuosUser duosUser, @PathParam("studyId") Integer studyId) { try { Study study = datasetService.getStudyWithDatasetsById(duosUser.getUser(), studyId); - checkPublicVisibilityForUser(study, duosUser.getUser()); + requireReadableStudy(study, duosUser.getUser()); return Response.ok(study).build(); } catch (Exception e) { return createExceptionResponse(e); @@ -155,16 +155,26 @@ public Response getStudyById(@Auth DuosUser duosUser, @PathParam("studyId") Inte public Response patchStudyById( @Auth DuosUser duosUser, @PathParam("studyId") Integer studyId, String json) { try { + User user = duosUser.getUser(); Study study = datasetService.findStudy(studyId); if (study == null) { throw new NotFoundException("Study not found"); } - checkPublicVisibilityForUser(study, duosUser.getUser()); + requireReadableStudy(study, user); + // Reading the study is not authority to change it: a publicly visible study is readable by + // everyone, and the class-level role gate only says the caller holds a study-editing role + // somewhere in DUOS. A write additionally requires ownership of this study, as the + // registration PUT path does. + if (!datasetService.isCreatorCustodianOrAdmin(user, study)) { + throw new ForbiddenException("Study with ID " + studyId + " is not updatable"); + } StudyPatch studyPatch = StudyPatch.fromJson(json); - if (!studyPatch.isPatchable(study)) { + // Not modified means nothing to do, and a patch that only retires the legacy institution + // property has something to do even though it changes no stored value. + if (!studyPatch.isPatchable(study) && !studyPatch.retiresLegacyPiInstitution(study)) { return Response.status(Status.NOT_MODIFIED).entity(study).build(); } - Study patchedStudy = datasetService.patchStudy(studyId, duosUser.getUser(), studyPatch); + Study patchedStudy = datasetService.patchStudy(studyId, user, studyPatch); return Response.ok(patchedStudy).build(); } catch (Exception e) { return createExceptionResponse(e); @@ -212,7 +222,7 @@ public Response getRegistrationFromStudy( @Auth DuosUser duosUser, @PathParam("studyId") Integer studyId) { try { Study study = datasetService.getStudyWithDatasetsById(duosUser.getUser(), studyId); - checkPublicVisibilityForUser(study, duosUser.getUser()); + requireReadableStudy(study, duosUser.getUser()); List datasets = Objects.nonNull(study.getDatasets()) ? study.getDatasets().stream().toList() : List.of(); DatasetRegistrationSchemaV1 registration = @@ -297,12 +307,17 @@ private StudyUpdateValidationResult validateRegistrationUpdate(String json, Stud return new StudyUpdateValidationResult(request, valid); } - private void checkPublicVisibilityForUser(Study study, User user) { - boolean isApprovedRole = datasetService.isCreatorCustodianOrAdmin(user, study); - boolean isPubliclyVisible = study.getPublicVisibility(); - // If approved role or publicly visible, the user can see the study, otherwise throw - if (!isApprovedRole && !isPubliclyVisible) { - throw new NotFoundException("Study not found"); - } + /** + * Enforces read access to the study, and nothing more. + * + *

Named for what it actually decides. A publicly visible study satisfies this for everyone, so + * it says only that the caller may see the study - never that they may change it. A write needs + * ownership on top, which is what {@link DatasetService#isCreatorCustodianOrAdmin} is for and + * what the DELETE path applies. The previous name said "check public visibility for user" without + * naming the operation it authorized, which is how a read predicate came to stand in front of a + * write. + */ + private void requireReadableStudy(Study study, User user) { + datasetService.verifyStudyVisibilityAccess(study, user); } } diff --git a/src/main/java/org/broadinstitute/consent/http/service/DatasetRegistrationService.java b/src/main/java/org/broadinstitute/consent/http/service/DatasetRegistrationService.java index 50a957a940..765ecf67e3 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/DatasetRegistrationService.java +++ b/src/main/java/org/broadinstitute/consent/http/service/DatasetRegistrationService.java @@ -168,6 +168,13 @@ public Study updateStudyFromRegistration( }); List studyProps = registrationRequestMapper.toStudyProperties(registration); + // Registration carries the PI institution but not the PI's profile links, which are only + // editable through PATCH /api/dataset/study/{studyId}. The institution comes from this + // payload; the links are filled in from the study the write transaction already loads, so a + // registration edit does not drop them and does not need a second read of the whole study. + DatasetServiceDAO.StudyPiDetails piDetails = + DatasetServiceDAO.StudyPiDetails.institutionWithStoredLinks( + registration.getPiInstitution()); DatasetServiceDAO.StudyUpdate studyUpdate = new StudyUpdate( registration.getStudyName(), @@ -176,6 +183,7 @@ public Study updateStudyFromRegistration( registration.getDataTypes(), registration.getPiName(), registration.getPiEmail(), + piDetails, registration.getPublicVisibility(), user.getUserId(), studyProps, @@ -259,6 +267,7 @@ private DatasetServiceDAO.StudyInsert createStudyInsert( registration.getDataTypes(), registration.getPiName(), registration.getPiEmail(), + registration.getPiInstitution(), registration.getPublicVisibility(), user.getUserId(), registrationRequestMapper.toStudyProperties(registration), diff --git a/src/main/java/org/broadinstitute/consent/http/service/DatasetService.java b/src/main/java/org/broadinstitute/consent/http/service/DatasetService.java index f4f4ccfb21..265a31176c 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/DatasetService.java +++ b/src/main/java/org/broadinstitute/consent/http/service/DatasetService.java @@ -13,6 +13,7 @@ import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; @@ -180,13 +181,29 @@ protected List verifyPublicVisibilityAccess( } protected Dataset verifyPublicVisibilityAccess(Dataset dataset, User user) { - // Admins + return readBasis(dataset, user) == null ? null : dataset; + } + + /** + * Why this user may read this dataset, or null if they may not. + * + *

Same decision {@link #verifyPublicVisibilityAccess(Dataset, User)} has always made - the set + * of callers it lets through is unchanged - but it says which rule let them through. A caller + * that derives something more sensitive than the dataset itself needs that: a dataset belonging + * to no study is returned to everyone because there is no visibility to test, which is a weaker + * reason than being its creator or an admin, and the two should not be treated alike. + */ + protected DatasetReadBasis readBasis(Dataset dataset, User user) { if (user.hasUserRole(UserRoles.ADMIN)) { - return dataset; + return DatasetReadBasis.ADMIN; + } + // Checked before the study, so a creator is reported as one whether or not a study exists. This + // grants nothing new: the study branch below already admitted them. + if (Objects.equals(dataset.getCreateUserId(), user.getUserId())) { + return DatasetReadBasis.DATASET_CREATOR; } - // If there is no study, we can't verify visibility, so return the dataset if (dataset.getStudyId() == null) { - return dataset; + return DatasetReadBasis.NO_STUDY; } // Reuse the study when the caller already populated it for the response. Otherwise read only // the details canReadStudy needs, and do not attach it - callers decide what the response @@ -195,23 +212,44 @@ protected Dataset verifyPublicVisibilityAccess(Dataset dataset, User user) { dataset.getStudy() != null ? dataset.getStudy() : studyDAO.findStudyDetailsById(dataset.getStudyId()); - if (canReadStudy(user, study) || Objects.equals(dataset.getCreateUserId(), user.getUserId())) { - return dataset; - } - return null; + return canReadStudy(user, study) ? DatasetReadBasis.STUDY_READABLE : null; + } + + /** The dataset, and the rule that let this caller read it. */ + public record DatasetRead(Dataset dataset, DatasetReadBasis basis) {} + + /** Why a caller was allowed to read a dataset. */ + public enum DatasetReadBasis { + /** An admin, who may read anything. */ + ADMIN, + /** The user who created the dataset. */ + DATASET_CREATOR, + /** The dataset's study is published, or this caller is its creator or custodian. */ + STUDY_READABLE, + /** + * The dataset belongs to no study, so there was no visibility to test and every authenticated + * caller is let through. Nothing about this caller was checked. + */ + NO_STUDY } + /** + * Whether the user may read this study and anything derived from it: its creator, its custodians + * and admins always may, anyone may when it is publicly visible. + * + *

public_visibility is nullable, and a null is not a grant - it is an unset flag on a study + * nobody has published. This used to read as public here while the dataset study summaries + * treated it as private, so the same study was readable through one route and hidden on another. + * The single definition lives here; {@link #verifyStudyVisibilityAccess(Study, User)} is the + * throwing form of it. + */ protected boolean canReadStudy(User user, Study study) { if (study == null) { return false; } - if (user.hasUserRole(UserRoles.ADMIN)) { - return true; - } - if (!Boolean.FALSE.equals(study.getPublicVisibility())) { - return true; - } - return isCreatorOrCustodian(user, study); + // Visibility first: a published study is readable without reading its creator or properties. + return Boolean.TRUE.equals(study.getPublicVisibility()) + || isCreatorCustodianOrAdmin(user, study); } protected boolean isCreatorOrCustodian(User user, Dataset dataset) { @@ -251,6 +289,43 @@ public boolean isCreatorCustodianOrAdmin(User user, Study study) { return user.hasUserRole(UserRoles.ADMIN) || isCreatorOrCustodian(user, study); } + /** + * Enforces read access to a study and everything derived from it. A study that is not publicly + * visible is readable only by its creator, its custodians, and admins. Mirrors {@link + * #verifyPublicVisibilityAccess(Dataset, User)} for datasets. + * + * @param study The study to check. Must be populated with creator and properties. + * @param user The requesting user + * @return The same study, when the user may read it + * @throws NotFoundException if the study is not visible to the user + */ + public Study verifyStudyVisibilityAccess(Study study, User user) { + // A study the caller may not read is reported as absent rather than forbidden, as is a study + // that does not exist. canReadStudy holds the rule; see it for the null-visibility case. + if (!canReadStudy(user, study)) { + throw new NotFoundException("Study not found"); + } + return study; + } + + /** + * Loads a study for reading by an endpoint scoped to it, or reports it absent. + * + *

Reads only the study's own details. {@link #findStudy(Integer)} additionally opens a + * REPEATABLE_READ transaction and fetches dataset ids and the alternative data sharing plan file, + * none of which the visibility rule looks at; the study-scoped comment, metrics and asset + * endpoints were each paying for that on every request. + * + * @throws NotFoundException if the study does not exist, or is not visible to the user + */ + public Study requireReadableStudy(Integer studyId, User user) { + Study study = studyDAO.findStudyDetailsById(studyId); + if (study == null) { + throw new NotFoundException("Study not found"); + } + return verifyStudyVisibilityAccess(study, user); + } + public Dataset getDatasetByName(String name) { String lowercaseName = name.toLowerCase(); return datasetDAO.getDatasetByName(lowercaseName); @@ -276,15 +351,23 @@ public Dataset findDatasetById(User user, Integer id) { * @throws ForbiddenException if the user cannot view the dataset */ public Dataset findDatasetByIdForRead(User user, Integer id) { + return findDatasetByIdForReadWithBasis(user, id).dataset(); + } + + /** + * {@link #findDatasetByIdForRead(User, Integer)} with the rule that allowed it, for callers that + * return more than the dataset and need to know how much the check actually established. + */ + public DatasetRead findDatasetByIdForReadWithBasis(User user, Integer id) { Dataset dataset = datasetDAO.findDatasetById(id); if (dataset == null) { throw new NotFoundException("Entity not found"); } - Dataset authorizedDataset = verifyPublicVisibilityAccess(dataset, user); - if (authorizedDataset == null) { + DatasetReadBasis basis = readBasis(dataset, user); + if (basis == null) { throw new ForbiddenException("User does not have permission"); } - return authorizedDataset; + return new DatasetRead(dataset, basis); } /** @@ -347,15 +430,21 @@ public Study findStudy(Integer studyId) { return studyDAO.findStudyById(studyId); } + /** + * Loads a study for reading, or reports it absent. + * + *

Delegates to {@link #verifyStudyVisibilityAccess(Study, User)} rather than re-deciding: a + * second gate over the same predicate used to answer 403 here and 404 there, so two routes over + * the same study - its registration assets and its files - disagreed about whether a study the + * caller may not read is forbidden or absent. A 403 also confirms the study exists, which is what + * the visibility flag is meant to withhold. + */ public Study findStudyByIdForRead(User user, Integer studyId) { Study study = studyDAO.findStudyById(studyId); if (study == null) { throw new NotFoundException("Entity not found"); } - if (!canReadStudy(user, study)) { - throw new ForbiddenException("User does not have permission"); - } - return study; + return verifyStudyVisibilityAccess(study, user); } public List findAllDatasetStudySummaries(User user) { @@ -728,12 +817,17 @@ private Integer updateStudyFromConversion( study.setStudyId(studyId); } else { studyId = study.getStudyId(); + // A study conversion carries no PI detail columns, so keep the stored ones. studyDAO.updateStudy( study.getStudyId(), studyConversion.getName(), studyConversion.getDescription(), studyConversion.getPiName(), studyConversion.getPiEmail(), + study.getPiInstitution() == null ? null : study.getPiInstitution().getId(), + study.getPiOrcid(), + study.getPiLinkedinUrl(), + study.getPiWebsiteUrl(), studyConversion.getDataTypes(), studyConversion.getPublicVisibility(), userId, @@ -793,6 +887,10 @@ public Study patchStudy(Integer studyId, User user, StudyPatch patch) { datasetServiceDAO.patchStudy(study, user, patch); elasticSearchService.indexStudy(studyId); return studyDAO.findStudyById(studyId); + } catch (WebApplicationException ex) { + // A rejected patch is the caller's problem, not a server fault: re-wrapping it here turned + // "PI institution 999999 does not exist" into an opaque 500. + throw ex; } catch (Exception ex) { logException(ex); throw new InternalServerErrorException( diff --git a/src/main/java/org/broadinstitute/consent/http/service/ElasticSearchService.java b/src/main/java/org/broadinstitute/consent/http/service/ElasticSearchService.java index 222ffb6452..6dbbe84cb1 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/ElasticSearchService.java +++ b/src/main/java/org/broadinstitute/consent/http/service/ElasticSearchService.java @@ -2,7 +2,6 @@ import static org.broadinstitute.consent.http.models.StudyPatch.EXTERNAL_IDENTIFIER; import static org.broadinstitute.consent.http.models.StudyPatch.EXTERNAL_IDENTIFIER_TYPE; -import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.assets; import static org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder.data; import com.google.api.client.http.HttpStatusCodes; @@ -41,6 +40,7 @@ import org.broadinstitute.consent.http.models.DatasetProperty; import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; +import org.broadinstitute.consent.http.models.StudyAssets; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.models.datause.DataUsePrimaryClassifier; @@ -72,6 +72,7 @@ public class ElasticSearchService implements ConsentLogger { private final UserDAO userDAO; private final OntologyService ontologyService; private final InstitutionDAO institutionDAO; + private final StudyAssets studyAssets = new StudyAssets(); private final DatasetDAO datasetDAO; private final DatasetServiceDAO datasetServiceDAO; private final StudyDAO studyDAO; @@ -299,8 +300,12 @@ public StudyTerm toStudyTerm(Study study) { term.setDataSubmitterEmail(study.getCreateUserEmail()); } - findStudyProperty(study.getProperties(), assets) - .ifPresent(prop -> term.setAssets(buildMapFromPropertyValue(prop.getValue()))); + // The indexed assets object keeps its original shape — every promoted list plus whatever + // unpromoted keys remain — so search consumers are unaffected by the promotion. + Map studyAssetsMap = studyAssets.assemble(study.getProperties()); + if (!studyAssetsMap.isEmpty()) { + term.setAssets(studyAssetsMap); + } findStudyProperty(study.getProperties(), data) .ifPresent(prop -> term.setData(buildMapFromPropertyValue(prop.getValue()))); findStudyProperty(study.getProperties(), EXTERNAL_IDENTIFIER) diff --git a/src/main/java/org/broadinstitute/consent/http/service/MetricsService.java b/src/main/java/org/broadinstitute/consent/http/service/MetricsService.java index 2c03e91802..c5ba760c18 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/MetricsService.java +++ b/src/main/java/org/broadinstitute/consent/http/service/MetricsService.java @@ -1,30 +1,101 @@ package org.broadinstitute.consent.http.service; import com.google.inject.Inject; -import jakarta.ws.rs.NotFoundException; import java.util.List; +import java.util.Objects; +import java.util.function.Function; import org.broadinstitute.consent.http.db.DataAccessRequestDAO; -import org.broadinstitute.consent.http.db.DatasetDAO; +import org.broadinstitute.consent.http.db.StudyRecommendationDAO; import org.broadinstitute.consent.http.models.DarMetricsSummary; +import org.broadinstitute.consent.http.models.DataAccessRequest; +import org.broadinstitute.consent.http.models.DataAccessRequestData; +import org.broadinstitute.consent.http.models.StudyRecommendation; +import org.broadinstitute.consent.http.models.StudyResearchOutputs; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.service.DatasetService.DatasetRead; +import org.broadinstitute.consent.http.service.DatasetService.DatasetReadBasis; import org.jdbi.v3.core.Jdbi; public class MetricsService { - private final DatasetDAO dataSetDAO; private final DataAccessRequestDAO darDAO; + private final StudyRecommendationDAO recommendationDAO; + private final DatasetService datasetService; @Inject - public MetricsService(Jdbi jdbi) { - this.dataSetDAO = jdbi.onDemand(DatasetDAO.class); + public MetricsService(Jdbi jdbi, DatasetService datasetService) { this.darDAO = jdbi.onDemand(DataAccessRequestDAO.class); + this.recommendationDAO = jdbi.onDemand(StudyRecommendationDAO.class); + this.datasetService = datasetService; } - public List generateDarSummaries(Integer datasetId) { - // Only the dataset's existence matters here, so avoid assembling the full dataset. - Integer existingDatasetId = dataSetDAO.findDatasetIdById(datasetId); - if (existingDatasetId == null) { - throw new NotFoundException("Dataset with specified ID does not exist."); + /** + * The granted requests recorded against one dataset. + * + *

Gated on being able to read the dataset: this route once checked only that the dataset + * existed, so any authenticated caller could walk ids and read the project titles and research + * use statements. findDatasetByIdForRead applies the existence-then-visibility rule the other + * dataset routes use, but admits a dataset with no study to everyone, having no study visibility + * to test - so the requester's institution is withheld on those. + */ + public List generateDarSummaries(Integer datasetId, User user) { + DatasetRead read = datasetService.findDatasetByIdForReadWithBasis(user, datasetId); + List summaries = + darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(datasetId); + // Withheld only where no visibility decision covers the dataset at all. A dataset with no + // study has none to test, so it is returned to every authenticated caller and the requester's + // affiliation would be enumerable by walking ids. Everything else is covered by a decision + // someone made: a published study was deliberately opened to any authenticated caller, and a + // hidden one is reachable only by its creator, its custodians or an admin. STUDY_READABLE + // spans both, so it does not mean this particular caller was vetted - only that the study's + // own visibility already answered who may see what it carries. + if (read.basis() != DatasetReadBasis.NO_STUDY) { + return summaries; } - return darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(datasetId); + return summaries.stream().map(DarMetricsSummary::withoutRequesterIdentity).toList(); + } + + public List generateStudyDarSummaries(Integer studyId, User user) { + requireStudy(studyId, user); + return darDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + } + + public StudyResearchOutputs generateStudyResearchOutputs(Integer studyId, User user) { + requireStudy(studyId, user); + List reports = darDAO.findProgressReportsByStudyId(studyId); + return new StudyResearchOutputs( + collectOutputs(reports, DataAccessRequestData::getPresentations), + collectOutputs(reports, DataAccessRequestData::getPublications), + collectOutputs(reports, DataAccessRequestData::getIntellectualProperties)); + } + + private static List collectOutputs( + List reports, Function> getOutputs) { + return reports.stream() + .map(DataAccessRequest::getData) + .filter(Objects::nonNull) + .map(getOutputs) + .filter(Objects::nonNull) + .flatMap(List::stream) + .toList(); + } + + public List getSimilarStudies(Integer studyId, User user) { + requireStudy(studyId, user); + return recommendationDAO.findSimilar(studyId); + } + + public List getFrequentlyRequestedWith(Integer studyId, User user) { + requireStudy(studyId, user); + return recommendationDAO.findFrequentlyRequestedWith(studyId); + } + + /** + * Enforces the same read access StudyResource applies to the study itself: a study that is not + * publicly visible is readable only by its creator, custodians, and admins. The shared gate reads + * only the study's own details, which is all the rule needs. + */ + private void requireStudy(Integer studyId, User user) { + datasetService.requireReadableStudy(studyId, user); } } diff --git a/src/main/java/org/broadinstitute/consent/http/service/StudyAssetService.java b/src/main/java/org/broadinstitute/consent/http/service/StudyAssetService.java new file mode 100644 index 0000000000..70550dfc75 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/StudyAssetService.java @@ -0,0 +1,32 @@ +package org.broadinstitute.consent.http.service; + +import com.google.inject.Inject; +import java.util.List; +import org.broadinstitute.consent.http.models.Study; +import org.broadinstitute.consent.http.models.StudyAssets; +import org.broadinstitute.consent.http.models.User; + +public class StudyAssetService { + + private final DatasetService datasetService; + private final StudyAssets studyAssets = new StudyAssets(); + + @Inject + public StudyAssetService(DatasetService datasetService) { + this.datasetService = datasetService; + } + + /** + * Enforces the same read access StudyResource applies to the study itself: a study that is not + * publicly visible is readable only by its creator, custodians, and admins. The shared gate reads + * the study's own details, which carries the properties the asset lists live in. + */ + private Study requireStudy(Integer studyId, User user) { + return datasetService.requireReadableStudy(studyId, user); + } + + /** Returns the registration assets of one type recorded for the study. */ + public List getAssetsByType(Integer studyId, User user, String key) { + return studyAssets.findAssetList(requireStudy(studyId, user).getProperties(), key); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/service/StudyCommentService.java b/src/main/java/org/broadinstitute/consent/http/service/StudyCommentService.java new file mode 100644 index 0000000000..6b86d1aec5 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/StudyCommentService.java @@ -0,0 +1,110 @@ +package org.broadinstitute.consent.http.service; + +import com.google.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import org.broadinstitute.consent.http.db.LibraryCardDAO; +import org.broadinstitute.consent.http.db.StudyCommentDAO; +import org.broadinstitute.consent.http.enumeration.UserRoles; +import org.broadinstitute.consent.http.models.StudyComment; +import org.broadinstitute.consent.http.models.StudyCommentsSummary; +import org.broadinstitute.consent.http.models.User; +import org.jdbi.v3.core.Jdbi; + +public class StudyCommentService { + private final StudyCommentDAO commentDAO; + private final LibraryCardDAO libraryCardDAO; + private final DatasetService datasetService; + + @Inject + public StudyCommentService(Jdbi jdbi, DatasetService datasetService) { + commentDAO = jdbi.onDemand(StudyCommentDAO.class); + libraryCardDAO = jdbi.onDemand(LibraryCardDAO.class); + this.datasetService = datasetService; + } + + /** The largest page a caller may request, so one request cannot pull an unbounded list. */ + public static final int MAX_PAGE_SIZE = 100; + + public static final int DEFAULT_PAGE_SIZE = 25; + + /** + * The longest comment accepted. Enforced here rather than by the column, which stays TEXT: the + * limit is a product decision that should be changeable without a migration. + */ + public static final int MAX_COMMENT_LENGTH = 2000; + + public StudyCommentsSummary list(Integer studyId, User user, int limit, int offset) { + requireStudy(studyId, user); + List comments = commentDAO.findByStudyId(studyId, limit, offset); + return new StudyCommentsSummary( + comments, + commentDAO.averageRatingByStudyId(studyId), + commentDAO.countByStudyId(studyId), + commentDAO.findByStudyIdAndUserId(studyId, user.getUserId())); + } + + /** + * Records or revises the caller's rating and comment. + * + *

A study's creator and its custodians may rate it, as long as they hold the Researcher role + * and an active library card - the same bar as anyone else. Their ratings count toward the + * average like any other. This is deliberate: the gate is about being an active researcher, not + * about distance from the study. + */ + public StudyComment post(Integer studyId, User user, Integer rating, String text) { + requireStudy(studyId, user); + if (!user.hasUserRole(UserRoles.RESEARCHER) + || libraryCardDAO.findLibraryCardIdByUserId(user.getUserId()) == null) { + throw new ForbiddenException( + "Active Researcher Status is required to comment or rate this study."); + } + if (rating == null || rating < 1 || rating > 5) { + throw new BadRequestException("Rating must be between 1 and 5."); + } + if (text != null && text.length() > MAX_COMMENT_LENGTH) { + throw new BadRequestException( + "Comment must be %d characters or fewer.".formatted(MAX_COMMENT_LENGTH)); + } + Integer id = commentDAO.upsert(studyId, user.getUserId(), rating, text); + StudyComment saved = commentDAO.findById(studyId, id); + if (saved == null) { + // Deleted between the write and the read. Rare, but reading the whole list and calling + // orElseThrow on it reported that as a 500. + throw new NotFoundException("Comment not found"); + } + return saved; + } + + /** + * Removes a comment. + * + *

An author deletes their own; an admin deletes anyone's, which is the only moderation path + * over comments that every authenticated user can read. The Researcher role is required for the + * author path at the resource, so a user who has lost that role can no longer delete - including + * their own comment. An admin is not required to hold it. + */ + public void delete(Integer studyId, Integer commentId, User user) { + requireStudy(studyId, user); + int deleted = + user.hasUserRole(UserRoles.ADMIN) + ? commentDAO.deleteAny(studyId, commentId) + : commentDAO.deleteOwn(studyId, commentId, user.getUserId()); + if (deleted == 0) { + // Someone else's comment is reported absent rather than forbidden, so the response does not + // confirm that a comment the caller may not touch exists. + throw new NotFoundException("Comment not found"); + } + } + + /** + * Enforces the same read access StudyResource applies to the study itself: a study that is not + * publicly visible is readable only by its creator, custodians, and admins. The shared gate reads + * only the study's own details, which is all the rule needs. + */ + private void requireStudy(Integer studyId, User user) { + datasetService.requireReadableStudy(studyId, user); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAO.java b/src/main/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAO.java index 17b78a8f13..0e7d9179de 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAO.java +++ b/src/main/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAO.java @@ -4,6 +4,8 @@ import static org.broadinstitute.consent.http.models.StudyPatch.STUDY_TYPE; import com.google.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; @@ -19,6 +21,7 @@ import org.broadinstitute.consent.http.db.DatasetAuthorizationReaderDAO; import org.broadinstitute.consent.http.db.DatasetDAO; import org.broadinstitute.consent.http.db.FileStorageObjectDAO; +import org.broadinstitute.consent.http.db.InstitutionDAO; import org.broadinstitute.consent.http.db.StudyDAO; import org.broadinstitute.consent.http.enumeration.AuditActions; import org.broadinstitute.consent.http.enumeration.PropertyType; @@ -33,6 +36,7 @@ import org.broadinstitute.consent.http.models.StudyPatch; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder; import org.broadinstitute.consent.http.util.ConsentLogger; import org.broadinstitute.consent.http.util.gson.GsonUtil; import org.jdbi.v3.core.Handle; @@ -207,8 +211,27 @@ public void updateDatasetIndex(Integer datasetId, Integer userId, Instant indexD }); } + /** + * Rejects a PI institution id that names no institution. + * + *

Both write paths reach the column through here, so the rule has one definition. Without it + * the id fell through to fk_study_pi_institution: a registration then failed its whole + * transaction, where before the column existed the same id was accepted as a text property, and a + * PATCH surfaced the constraint violation as a 500 rather than telling the caller the id was + * wrong. + */ + private void requireExistingInstitution(Handle handle, Integer piInstitutionId) { + if (piInstitutionId == null) { + return; + } + if (handle.attach(InstitutionDAO.class).findInstitutionById(piInstitutionId) == null) { + throw new BadRequestException("PI institution %d does not exist".formatted(piInstitutionId)); + } + } + private Integer executeInsertStudy(Handle handle, StudyInsert insert) { StudyDAO studyDAOLocal = handle.attach(StudyDAO.class); + requireExistingInstitution(handle, insert.piInstitutionId); UUID uuid = insert.uuid; Integer studyId = studyDAOLocal.insertStudy( @@ -221,6 +244,9 @@ private Integer executeInsertStudy(Handle handle, StudyInsert insert) { insert.userId, Instant.now(), uuid); + if (insert.piInstitutionId != null) { + studyDAOLocal.updateStudyPiInstitutionId(studyId, insert.piInstitutionId); + } for (StudyProperty prop : insert.props) { studyDAOLocal.insertStudyProperty( @@ -279,12 +305,25 @@ private void executeUpdateStudyKeepProps(Handle handle, StudyUpdate update) { private void executeUpdateStudy(Handle handle, StudyUpdate update, boolean replaceProps) { StudyDAO studyDAOLocal = handle.attach(StudyDAO.class); Study study = studyDAOLocal.findStudyById(update.studyId); + if (study == null) { + throw new NotFoundException("Study with ID " + update.studyId + " does not exist."); + } + // A null piDetails means the caller carries no PI detail values, so keep the stored ones. + StudyPiDetails piDetails = + update.piDetails != null + ? update.piDetails.resolveAgainst(study) + : StudyPiDetails.of(study); + requireExistingInstitution(handle, piDetails.piInstitutionId()); studyDAOLocal.updateStudy( update.studyId, update.name, update.description, update.piName, update.piEmail, + piDetails.piInstitutionId(), + piDetails.piOrcid(), + piDetails.piLinkedinUrl(), + piDetails.piWebsiteUrl(), update.dataTypes, update.publicVisibility, update.userId, @@ -436,6 +475,7 @@ public Study patchStudy(Study study, User user, StudyPatch patch) throws SQLExce executeUpdateStudyKeepProps(handle, studyUpdate); // Blank string signals intent to remove the property deleteBlankPatchedStudyProps(handle, study.getStudyId(), patch); + retireLegacyPiInstitutionProp(handle, study.getStudyId(), patch); } catch (Exception e) { handle.rollback(); logException(e); @@ -446,6 +486,24 @@ public Study patchStudy(Study study, User user, StudyPatch patch) throws SQLExce return studyDAO.findStudyById(study.getStudyId()); } + /** + * Drops the legacy numeric `piInstitution` study property once a patch has set the column. + * + *

study.pi_institution_id is authoritative, but registration also recorded the institution as + * a study property, and the backfill copied that into the column rather than removing it. Left in + * place after a patch, the property is a second, stale answer: raw study reads report it beside + * the new value, and SchemaFromStudy falls back to it whenever the column is null - so a patch + * that deliberately cleared the institution would see the old one resurface in the next + * registration payload. + */ + private void retireLegacyPiInstitutionProp(Handle handle, Integer studyId, StudyPatch patch) { + if (patch.patchesPiInstitutionId()) { + handle + .attach(StudyDAO.class) + .deleteStudyPropertyByKey(studyId, DatasetRegistrationSchemaV1Builder.piInstitution); + } + } + private void deleteBlankPatchedStudyProps(Handle handle, Integer studyId, StudyPatch patch) { StudyDAO studyDAOLocal = handle.attach(StudyDAO.class); patch @@ -458,6 +516,19 @@ private void deleteBlankPatchedStudyProps(Handle handle, Integer studyId, StudyP }); } + /** + * Applies the patch's absent=no-op / explicit-null=clear / value=set convention to the PI detail + * columns. See {@link StudyPatch#resolvePiInstitutionId(Integer)}. + */ + private StudyPiDetails resolvePiDetails(Study study, StudyPatch patch) { + StudyPiDetails existing = StudyPiDetails.of(study); + return new StudyPiDetails( + patch.resolvePiInstitutionId(existing.piInstitutionId()), + patch.resolvePiOrcid(existing.piOrcid()), + patch.resolvePiLinkedinUrl(existing.piLinkedinUrl()), + patch.resolvePiWebsiteUrl(existing.piWebsiteUrl())); + } + // Helper method to convert StudyPatch to StudyUpdate private StudyUpdate convertToStudyUpdate(Study study, User user, StudyPatch patch) { StudyUpdate studyUpdate = @@ -468,6 +539,7 @@ private StudyUpdate convertToStudyUpdate(Study study, User user, StudyPatch patc patch.dataTypes() != null ? patch.dataTypes() : study.getDataTypes(), patch.piName() != null ? patch.piName() : study.getPiName(), patch.piEmail() != null ? patch.piEmail() : study.getPiEmail(), + resolvePiDetails(study, patch), patch.publicVisibility() != null ? patch.publicVisibility() : study.getPublicVisibility(), @@ -694,12 +766,68 @@ public record StudyInsert( List dataTypes, String piName, String piEmail, + Integer piInstitutionId, Boolean publicVisibility, Integer userId, List props, List files, UUID uuid) {} + /** + * The PI detail columns, resolved against the stored study. A null {@code piDetails} on a {@link + * StudyUpdate} means "leave the PI details as they are", for a caller that carries no PI detail + * values at all. + */ + public record StudyPiDetails( + Integer piInstitutionId, + String piOrcid, + String piLinkedinUrl, + String piWebsiteUrl, + /** + * Whether the profile links above are placeholders to be filled from the stored study. A + * patch has already resolved its values against storage, so its links are written verbatim; a + * registration edit carries no links at all, because they are PATCH-only. + */ + boolean keepStoredLinks) { + + public StudyPiDetails( + Integer piInstitutionId, String piOrcid, String piLinkedinUrl, String piWebsiteUrl) { + this(piInstitutionId, piOrcid, piLinkedinUrl, piWebsiteUrl, false); + } + + public static StudyPiDetails of(Study study) { + return new StudyPiDetails( + study.getPiInstitution() == null ? null : study.getPiInstitution().getId(), + study.getPiOrcid(), + study.getPiLinkedinUrl(), + study.getPiWebsiteUrl()); + } + + /** + * A registration edit: the institution comes from the payload, the profile links from the + * stored study. + * + *

Registration used to read those links through a separate findStudyById before building the + * update, which assembled the whole study - properties, dataset ids and files - to copy three + * strings, and left a gap between that read and the write. They are now filled in from the + * study the write transaction has already loaded. + */ + public static StudyPiDetails institutionWithStoredLinks(Integer piInstitutionId) { + return new StudyPiDetails(piInstitutionId, null, null, null, true); + } + + /** This, with any placeholder profile links filled in from the study being updated. */ + StudyPiDetails resolveAgainst(Study study) { + return keepStoredLinks + ? new StudyPiDetails( + piInstitutionId, + study.getPiOrcid(), + study.getPiLinkedinUrl(), + study.getPiWebsiteUrl()) + : this; + } + } + public record StudyUpdate( String name, Integer studyId, @@ -707,6 +835,7 @@ public record StudyUpdate( List dataTypes, String piName, String piEmail, + StudyPiDetails piDetails, Boolean publicVisibility, Integer userId, List props, diff --git a/src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyTemplateV1Fields.java b/src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyTemplateV1Fields.java index d53c61737f..f70736aaaa 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyTemplateV1Fields.java +++ b/src/main/java/org/broadinstitute/consent/http/service/studytemplate/StudyTemplateV1Fields.java @@ -74,6 +74,14 @@ final class StudyTemplateV1Fields { "alternativeDataSharingPlanTargetPublicReleaseDate", "alternativeDataSharingPlanAccessManagement", "nihInstitutionalCertificationFile", + "models", + "workspaces", + "presentations", + "publications", + "clinicalTrials", + "intellectualProperties", + "biospecimens", + "funding", "assets", "data", "externalIdentifier", diff --git a/src/main/resources/assets/api-docs.yaml b/src/main/resources/assets/api-docs.yaml index 8c0a397025..b188aaa68a 100644 --- a/src/main/resources/assets/api-docs.yaml +++ b/src/main/resources/assets/api-docs.yaml @@ -595,6 +595,24 @@ paths: $ref: './paths/studyConvertByIdentifier.yaml' /api/dataset/study/{studyId}/custodians: $ref: './paths/studyByIdCustodians.yaml' + /api/dataset/study/{studyId}/assets/publications: + $ref: './paths/studyPublications.yaml' + /api/dataset/study/{studyId}/assets/models: + $ref: './paths/studyModels.yaml' + /api/dataset/study/{studyId}/assets/workspaces: + $ref: './paths/studyWorkspaces.yaml' + /api/dataset/study/{studyId}/assets/presentations: + $ref: './paths/studyPresentations.yaml' + /api/dataset/study/{studyId}/assets/clinicalTrials: + $ref: './paths/studyClinicalTrials.yaml' + /api/dataset/study/{studyId}/assets/intellectualProperty: + $ref: './paths/studyIntellectualProperty.yaml' + /api/dataset/study/{studyId}/assets/fundingResources: + $ref: './paths/studyFundingResources.yaml' + /api/dataset/study/{studyId}/comments: + $ref: './paths/studyComments.yaml' + /api/dataset/study/{studyId}/comments/{commentId}: + $ref: './paths/studyCommentById.yaml' /api/dataset/study/registration/{studyId}: $ref: './paths/studyRegistrationByStudyId.yaml' /api/dataset/{id}: @@ -893,6 +911,14 @@ paths: $ref: './paths/getMatchesForLatestDataAccessElectionsByPurposeIds.yaml' /api/metrics/dar-summaries/{datasetId}: $ref: './paths/darSummariesByDatasetId.yaml' + /api/metrics/dar-summaries/study/{studyId}: + $ref: './paths/studyDarSummaries.yaml' + /api/metrics/research-outputs/study/{studyId}: + $ref: './paths/studyResearchOutputs.yaml' + /api/metrics/study-recommendations/{studyId}/similar: + $ref: './paths/studySimilarRecommendations.yaml' + /api/metrics/study-recommendations/{studyId}/frequently-requested-with: + $ref: './paths/studyFrequentlyRequestedWith.yaml' /api/passport/userinfo: $ref: './paths/passportUserInfo.yaml' /api/user: diff --git a/src/main/resources/assets/paths/studyById.yaml b/src/main/resources/assets/paths/studyById.yaml index 64539b8362..1541766bac 100644 --- a/src/main/resources/assets/paths/studyById.yaml +++ b/src/main/resources/assets/paths/studyById.yaml @@ -141,7 +141,9 @@ patch: operationId: apiDatasetStudyStudyIdPatch description: | This API allows Admins, Chairpersons, and Data Submitters to patch a limited set of study - properties. All fields are optional and if not provided, will be ignored. + properties. As with the registration PUT, a study is only patchable by Admins, its Creator, + and its Data Custodians; other callers holding one of those roles receive a 403. All fields + are optional and if not provided, will be ignored. * Study name * Study type * Study description @@ -151,6 +153,9 @@ patch: * Study Target Delivery Date * Study Target Public Release Date * Study PI Name + * Study PI Email + * Study PI Institution Id + * Study PI ORCID, LinkedIn URL, and website URL * Study public visibility (boolean) parameters: - name: studyId @@ -177,6 +182,10 @@ patch: $ref: '../schemas/Study.yaml' 304: description: Not Modified + 401: + description: Unauthorized. + 403: + description: Forbidden - the caller does not own this study 404: description: Not Found 429: diff --git a/src/main/resources/assets/paths/studyClinicalTrials.yaml b/src/main/resources/assets/paths/studyClinicalTrials.yaml new file mode 100644 index 0000000000..1d04fae4ba --- /dev/null +++ b/src/main/resources/assets/paths/studyClinicalTrials.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered clinical trials for a study + operationId: apiStudyAssetsClinicalTrialsGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study clinical trials + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/ClinicalTrial.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyCommentById.yaml b/src/main/resources/assets/paths/studyCommentById.yaml new file mode 100644 index 0000000000..9ba201700a --- /dev/null +++ b/src/main/resources/assets/paths/studyCommentById.yaml @@ -0,0 +1,28 @@ +delete: + summary: Delete a comment on a study + description: > + Removes the requesting user's own comment, or any user's comment when the requester is an + admin. Admins are the moderation path over comments, which every authenticated user can read + on a visible study. Requires the Researcher role or the Admin role: a user who has lost the + Researcher role can no longer delete, their own comment included. + operationId: apiStudyCommentsDelete + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + - name: commentId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 204: { description: Comment deleted } + 403: + description: > + The requesting user holds neither the Researcher role nor the Admin role + 404: + description: > + Study not found or not visible to this user, or the comment does not + exist on that study - or, for a non-admin, does not belong to the + requesting user diff --git a/src/main/resources/assets/paths/studyComments.yaml b/src/main/resources/assets/paths/studyComments.yaml new file mode 100644 index 0000000000..8db3a596eb --- /dev/null +++ b/src/main/resources/assets/paths/studyComments.yaml @@ -0,0 +1,72 @@ +get: + summary: A page of the comments and ratings on a study + description: > + Comments are returned newest first by creation date. The response is paged: a study's + comment count grows with its readers, so an unpaged list would grow without limit. + `averageRating` and `total` describe every comment on the study rather than the page + returned, so neither changes as a caller pages through. + operationId: apiStudyCommentsGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + - name: limit + in: query + required: false + description: How many comments to return. Defaults to 25. + schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + - name: offset + in: query + required: false + description: How many comments to skip. Defaults to 0. + schema: { type: integer, minimum: 0, default: 0 } + tags: [Study] + responses: + 200: + description: A page of the study's comments, with the study-wide average and total + content: + application/json: + schema: { $ref: '../schemas/StudyCommentsSummary.yaml' } + 400: { description: limit outside 1-100, or a negative offset } + 404: { description: Study not found, or not visible to this user } +post: + summary: Create or update the requesting user's comment on a study + description: > + A user has at most one comment per study; posting again replaces the + existing rating and text. Requires an active researcher status, + i.e. the Researcher role and a library card. A study's creator and its + custodians may rate it on the same terms as anyone else, and their + ratings count toward the average. + operationId: apiStudyCommentsPost + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [rating] + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + description: The rating for the study, from 1 to 5. + commentText: + type: string + description: Optional comment text. + tags: [Study] + responses: + 200: + description: The created or updated comment + content: + application/json: + schema: { $ref: '../schemas/StudyComment.yaml' } + 400: { description: Missing payload, a rating that is not a whole JSON number, a rating outside 1-5, comment text that is not a JSON string, or comment text longer than 2000 characters } + 403: { description: Active researcher status is required to comment or rate } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyDarSummaries.yaml b/src/main/resources/assets/paths/studyDarSummaries.yaml new file mode 100644 index 0000000000..c201b4ef34 --- /dev/null +++ b/src/main/resources/assets/paths/studyDarSummaries.yaml @@ -0,0 +1,18 @@ +get: + summary: Granted DARs for a study + operationId: apiMetricsDarSummariesStudyIdGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Metrics] + responses: + 200: + description: Granted DAR summaries, newest first + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/DarMetric.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyFrequentlyRequestedWith.yaml b/src/main/resources/assets/paths/studyFrequentlyRequestedWith.yaml new file mode 100644 index 0000000000..ce1fa240e2 --- /dev/null +++ b/src/main/resources/assets/paths/studyFrequentlyRequestedWith.yaml @@ -0,0 +1,18 @@ +get: + summary: Publicly visible studies frequently requested alongside a study + operationId: apiMetricsStudyRecommendationsFrequentlyRequestedWithGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Metrics] + responses: + 200: + description: Up to 12 studies, most frequently co-requested first + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/StudyRecommendation.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyFundingResources.yaml b/src/main/resources/assets/paths/studyFundingResources.yaml new file mode 100644 index 0000000000..d814ec0c96 --- /dev/null +++ b/src/main/resources/assets/paths/studyFundingResources.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered funding resources for a study + operationId: apiStudyAssetsFundingResourcesGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study funding resources + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/FundingResource.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyIntellectualProperty.yaml b/src/main/resources/assets/paths/studyIntellectualProperty.yaml new file mode 100644 index 0000000000..dd0645e345 --- /dev/null +++ b/src/main/resources/assets/paths/studyIntellectualProperty.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered intellectual property for a study + operationId: apiStudyAssetsIntellectualPropertyGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study intellectual property + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/IntellectualProperty.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyModels.yaml b/src/main/resources/assets/paths/studyModels.yaml new file mode 100644 index 0000000000..a7d9a28dd9 --- /dev/null +++ b/src/main/resources/assets/paths/studyModels.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered models for a study + operationId: apiStudyAssetsModelsGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study models + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/AiModel.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyPresentations.yaml b/src/main/resources/assets/paths/studyPresentations.yaml new file mode 100644 index 0000000000..c79ee06f6b --- /dev/null +++ b/src/main/resources/assets/paths/studyPresentations.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered presentations for a study + operationId: apiStudyAssetsPresentationsGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study presentations + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/Presentation.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyPublications.yaml b/src/main/resources/assets/paths/studyPublications.yaml new file mode 100644 index 0000000000..71992e77aa --- /dev/null +++ b/src/main/resources/assets/paths/studyPublications.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered primary publications for a study + operationId: apiStudyAssetsPublicationsGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study publications + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/Publication.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyResearchOutputs.yaml b/src/main/resources/assets/paths/studyResearchOutputs.yaml new file mode 100644 index 0000000000..f1eebfcfdf --- /dev/null +++ b/src/main/resources/assets/paths/studyResearchOutputs.yaml @@ -0,0 +1,16 @@ +get: + summary: Research outputs self-reported in study progress reports + operationId: apiMetricsResearchOutputsStudyIdGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Metrics] + responses: + 200: + description: Presentations, publications, and intellectual property + content: + application/json: + schema: { $ref: '../schemas/StudyResearchOutputs.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studySimilarRecommendations.yaml b/src/main/resources/assets/paths/studySimilarRecommendations.yaml new file mode 100644 index 0000000000..19057db4d4 --- /dev/null +++ b/src/main/resources/assets/paths/studySimilarRecommendations.yaml @@ -0,0 +1,18 @@ +get: + summary: Publicly visible studies similar to a study + operationId: apiMetricsStudyRecommendationsSimilarGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Metrics] + responses: + 200: + description: Up to 12 similar studies, best matches first + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/StudyRecommendation.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/paths/studyWorkspaces.yaml b/src/main/resources/assets/paths/studyWorkspaces.yaml new file mode 100644 index 0000000000..91a62b334d --- /dev/null +++ b/src/main/resources/assets/paths/studyWorkspaces.yaml @@ -0,0 +1,18 @@ +get: + summary: Registered workspaces for a study + operationId: apiStudyAssetsWorkspacesGet + parameters: + - name: studyId + in: path + required: true + schema: { type: integer } + tags: [Study] + responses: + 200: + description: Study workspaces + content: + application/json: + schema: + type: array + items: { $ref: '../schemas/Workspace.yaml' } + 404: { description: Study not found, or not visible to this user } diff --git a/src/main/resources/assets/schemas/AiModel.yaml b/src/main/resources/assets/schemas/AiModel.yaml new file mode 100644 index 0000000000..0d6d0483c9 --- /dev/null +++ b/src/main/resources/assets/schemas/AiModel.yaml @@ -0,0 +1,43 @@ +type: object +description: An AI Model object. +properties: + modelId: + type: string + description: The unique identifier for the model. + studyId: + type: string + description: The study ID associated with the model. + name: + type: string + description: The name of the model. + description: + type: string + description: A description of the model. + url: + type: string + description: The URL to the model. + cloud: + type: array + items: + type: string + description: The cloud platforms the model is available on. + format: + type: string + description: The format the model is distributed in. + license: + type: string + description: The license the model is released under. + trainedOnDatasets: + type: array + items: + type: string + description: The datasets the model was trained on. + maintainer: + description: The maintainer of the model. + allOf: + - $ref: './Maintainer.yaml' + tags: + type: array + items: + type: string + description: Tags associated with the model. diff --git a/src/main/resources/assets/schemas/Biospecimen.yaml b/src/main/resources/assets/schemas/Biospecimen.yaml new file mode 100644 index 0000000000..71dc9f3b97 --- /dev/null +++ b/src/main/resources/assets/schemas/Biospecimen.yaml @@ -0,0 +1,89 @@ +type: object +description: A Biospecimen object. +properties: + biospecimenId: + type: string + description: The unique identifier for the biospecimen. + studyId: + type: string + description: The study ID associated with the biospecimen. + donorId: + type: string + description: The identifier of the donor the biospecimen came from. + specimenType: + type: string + description: The type of biospecimen. + enum: + - BLOOD + - PLASMA + - SERUM + - TISSUE + - SALIVA + - CSF + - PBMC + - TUMOR + - NORMAL_ADJACENT + preservationMethod: + type: string + description: The method used to preserve the biospecimen. + enum: + - FFPE + - FRESH_FROZEN + - CRYO_PRESERVED + - RNA_LATER + - FORMALIN_FIXED + preservationDetails: + type: string + description: Additional detail about the preservation method. + dateOfCollection: + type: string + description: The date the biospecimen was collected. + postMortemInterval: + type: object + description: The interval between death and collection. + properties: + value: + type: number + description: The length of the interval. + unit: + type: string + description: The unit the interval is measured in. + enum: + - HOURS + - MINUTES + - DAYS + - WEEKS + sex: + type: string + description: The donor's sex. + enum: + - FEMALE + - MALE + - UNKNOWN + - NOT_REPORTED + age: + type: number + description: The donor's age. + race: + type: string + description: The donor's race. + countryOfOrigin: + type: string + description: The donor's country of origin, as an ISO-3166 country name. + extractedDiagnoses: + type: array + items: + type: string + description: Diagnoses extracted for the donor. + pathology: + type: string + description: Pathology information for the biospecimen. + organization: + type: string + description: The institution, biobank, or provider holding the biospecimen. + sourceSite: + type: string + description: The clinic, hospital, or collection site the biospecimen came from. + optionalDataUse: + type: string + description: Additional data use terms attached to the biospecimen. diff --git a/src/main/resources/assets/schemas/ClinicalTrial.yaml b/src/main/resources/assets/schemas/ClinicalTrial.yaml new file mode 100644 index 0000000000..eb648248aa --- /dev/null +++ b/src/main/resources/assets/schemas/ClinicalTrial.yaml @@ -0,0 +1,81 @@ +type: object +description: A Clinical Trial object. +properties: + clinicalTrialId: + type: string + description: The unique identifier for the clinical trial. + studyId: + type: string + description: The study ID associated with the clinical trial. + title: + type: string + description: The title of the clinical trial. + registry: + type: string + description: The registry the trial is listed in. + identifier: + type: string + description: The trial's identifier within its registry. + status: + type: string + description: The recruitment status of the clinical trial. + enum: + - Active, not recruiting + - Completed + - Enrolling by invitation + - Not yet recruiting + - Recruiting + - Suspended + - Terminated + - Withdrawn + - Available + - No longer available + - Temporarily not available + - Approved for marketing + - Withheld + - Unknown + sponsor: + type: string + description: The sponsor of the clinical trial. + startDate: + type: string + description: The date the clinical trial started. + endDate: + type: string + description: The date the clinical trial ended. + interventionType: + type: string + description: The type of intervention studied. + enum: + - Behavioral + - Biological + - Combination product + - Device + - Diagnostic test + - Dietary supplement + - Drug + - Genetic + - Procedure + - Radiation + - Other + description: + type: string + description: A description of the clinical trial. + phase: + type: string + description: The phase of the clinical trial. + enum: + - Not Applicable + - Early Phase 1 + - Phase 1 + - Phase 2 + - Phase 3 + - Phase 4 + url: + type: string + description: The URL to the clinical trial. + tags: + type: array + items: + type: string + description: Tags associated with the clinical trial. diff --git a/src/main/resources/assets/schemas/DarMetric.yaml b/src/main/resources/assets/schemas/DarMetric.yaml index 6b2a2c6819..2f39a55836 100644 --- a/src/main/resources/assets/schemas/DarMetric.yaml +++ b/src/main/resources/assets/schemas/DarMetric.yaml @@ -4,6 +4,10 @@ properties: type: string format: date-time description: The latest update date of the DAR + submissionDate: + type: string + format: date-time + description: The date the DAR was submitted projectTitle: type: string description: The DAR project title @@ -12,10 +16,23 @@ properties: description: The DAR code nonTechRus: type: string - description: The DAR non-technical research use statement + description: The DAR's non-technical summary + rus: + type: string + description: The DAR's research use statement expired: type: boolean description: Whether the DAR is expired referenceId: type: string description: The reference identifier for the DAR + institutionName: + type: string + nullable: true + description: >- + The institution of the requester the DAR was granted to. Withheld, and omitted from the + response rather than sent as null, when the dataset belongs to no study: such a dataset is + readable by any authenticated caller, so nothing about the caller was established and the + requester's affiliation would be enumerable by walking dataset ids. Callers admitted on + their own merits - an admin, the dataset's creator, or a reader of its study - always + receive it. The requester's name is never included, whatever the caller. diff --git a/src/main/resources/assets/schemas/DatasetRegistrationSchemaV1.yaml b/src/main/resources/assets/schemas/DatasetRegistrationSchemaV1.yaml index fc7760e29a..cb90ab2345 100644 --- a/src/main/resources/assets/schemas/DatasetRegistrationSchemaV1.yaml +++ b/src/main/resources/assets/schemas/DatasetRegistrationSchemaV1.yaml @@ -228,13 +228,59 @@ properties: description: Consent Groups items: "$ref": "./ConsentGroup.yaml" + models: + type: array + description: Models produced by or associated with this study. + items: + $ref: './AiModel.yaml' + workspaces: + type: array + description: Analysis workspaces associated with this study. + items: + $ref: './Workspace.yaml' + presentations: + type: array + description: Presentations produced by or associated with this study. + items: + $ref: './Presentation.yaml' + publications: + type: array + description: Publications produced by or associated with this study. + items: + $ref: './Publication.yaml' + clinicalTrials: + type: array + description: Clinical trials associated with this study. + items: + $ref: './ClinicalTrial.yaml' + intellectualProperties: + type: array + description: Intellectual property produced by or associated with this study. + items: + $ref: './IntellectualProperty.yaml' + biospecimens: + type: array + description: Biospecimens associated with this study. + items: + $ref: './Biospecimen.yaml' + funding: + type: array + description: Funding sources for this study. + items: + $ref: './FundingResource.yaml' assets: type: object + deprecated: true title: DatasetRegistrationSchemaV1Assets description: | - Optional client-managed presentation/submission-context metadata (e.g. - UI labels or form choices). Preserved and returned as-is by the backend; - not backend-validated. + Deprecated. The asset lists the backend reads — models, workspaces, + presentations, publications, clinicalTrials, intellectualProperties, + biospecimens and funding — are now first-class fields above, and are + stripped from this object when submitted here. What remains is optional + client-managed presentation/submission-context metadata (e.g. UI labels + or form choices), preserved and returned as-is; not backend-validated. + Reads and writes still round-trip the promoted keys here for + compatibility; send the top-level fields instead. data: type: object title: DatasetRegistrationSchemaV1Data diff --git a/src/main/resources/assets/schemas/FundingResource.yaml b/src/main/resources/assets/schemas/FundingResource.yaml new file mode 100644 index 0000000000..a91427d8f6 --- /dev/null +++ b/src/main/resources/assets/schemas/FundingResource.yaml @@ -0,0 +1,35 @@ +type: object +description: A Funding Resource object. +properties: + fundingId: + type: string + description: The unique identifier for the funding resource. + studyId: + type: string + description: The study ID associated with the funding resource. + funderName: + type: string + description: The name of the funder. + funderProgram: + type: string + description: The funder's program supporting the study. + grantNumber: + type: string + description: The grant number. + projectTitle: + type: string + description: The title of the funded project. + startDate: + type: string + description: The date the funding started. + endDate: + type: string + description: The date the funding ended. + url: + type: string + description: The URL to the funding resource. + tags: + type: array + items: + type: string + description: Tags associated with the funding resource. diff --git a/src/main/resources/assets/schemas/IntellectualProperty.yaml b/src/main/resources/assets/schemas/IntellectualProperty.yaml index ef06dfaec9..31d57b6b87 100644 --- a/src/main/resources/assets/schemas/IntellectualProperty.yaml +++ b/src/main/resources/assets/schemas/IntellectualProperty.yaml @@ -17,8 +17,8 @@ properties: type: string description: The patent number. filingDate: - type: boolean - description: The filing date indicator. + type: string + description: The date the intellectual property was filed. status: type: string description: The status of the intellectual property. diff --git a/src/main/resources/assets/schemas/Maintainer.yaml b/src/main/resources/assets/schemas/Maintainer.yaml new file mode 100644 index 0000000000..7e1da06ae1 --- /dev/null +++ b/src/main/resources/assets/schemas/Maintainer.yaml @@ -0,0 +1,9 @@ +type: object +description: A Maintainer object. +properties: + name: + type: string + description: The maintainer's name. + email: + type: string + description: The maintainer's email address. diff --git a/src/main/resources/assets/schemas/Study.yaml b/src/main/resources/assets/schemas/Study.yaml index af271182ea..dd700c3654 100644 --- a/src/main/resources/assets/schemas/Study.yaml +++ b/src/main/resources/assets/schemas/Study.yaml @@ -22,6 +22,20 @@ properties: type: string format: email description: Principal Investigator Email + piInstitution: + $ref: './Institution.yaml' + description: Principal Investigator institution + piOrcid: + type: string + description: Principal Investigator ORCID identifier or URL + piLinkedinUrl: + type: string + format: uri + description: Principal Investigator LinkedIn profile URL + piWebsiteUrl: + type: string + format: uri + description: Principal Investigator website URL publicVisibility: type: boolean description: Whether this study is publicly visible or not. diff --git a/src/main/resources/assets/schemas/StudyComment.yaml b/src/main/resources/assets/schemas/StudyComment.yaml new file mode 100644 index 0000000000..eca0f92453 --- /dev/null +++ b/src/main/resources/assets/schemas/StudyComment.yaml @@ -0,0 +1,35 @@ +type: object +description: A researcher's rating and comment on a study. +properties: + studyCommentId: + type: integer + description: The unique identifier of the comment. + studyId: + type: integer + description: The study the comment belongs to. + userId: + type: integer + description: The user who wrote the comment. + rating: + type: integer + minimum: 1 + maximum: 5 + description: The rating the user gave the study, from 1 to 5. + commentText: + type: string + maxLength: 2000 + description: The comment text. Optional; a rating may stand on its own. + createDate: + type: string + format: date-time + description: When the comment was first posted. + updateDate: + type: string + format: date-time + description: When the comment was last edited. + displayName: + type: string + description: The display name of the commenting user. + institutionName: + type: string + description: The institution of the commenting user, if they have one. diff --git a/src/main/resources/assets/schemas/StudyCommentsSummary.yaml b/src/main/resources/assets/schemas/StudyCommentsSummary.yaml new file mode 100644 index 0000000000..740b77ff89 --- /dev/null +++ b/src/main/resources/assets/schemas/StudyCommentsSummary.yaml @@ -0,0 +1,32 @@ +type: object +description: > + A page of a study's comments, with the study-wide average rating and comment count. +properties: + comments: + type: array + items: + $ref: './StudyComment.yaml' + description: > + The requested page, newest first by creation date. Editing a comment does not change + its position: the order is by when it was first posted, and an edit advances only + updateDate. + averageRating: + type: number + format: double + nullable: true + description: > + The mean of every rating on the study, not only the returned page, or null when the + study has no comments. + total: + type: integer + description: > + How many comments the study has in total, so a caller can tell whether more pages + remain. + yourComment: + allOf: + - $ref: './StudyComment.yaml' + nullable: true + description: > + The requesting user's own comment, or null when they have none. Carried separately + because paging puts it on an unpredictable page, and a client needs it to know whether + saving will add a comment or revise the existing one. diff --git a/src/main/resources/assets/schemas/StudyPatch.yaml b/src/main/resources/assets/schemas/StudyPatch.yaml index af0f7fbf1b..72ff921331 100644 --- a/src/main/resources/assets/schemas/StudyPatch.yaml +++ b/src/main/resources/assets/schemas/StudyPatch.yaml @@ -22,6 +22,36 @@ properties: piName: type: string description: The name of the PI of the study. + piEmail: + type: string + format: email + description: The email address of the PI of the study. + piInstitutionId: + type: integer + nullable: true + description: > + The institution of the PI of the study. Omit the field to leave it + unchanged; send null to clear it. + piOrcid: + type: string + nullable: true + description: > + The ORCID identifier or URL of the PI of the study. Omit the field to + leave it unchanged; send null (or an empty string) to clear it. + piLinkedinUrl: + type: string + format: uri + nullable: true + description: > + The LinkedIn profile URL of the PI of the study. Omit the field to leave + it unchanged; send null (or an empty string) to clear it. + piWebsiteUrl: + type: string + format: uri + nullable: true + description: > + The website URL of the PI of the study. Omit the field to leave it + unchanged; send null (or an empty string) to clear it. dataCustodianEmail: type: array items: diff --git a/src/main/resources/assets/schemas/StudyRecommendation.yaml b/src/main/resources/assets/schemas/StudyRecommendation.yaml new file mode 100644 index 0000000000..bd73219e27 --- /dev/null +++ b/src/main/resources/assets/schemas/StudyRecommendation.yaml @@ -0,0 +1,24 @@ +type: object +description: A publicly visible study recommended alongside another study. +properties: + studyId: + type: integer + description: The unique identifier of the recommended study. + studyName: + type: string + description: The name of the recommended study. + studyDescription: + type: string + description: A human-readable description of the recommended study. + piName: + type: string + description: The name of the PI of the recommended study. + datasetCount: + type: integer + format: int64 + description: Number of datasets belonging to the recommended study. + datasetIds: + type: array + items: + type: integer + description: The dataset ids belonging to the recommended study. diff --git a/src/main/resources/assets/schemas/StudyResearchOutputs.yaml b/src/main/resources/assets/schemas/StudyResearchOutputs.yaml new file mode 100644 index 0000000000..11f599aad1 --- /dev/null +++ b/src/main/resources/assets/schemas/StudyResearchOutputs.yaml @@ -0,0 +1,15 @@ +type: object +description: Research outputs self-reported in a study's data access request progress reports. +properties: + presentations: + type: array + items: + $ref: './Presentation.yaml' + publications: + type: array + items: + $ref: './Publication.yaml' + intellectualProperties: + type: array + items: + $ref: './IntellectualProperty.yaml' diff --git a/src/main/resources/assets/schemas/Workspace.yaml b/src/main/resources/assets/schemas/Workspace.yaml new file mode 100644 index 0000000000..44aea9a770 --- /dev/null +++ b/src/main/resources/assets/schemas/Workspace.yaml @@ -0,0 +1,39 @@ +type: object +description: An analysis Workspace object. +properties: + workspaceId: + type: string + description: The unique identifier for the workspace. + studyId: + type: string + description: The study ID associated with the workspace. + name: + type: string + description: The name of the workspace. + platform: + type: string + description: The platform hosting the workspace. + url: + type: string + description: The URL to the workspace. + cloud: + type: array + items: + type: string + description: The cloud platforms the workspace runs on. + description: + type: string + description: A description of the workspace. + tools: + type: array + items: + type: string + description: The tools available in the workspace. + access: + type: string + description: The access level of the workspace. + tags: + type: array + items: + type: string + description: Tags associated with the workspace. diff --git a/src/main/resources/changelog-master.xml b/src/main/resources/changelog-master.xml index 4d395cf722..9f575a0b4e 100644 --- a/src/main/resources/changelog-master.xml +++ b/src/main/resources/changelog-master.xml @@ -257,6 +257,10 @@ + + + + diff --git a/src/main/resources/changesets/changelog-consent-2026-08-18-study-comments.xml b/src/main/resources/changesets/changelog-consent-2026-08-18-study-comments.xml new file mode 100644 index 0000000000..20d82c2e1b --- /dev/null +++ b/src/main/resources/changesets/changelog-consent-2026-08-18-study-comments.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + ALTER TABLE study_comment ADD CONSTRAINT ck_study_comment_rating CHECK (rating BETWEEN 1 AND 5) + + + diff --git a/src/main/resources/changesets/changelog-consent-2026-08-18-study-pi-details.xml b/src/main/resources/changesets/changelog-consent-2026-08-18-study-pi-details.xml new file mode 100644 index 0000000000..a54c3cc3a6 --- /dev/null +++ b/src/main/resources/changesets/changelog-consent-2026-08-18-study-pi-details.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/changesets/changelog-consent-2026-09-02-study-assets.xml b/src/main/resources/changesets/changelog-consent-2026-09-02-study-assets.xml new file mode 100644 index 0000000000..a6e48076e9 --- /dev/null +++ b/src/main/resources/changesets/changelog-consent-2026-09-02-study-assets.xml @@ -0,0 +1,98 @@ + + + + + + WITH assets_rows AS MATERIALIZED ( + SELECT sp.study_id, sp.value + FROM study_property sp + WHERE sp.key = 'assets' + ) + INSERT INTO study_property (study_id, key, type, value) + SELECT a.study_id, k.key, 'json', (a.value::jsonb -> k.key)::text + FROM assets_rows a + CROSS JOIN (VALUES + ('models'), ('workspaces'), ('presentations'), ('publications'), + ('clinicalTrials'), ('intellectualProperties'), ('biospecimens'), ('funding') + ) AS k(key) + WHERE jsonb_typeof(a.value::jsonb) = 'object' + AND jsonb_typeof(a.value::jsonb -> k.key) = 'array' + -- Compared against the empty array rather than measured with jsonb_array_length: the + -- planner is free to evaluate this before the typeof guard above, and the length + -- function raises "cannot get array length of a scalar" on a string or number value, + -- which the unvalidated assets object is free to hold. Comparison is total. + -- Spelled != rather than the other inequality operator, whose leading angle bracket + -- would not be valid XML character data here. + AND a.value::jsonb -> k.key != '[]'::jsonb + AND NOT EXISTS ( + SELECT 1 FROM study_property existing + WHERE existing.study_id = a.study_id AND existing.key = k.key + ); + + + + + + + WITH assets_rows AS MATERIALIZED ( + SELECT sp.study_property_id, sp.study_id, sp.value + FROM study_property sp + WHERE sp.key = 'assets' + ) + UPDATE study_property sp + SET value = ( + SELECT COALESCE(jsonb_object_agg(kv.key, kv.value), '{}'::jsonb)::text + FROM jsonb_each(a.value::jsonb) AS kv + WHERE NOT ( + kv.key IN ('models', 'workspaces', 'presentations', 'publications', + 'clinicalTrials', 'intellectualProperties', 'biospecimens', 'funding') + AND EXISTS ( + SELECT 1 FROM study_property promoted + WHERE promoted.study_id = a.study_id AND promoted.key = kv.key + ) + ) + ) + FROM assets_rows a + WHERE sp.study_property_id = a.study_property_id + AND jsonb_typeof(a.value::jsonb) = 'object'; + + WITH assets_rows AS MATERIALIZED ( + SELECT sp.study_property_id, sp.value + FROM study_property sp + WHERE sp.key = 'assets' + ) + DELETE FROM study_property sp + USING assets_rows a + WHERE sp.study_property_id = a.study_property_id + AND jsonb_typeof(a.value::jsonb) = 'object' + AND a.value::jsonb = '{}'::jsonb; + + + + diff --git a/src/main/resources/changesets/changelog-consent-2026-09-02-study-pi-institution-backfill.xml b/src/main/resources/changesets/changelog-consent-2026-09-02-study-pi-institution-backfill.xml new file mode 100644 index 0000000000..7507950da5 --- /dev/null +++ b/src/main/resources/changesets/changelog-consent-2026-09-02-study-pi-institution-backfill.xml @@ -0,0 +1,39 @@ + + + + + + + UPDATE study s + SET pi_institution_id = btrim(sp.value)::integer + FROM study_property sp + WHERE sp.study_id = s.study_id + AND sp.key = 'piInstitution' + AND s.pi_institution_id IS NULL + AND btrim(sp.value) ~ '^[0-9]{1,9}$' + AND EXISTS ( + SELECT 1 FROM institution i WHERE i.institution_id = btrim(sp.value)::integer + ); + + + + diff --git a/src/test/java/org/broadinstitute/consent/http/authentication/AuthorizationHelperTest.java b/src/test/java/org/broadinstitute/consent/http/authentication/AuthorizationHelperTest.java index 553e8a6661..89215ce281 100644 --- a/src/test/java/org/broadinstitute/consent/http/authentication/AuthorizationHelperTest.java +++ b/src/test/java/org/broadinstitute/consent/http/authentication/AuthorizationHelperTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -53,6 +54,7 @@ class AuthorizationHelperTest extends AbstractTestHelper { private AuthorizationHelper authorizationHelper; private DuosUserAuthenticator duosUserAuthenticator; private OAuthAuthenticator oAuthAuthenticator; + private static final String EMAIL = "email"; private final ClaimsCache headerCache = new ClaimsCache(); private final String bearerToken = randomAlphabetic(100); private final MultivaluedMap headerMap = new MultivaluedHashMap<>(); @@ -67,12 +69,15 @@ void setUp() { @Test void testAuthorized() { - unauthorizedUser.setEmail("email"); - unauthorizedDuosUser.setEmail(unauthorizedUser.getEmail()); + // Stubbed, not set: these are mocks, so setEmail would be a no-op and getEmail would answer + // null - which findUserByEmail(null) would then match, passing the test through a path the + // service never takes. + when(authorizedUser.getEmail()).thenReturn(EMAIL); + when(authorizedDuosUser.getEmail()).thenReturn(EMAIL); User user = new User(); - user.setEmail(unauthorizedUser.getEmail()); + user.setEmail(EMAIL); user.addRole(UserRoles.Chairperson()); - when(userService.findUserByEmail(unauthorizedUser.getEmail())).thenReturn(user); + when(userService.findUserByEmail(EMAIL)).thenReturn(user); assertTrue(authorizationHelper.authorize(authorizedUser, Resource.CHAIRPERSON)); assertTrue(authorizationHelper.authorize(authorizedDuosUser, Resource.CHAIRPERSON)); } @@ -89,16 +94,75 @@ void testAuthorized() { Resource.ITDIRECTOR }) void testNotAuthorized(String roleName) { - unauthorizedUser.setEmail("email"); - unauthorizedDuosUser.setEmail(unauthorizedUser.getEmail()); + when(unauthorizedUser.getEmail()).thenReturn(EMAIL); + when(unauthorizedDuosUser.getEmail()).thenReturn(EMAIL); User user = new User(); - user.setEmail(unauthorizedUser.getEmail()); + user.setEmail(EMAIL); user.addRole(UserRoles.Researcher()); - when(userService.findUserByEmail(unauthorizedUser.getEmail())).thenReturn(user); + when(userService.findUserByEmail(EMAIL)).thenReturn(user); assertFalse(authorizationHelper.authorize(unauthorizedUser, roleName)); assertFalse(authorizationHelper.authorize(unauthorizedDuosUser, roleName)); } + /** + * A user with no user_role rows comes back with a null role list, not an empty one. Before the + * null guard this threw a NullPointerException out of the authorizer, which Jersey reported as a + * 500 - so every @RolesAllowed endpoint answered a roleless caller with a server error instead of + * denying them. Authorization must simply be false. + */ + @ParameterizedTest + @ValueSource( + strings = { + Resource.MEMBER, + Resource.CHAIRPERSON, + Resource.RESEARCHER, + Resource.SIGNINGOFFICIAL, + Resource.ADMIN, + Resource.DATASUBMITTER, + Resource.ITDIRECTOR + }) + void testNotAuthorizedWhenUserHasNoRoles(String roleName) { + User user = new User(); + assertNull(user.getRoles(), "a user with no roles must have a null role list for this test"); + // The email is incidental here: what matters is that the lookup yields a user with no roles. + when(userService.findUserByEmail(any())).thenReturn(user); + + assertFalse(authorizationHelper.authorize(unauthorizedUser, roleName)); + assertFalse(authorizationHelper.authorize(unauthorizedDuosUser, roleName)); + } + + /** The same case, reached through the authorizers Dropwizard actually wires up. */ + @Test + void testAuthorizersDenyUserWithNoRoles() { + User user = new User(); + when(userService.findUserByEmail(any())).thenReturn(user); + + assertFalse( + new UserAuthorizer(authorizationHelper) + .authorize(unauthorizedUser, Resource.RESEARCHER, null)); + assertFalse( + new DuosUserAuthorizer(authorizationHelper) + .authorize(unauthorizedDuosUser, Resource.RESEARCHER, null)); + } + + /** An explicitly empty role list behaves the same way as a null one. */ + @Test + void testNotAuthorizedWhenUserHasEmptyRoleList() { + User user = new User(); + user.setRoles(List.of()); + when(userService.findUserByEmail(any())).thenReturn(user); + + assertFalse(authorizationHelper.authorize(unauthorizedUser, Resource.RESEARCHER)); + } + + /** findUserByEmail returning null must deny rather than throw. */ + @Test + void testNotAuthorizedWhenUserIsNull() { + when(userService.findUserByEmail(any())).thenReturn(null); + + assertFalse(authorizationHelper.authorize(unauthorizedUser, Resource.RESEARCHER)); + } + @Test void testAuthenticateWithToken() { headerMap.put(ClaimsCache.OAUTH2_CLAIM_email, List.of("email")); diff --git a/src/test/java/org/broadinstitute/consent/http/db/DataAccessRequestDAOTest.java b/src/test/java/org/broadinstitute/consent/http/db/DataAccessRequestDAOTest.java index 39820ec746..401f2f40ee 100644 --- a/src/test/java/org/broadinstitute/consent/http/db/DataAccessRequestDAOTest.java +++ b/src/test/java/org/broadinstitute/consent/http/db/DataAccessRequestDAOTest.java @@ -20,6 +20,8 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.broadinstitute.consent.http.enumeration.ElectionStatus; import org.broadinstitute.consent.http.enumeration.ElectionType; import org.broadinstitute.consent.http.enumeration.EmailType; @@ -1132,16 +1134,18 @@ void testFindSummaryMetricApprovedDARsByDatasetIdIncludesExpired() { .orElseThrow(); assertTrue(expiredSummary.expired()); - // Approved collection summary is present, sourced from one of its submitted DARs, not expired + // Approved collection summary is present, sourced from one of its approved DARs. The closeout + // is not one of them, and having been filed it has already ended the grant's access. DarMetricsSummary approvedSummary = summaries.stream() .filter(s -> !expiredReferenceId.equals(s.referenceId())) .findFirst() .orElseThrow(); assertTrue( - List.of(approvedDAR.getReferenceId(), prDAR.getReferenceId(), closeoutDAR.getReferenceId()) + List.of(approvedDAR.getReferenceId(), prDAR.getReferenceId()) .contains(approvedSummary.referenceId())); - assertFalse(approvedSummary.expired()); + assertNotEquals(closeoutDAR.getReferenceId(), approvedSummary.referenceId()); + assertTrue(approvedSummary.expired()); } // A collection's most recently submitted DAR may target a different dataset than the one that @@ -1181,6 +1185,912 @@ void testFindSummaryMetricApprovedDARsByDatasetId_latestDarOnDifferentDataset() // The collection is still returned, sourced from the dataset-linked DAR, not the later one assertEquals(1, summaries.size()); assertEquals(approvedDAR.getReferenceId(), summaries.getFirst().referenceId()); + assertNotNull(summaries.getFirst().submissionDate()); + + // The requester's institution, as on the study route + assertNotNull(summaries.getFirst().institutionName()); + } + + /** The submitter's institution comes through on the study route; their name does not. */ + @Test + void testFindSummaryMetricApprovedDARsCarriesTheSubmittersInstitution() { + Dataset dataset = createDataset(); + User user = createUserWithInstitution(); + Date now = new Date(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + datasetDAO.updateStudyId(dataset.getDatasetId(), studyId); + Integer collectionId = + darCollectionDAO.insertDarCollection( + "DAR-" + randomInt(1, 10), user.getUserId(), new Date()); + + String referenceId = UUID.randomUUID().toString(); + DataAccessRequestData data = new DataAccessRequestData(); + data.setPiName("Recorded PI Name"); + dataAccessRequestDAO.insertDataAccessRequest( + collectionId, referenceId, user.getUserId(), now, now, now, data, randomAlphabetic(10)); + dataAccessRequestDAO.insertDARDatasetRelation(referenceId, dataset.getDatasetId()); + Election election = createDataAccessElection(referenceId, dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote(true, "", now, vote.getVoteId(), false, election.getElectionId(), now, false); + + List summaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + + assertEquals(1, summaries.size()); + assertEquals( + institutionDAO.findInstitutionById(user.getInstitutionId()).getName(), + summaries.getFirst().institutionName()); + } + + // The study-scoped query covers every dataset in the study in one round trip, and must agree + // with the per-dataset query it replaces. + @Test + void testFindSummaryMetricApprovedDARsByStudyId() { + User user = createUserWithInstitution(); + Date now = new Date(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + Dataset datasetOne = createDataset(); + Dataset datasetTwo = createDataset(); + datasetDAO.updateStudyId(datasetOne.getDatasetId(), studyId); + datasetDAO.updateStudyId(datasetTwo.getDatasetId(), studyId); + // A dataset outside the study, whose approved DAR must not appear + Dataset unrelatedDataset = createDataset(); + + approveDarForDataset(user, datasetOne, now); + approveDarForDataset(user, datasetTwo, now); + approveDarForDataset(user, unrelatedDataset, now); + // An unsubmitted draft on one of the study's datasets never sources a summary + DataAccessRequest draft = createDraftDAR(user); + dataAccessRequestDAO.insertDARDatasetRelation( + draft.getReferenceId(), datasetOne.getDatasetId()); + + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + List datasetOneSummaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + datasetOne.getDatasetId()); + List datasetTwoSummaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + datasetTwo.getDatasetId()); + + // One summary per qualifying collection across the whole study, and nothing from outside it + assertEquals(2, byStudy.size()); + assertEquals( + Stream.concat(datasetOneSummaries.stream(), datasetTwoSummaries.stream()) + .map(DarMetricsSummary::referenceId) + .collect(Collectors.toSet()), + byStudy.stream().map(DarMetricsSummary::referenceId).collect(Collectors.toSet())); + assertTrue( + byStudy.stream() + .map(DarMetricsSummary::referenceId) + .noneMatch(draft.getReferenceId()::equals)); + } + + /** Both routes report the same institution, and neither names the requester. */ + @Test + void testFindSummaryMetricApprovedDARsByDatasetIdCarriesRequesterInstitution() { + User user = createUserWithInstitution(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + Dataset dataset = createDataset(); + datasetDAO.updateStudyId(dataset.getDatasetId(), studyId); + approveDarForDataset(user, dataset, new Date()); + + List byDataset = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + dataset.getDatasetId()); + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + + assertFalse(byDataset.isEmpty()); + assertTrue(byDataset.stream().allMatch(s -> s.institutionName() != null)); + // The same grant read through the study route reports the same institution + assertFalse(byStudy.isEmpty()); + assertEquals(byStudy.getFirst().institutionName(), byDataset.getFirst().institutionName()); + } + + /** + * The section presents every row as a granted request, and the current/expired chip comes from + * the sourced DAR's submission date. A progress report is submitted and reviewed on its own + * election, so until that election approves it the collection's newest submission is not a grant: + * sourcing the display record from it would overwrite the grant's title and RUS and reset an + * expired grant to current. + */ + @Test + void testFindSummaryMetricApprovedDARsByStudyIdIgnoresALaterUnapprovedProgressReport() { + User user = createUserWithInstitution(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + Dataset dataset = createDataset(); + datasetDAO.updateStudyId(dataset.getDatasetId(), studyId); + Integer collectionId = createDarCollection(user.getUserId()); + + // A grant old enough that the chip should read "expired" + Date grantedOn = + new Date(System.currentTimeMillis() - DataAccessRequest.EXPIRATION_DURATION_MILLIS - 1000); + DataAccessRequest grantedDar = + createDataAccessRequest(collectionId, user.getUserId(), grantedOn); + dataAccessRequestDAO.insertDARDatasetRelation( + grantedDar.getReferenceId(), dataset.getDatasetId()); + Election election = + createDataAccessElection(grantedDar.getReferenceId(), dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote( + true, "", grantedOn, vote.getVoteId(), false, election.getElectionId(), grantedOn, false); + + // Submitted just now, in the same collection, and awaiting review + DataAccessRequest pendingReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, grantedDar.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + pendingReport.getReferenceId(), dataset.getDatasetId()); + + List summaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + + assertEquals(1, summaries.size()); + assertEquals(grantedDar.getReferenceId(), summaries.getFirst().referenceId()); + assertNotEquals(pendingReport.getReferenceId(), summaries.getFirst().referenceId()); + assertTrue(summaries.getFirst().expired()); + } + + /** + * The dataset route sourced its display record from anything submitted against the dataset, so a + * pending progress report stood in for the grant - overwriting the title and RUS, resetting an + * expired grant to current, and, once identity was added, naming the report's submitter as the + * PI. The study route already refused to do that; this asserts the dataset route now matches. + */ + @Test + void testFindSummaryMetricApprovedDARsByDatasetIdIgnoresALaterUnapprovedProgressReport() { + User user = createUserWithInstitution(); + Dataset dataset = createDataset(); + Integer collectionId = createDarCollection(user.getUserId()); + + Date grantedOn = + new Date(System.currentTimeMillis() - DataAccessRequest.EXPIRATION_DURATION_MILLIS - 1000); + DataAccessRequest grantedDar = + createDataAccessRequest(collectionId, user.getUserId(), grantedOn); + dataAccessRequestDAO.insertDARDatasetRelation( + grantedDar.getReferenceId(), dataset.getDatasetId()); + Election election = + createDataAccessElection(grantedDar.getReferenceId(), dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote( + true, "", grantedOn, vote.getVoteId(), false, election.getElectionId(), grantedOn, false); + + // Submitted just now, in the same collection, and awaiting review + DataAccessRequest pendingReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, grantedDar.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + pendingReport.getReferenceId(), dataset.getDatasetId()); + + List summaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + dataset.getDatasetId()); + + assertEquals(1, summaries.size()); + assertEquals(grantedDar.getReferenceId(), summaries.getFirst().referenceId()); + assertNotEquals(pendingReport.getReferenceId(), summaries.getFirst().referenceId()); + assertTrue(summaries.getFirst().expired()); + } + + /** + * A closeout carries no election of its own, so it is not a qualifying DAR and cannot source the + * display record. Before, it could: sourcing by date alone let a closeout submitted after the + * grant speak for it, showing the closeout's title, RUS and dates in place of the grant's. + */ + @Test + void testFindSummaryMetricApprovedDARsPrefersTheGrantOverALaterCloseout() { + User grantee = createUserWithInstitution(); + Dataset dataset = createDataset(); + Integer collectionId = createDarCollection(grantee.getUserId()); + + Date grantedOn = new Date(System.currentTimeMillis() - 60_000); + DataAccessRequest grantedDar = + createDataAccessRequest(collectionId, grantee.getUserId(), grantedOn); + dataAccessRequestDAO.insertDARDatasetRelation( + grantedDar.getReferenceId(), dataset.getDatasetId()); + Election election = + createDataAccessElection(grantedDar.getReferenceId(), dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote( + true, "", grantedOn, vote.getVoteId(), false, election.getElectionId(), grantedOn, false); + + // Submitted later than the grant, and never reviewed. The submitter is the same researcher: + // only the parent DAR's own user may file a progress report against it. + DataAccessRequest closeoutDar = + fileFollowOn(grantee, collectionId, grantedDar, List.of(dataset), 0, true); + + List summaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + dataset.getDatasetId()); + + assertEquals(1, summaries.size()); + assertEquals(grantedDar.getReferenceId(), summaries.getFirst().referenceId()); + assertNotEquals(closeoutDar.getReferenceId(), summaries.getFirst().referenceId()); + // The closeout inherits the grant's title and RUS, so the dates are what gives it away: a row + // sourced from the closeout would report when the grant ended, not when it was requested. + assertEquals(grantedOn.getTime(), summaries.getFirst().submissionDate().getTime()); + assertNotEquals( + closeoutDar.getSubmissionDate().getTime(), summaries.getFirst().submissionDate().getTime()); + } + + /** + * A closeout may only name datasets the grant was approved on, but approval is the last final + * vote, and a later election can take one back after the closeout is filed. The dataset is then + * named by a closeout while having no approval to show, and its page must stay empty. The study + * still sees the collection, because the study was granted access to another of its datasets. + */ + @Test + void testFindSummaryMetricApprovedDARsSkipsTheDatasetTheGrantWasDeniedOn() { + User grantee = createUserWithInstitution(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + grantee.getUserId(), + Instant.now(), + UUID.randomUUID()); + Dataset granted = createDataset(); + Dataset denied = createDataset(); + datasetDAO.updateStudyId(granted.getDatasetId(), studyId); + datasetDAO.updateStudyId(denied.getDatasetId(), studyId); + + Date grantedOn = new Date(System.currentTimeMillis() - 60_000); + Integer collectionId = createDarCollection(grantee.getUserId()); + DataAccessRequest dar = createDataAccessRequest(collectionId, grantee.getUserId(), grantedOn); + dataAccessRequestDAO.insertDARDatasetRelation(dar.getReferenceId(), granted.getDatasetId()); + dataAccessRequestDAO.insertDARDatasetRelation(dar.getReferenceId(), denied.getDatasetId()); + + Election approval = createDataAccessElection(dar.getReferenceId(), granted.getDatasetId()); + Vote approvingVote = createFinalVote(granted.getCreateUserId(), approval.getElectionId()); + updateVote( + true, + "", + grantedOn, + approvingVote.getVoteId(), + false, + approval.getElectionId(), + grantedOn, + false); + + Election firstReview = createDataAccessElection(dar.getReferenceId(), denied.getDatasetId()); + Vote firstVote = createFinalVote(denied.getCreateUserId(), firstReview.getElectionId()); + updateVote( + true, + "", + grantedOn, + firstVote.getVoteId(), + false, + firstReview.getElectionId(), + grantedOn, + false); + + // Both datasets are approved, so both may be closed out + fileFollowOn(grantee, collectionId, dar, List.of(granted, denied), 30_000, true); + + // A later election takes the second dataset back. Nothing clears the closeout's claim on it: + // dar_dataset still names it, and a chairperson can open an election on a collection whether or + // not it has been closed out. + Date revokedOn = new Date(); + Election secondReview = createDataAccessElection(dar.getReferenceId(), denied.getDatasetId()); + Vote revokingVote = createFinalVote(denied.getCreateUserId(), secondReview.getElectionId()); + updateVote( + false, + "", + revokedOn, + revokingVote.getVoteId(), + false, + secondReview.getElectionId(), + revokedOn, + false); + + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(denied.getDatasetId()) + .isEmpty(), + "A closeout must not stand in for an approval that was taken back"); + + List onGranted = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + granted.getDatasetId()); + assertEquals(1, onGranted.size()); + assertEquals(dar.getReferenceId(), onGranted.getFirst().referenceId()); + + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + assertEquals(1, byStudy.size()); + assertEquals(dar.getReferenceId(), byStudy.getFirst().referenceId()); + } + + /** + * One DAR spanning two studies, two datasets each. Approval is per dataset, so the same request + * can be a grant on one of a study's datasets and nothing at all on the other. Nothing shows + * anywhere until a final vote lands; afterwards each study shows the DAR because each granted one + * of its datasets, while on the dataset pages only the two that were approved list it. The two + * that were not are excluded for different reasons - one was voted down, the other is still under + * review - and neither may borrow its sibling's approval. + * + *

A progress report and then a closeout are filed on the approved datasets. Both end or report + * on access rather than undoing it, so these remain usage history and every expectation has to + * hold unchanged after each one, still naming the grant rather than the follow-on. + */ + @Test + void testFindSummaryMetricApprovedDARsSpanningTwoStudiesFollowsPerDatasetApproval() { + User requester = createUserWithInstitution(); + Integer firstStudyId = createStudy(requester); + Integer secondStudyId = createStudy(requester); + + Dataset firstGranted = createStudyDataset(firstStudyId); + Dataset firstDenied = createStudyDataset(firstStudyId); + Dataset secondGranted = createStudyDataset(secondStudyId); + Dataset secondPending = createStudyDataset(secondStudyId); + List allDatasets = List.of(firstGranted, firstDenied, secondGranted, secondPending); + + Integer collectionId = createDarCollection(requester.getUserId()); + Date submittedOn = new Date(System.currentTimeMillis() - 60_000); + DataAccessRequest dar = + createDataAccessRequest(collectionId, requester.getUserId(), submittedOn); + allDatasets.forEach( + dataset -> + dataAccessRequestDAO.insertDARDatasetRelation( + dar.getReferenceId(), dataset.getDatasetId())); + + // Under review on every dataset it asks for, decided on none + Election firstApproval = + createDataAccessElection(dar.getReferenceId(), firstGranted.getDatasetId()); + Election firstRejection = + createDataAccessElection(dar.getReferenceId(), firstDenied.getDatasetId()); + Election secondApproval = + createDataAccessElection(dar.getReferenceId(), secondGranted.getDatasetId()); + createDataAccessElection(dar.getReferenceId(), secondPending.getDatasetId()); + + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByStudyIdIncludesExpired(firstStudyId) + .isEmpty(), + "A study shows nothing for a DAR that has not been granted any of its datasets"); + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByStudyIdIncludesExpired(secondStudyId) + .isEmpty(), + "A study shows nothing for a DAR that has not been granted any of its datasets"); + allDatasets.forEach( + dataset -> + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(dataset.getDatasetId()) + .isEmpty(), + "A dataset shows nothing for a DAR still awaiting its election")); + + approve(firstApproval, firstGranted, submittedOn); + approve(secondApproval, secondGranted, submittedOn); + reject(firstRejection, firstDenied, submittedOn); + // secondPending keeps an open election and no final vote + + assertGrantVisibleWhereApproved( + firstStudyId, + secondStudyId, + firstGranted, + secondGranted, + firstDenied, + secondPending, + dar, + requester, + "once the votes land"); + + // Follow-on submissions arrive one at a time, each later than the grant. A progress report is + // reviewed on its own election; a closeout ends access rather than undoing it. Neither erases + // the fact that access was given, so every expectation above has to survive both, with the + // summaries still naming the grant rather than the follow-on. Only the parent DAR's own + // researcher may file one, and each hangs off the previous submission, since uk_parent_id + // allows a DAR only one child. + DataAccessRequest progressReport = + fileFollowOn( + requester, collectionId, dar, List.of(firstGranted, secondGranted), 30_000, false); + assertGrantVisibleWhereApproved( + firstStudyId, + secondStudyId, + firstGranted, + secondGranted, + firstDenied, + secondPending, + dar, + requester, + "after a progress report is submitted"); + + // Only the approved datasets can be closed out, and only once: createProgressReport rejects a + // dataset the parent was not granted, and findDatasetApprovalsByDar returns nothing for a + // collection that already holds a closeout. + fileFollowOn( + requester, + collectionId, + progressReport, + List.of(firstGranted, secondGranted), + 20_000, + true); + assertGrantVisibleWhereApproved( + firstStudyId, + secondStudyId, + firstGranted, + secondGranted, + firstDenied, + secondPending, + dar, + requester, + "after the grant is closed out"); + } + + private void assertGrantVisibleWhereApproved( + Integer firstStudyId, + Integer secondStudyId, + Dataset firstGranted, + Dataset secondGranted, + Dataset firstDenied, + Dataset secondPending, + DataAccessRequest dar, + User requester, + String when) { + assertSourcesTheDar( + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(firstStudyId), + dar, + requester, + "The study granted one of its datasets, so the DAR is part of its usage history " + when); + assertSourcesTheDar( + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(secondStudyId), + dar, + requester, + "The study granted one of its datasets, so the DAR is part of its usage history " + when); + + assertSourcesTheDar( + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + firstGranted.getDatasetId()), + dar, + requester, + "The approved dataset lists the grant it gave " + when); + assertSourcesTheDar( + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + secondGranted.getDatasetId()), + dar, + requester, + "The approved dataset lists the grant it gave " + when); + + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(firstDenied.getDatasetId()) + .isEmpty(), + "A dataset the DAR was voted down on must not borrow its sibling's approval " + when); + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(secondPending.getDatasetId()) + .isEmpty(), + "A dataset whose election is still open must not borrow its sibling's approval " + when); + } + + /** + * Files a progress report, closeout or not, on some of the parent DAR's datasets, submitted + * {@code agoMillis} ago, and returns it so a further report can hang off it in turn. + */ + private DataAccessRequest fileFollowOn( + User researcher, + Integer collectionId, + DataAccessRequest parent, + List covered, + long agoMillis, + boolean closeout) { + DataAccessRequest closeoutDar = + createProgressReport( + researcher.getEraCommonsId(), researcher.getUserId(), collectionId, parent.getId()); + // populateProgressReportFromJsonString starts from a copy of the parent's data and overrides + // only the fields the researcher fills in again, so the narrative carries over unchanged + closeoutDar.getData().setProjectTitle(parent.getData().getProjectTitle()); + closeoutDar.getData().setNonTechRus(parent.getData().getNonTechRus()); + covered.forEach( + dataset -> + dataAccessRequestDAO.insertDARDatasetRelation( + closeoutDar.getReferenceId(), dataset.getDatasetId())); + if (closeout) { + closeoutDar + .getData() + .setCloseoutSupplement( + new CloseoutSupplement(List.of("Reason"), "Other Reason", researcher.getUserId())); + } + Date closedOn = new Date(System.currentTimeMillis() - agoMillis); + dataAccessRequestDAO.updateDataByReferenceId( + closeoutDar.getReferenceId(), + researcher.getUserId(), + closedOn, + closedOn, + closeoutDar.getData(), + randomAlphabetic(10)); + return dataAccessRequestDAO.findByReferenceId(closeoutDar.getReferenceId()); + } + + /** + * The row spans the collection's whole history on a dataset: it is submitted on the day access + * began, and it lapses on the day access ended. Those are different DARs. A renewal restarts the + * term without changing when the work started, and a closeout ends the grant on the day it is + * filed even though the renewal it cut short still had time to run. The row stays either way - + * these queries report how a dataset has been used, they do not decide who may reach it. + */ + @Test + void testFindSummaryMetricApprovedDARsSpansFromFirstGrantToLastExpiry() { + User requester = createUserWithInstitution(); + Integer studyId = createStudy(requester); + Dataset dataset = createStudyDataset(studyId); + + long day = TimeUnit.DAYS.toMillis(1); + Date firstGrantedOn = new Date(System.currentTimeMillis() - 400 * day); + Integer collectionId = createDarCollection(requester.getUserId()); + DataAccessRequest grant = + createDataAccessRequest(collectionId, requester.getUserId(), firstGrantedOn); + dataAccessRequestDAO.insertDARDatasetRelation(grant.getReferenceId(), dataset.getDatasetId()); + approve( + createDataAccessElection(grant.getReferenceId(), dataset.getDatasetId()), + dataset, + firstGrantedOn); + + // On its own the first grant's term has run out + assertTrue( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(dataset.getDatasetId()) + .getFirst() + .expired()); + + // A renewal is approved, restarting the term + DataAccessRequest renewal = + fileFollowOn(requester, collectionId, grant, List.of(dataset), 10 * day, false); + approve( + createDataAccessElection(renewal.getReferenceId(), dataset.getDatasetId()), + dataset, + renewal.getSubmissionDate()); + + DarMetricsSummary renewed = + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(dataset.getDatasetId()) + .getFirst(); + assertEquals( + firstGrantedOn.getTime(), + renewed.submissionDate().getTime(), + "The row is submitted on the day access began, not the day it was last renewed"); + assertFalse(renewed.expired(), "The renewal's term governs, and it has not run out"); + + // The closeout ends the grant early, and the collection still belongs in the history + fileFollowOn(requester, collectionId, renewal, List.of(dataset), 0, true); + + List closedOut = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + dataset.getDatasetId()); + assertEquals(1, closedOut.size(), "A closed-out grant is still part of the dataset's history"); + assertEquals( + firstGrantedOn.getTime(), + closedOut.getFirst().submissionDate().getTime(), + "Closing out does not change when access began"); + assertTrue( + closedOut.getFirst().expired(), + "The closeout ended access, though the renewal it cut short had time left"); + + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + assertEquals(1, byStudy.size(), "The study keeps the closed-out grant in its history too"); + assertEquals(firstGrantedOn.getTime(), byStudy.getFirst().submissionDate().getTime()); + assertTrue(byStudy.getFirst().expired()); + } + + /** + * A closeout ends the collection's grant outright, so every dataset it reached is closed even + * when the closeout's own report names only some of them. That matches how + * findApprovedDARsByDatasetId and its neighbours already treat a closeout - they drop the whole + * collection the moment one exists. + */ + @Test + void testFindSummaryMetricApprovedDARsClosesOutEveryDatasetInTheCollection() { + User requester = createUserWithInstitution(); + Integer studyId = createStudy(requester); + Dataset namedByTheCloseout = createStudyDataset(studyId); + Dataset leftOutOfTheCloseout = createStudyDataset(studyId); + + Date grantedOn = new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)); + Integer collectionId = createDarCollection(requester.getUserId()); + DataAccessRequest grant = + createDataAccessRequest(collectionId, requester.getUserId(), grantedOn); + dataAccessRequestDAO.insertDARDatasetRelation( + grant.getReferenceId(), namedByTheCloseout.getDatasetId()); + dataAccessRequestDAO.insertDARDatasetRelation( + grant.getReferenceId(), leftOutOfTheCloseout.getDatasetId()); + approve( + createDataAccessElection(grant.getReferenceId(), namedByTheCloseout.getDatasetId()), + namedByTheCloseout, + grantedOn); + approve( + createDataAccessElection(grant.getReferenceId(), leftOutOfTheCloseout.getDatasetId()), + leftOutOfTheCloseout, + grantedOn); + + // The closeout's own report names one of the two datasets + fileFollowOn(requester, collectionId, grant, List.of(namedByTheCloseout), 0, true); + + List onNamed = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + namedByTheCloseout.getDatasetId()); + assertEquals(1, onNamed.size(), "A closed-out grant stays in the dataset's history"); + assertTrue(onNamed.getFirst().expired(), "The closeout ended this dataset's grant"); + + List onLeftOut = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired( + leftOutOfTheCloseout.getDatasetId()); + assertEquals(1, onLeftOut.size(), "A closed-out grant stays in this dataset's history too"); + assertTrue( + onLeftOut.getFirst().expired(), + "The closeout ends the whole collection, including datasets its report left out"); + + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + assertEquals(1, byStudy.size()); + assertTrue(byStudy.getFirst().expired(), "The study's grant is closed out along with it"); + } + + /** + * The study list is newest first, and the date it sorts by has to be the date it shows. A + * collection whose grant is old but recently renewed is older, for this purpose, than one granted + * last month and never renewed - the cards carry the first submission, so that is what orders + * them. StudyDarHistory renders the list as it arrives and never re-sorts. + */ + @Test + void testFindSummaryMetricApprovedDARsByStudyIdOrdersByTheDateItShows() { + User requester = createUserWithInstitution(); + Integer studyId = createStudy(requester); + Dataset dataset = createStudyDataset(studyId); + long day = TimeUnit.DAYS.toMillis(1); + + // Granted long ago, renewed recently + Date oldGrantOn = new Date(System.currentTimeMillis() - 400 * day); + Integer renewedCollection = createDarCollection(requester.getUserId()); + DataAccessRequest oldGrant = + createDataAccessRequest(renewedCollection, requester.getUserId(), oldGrantOn); + dataAccessRequestDAO.insertDARDatasetRelation( + oldGrant.getReferenceId(), dataset.getDatasetId()); + approve( + createDataAccessElection(oldGrant.getReferenceId(), dataset.getDatasetId()), + dataset, + oldGrantOn); + DataAccessRequest renewal = + fileFollowOn(requester, renewedCollection, oldGrant, List.of(dataset), 5 * day, false); + approve( + createDataAccessElection(renewal.getReferenceId(), dataset.getDatasetId()), + dataset, + renewal.getSubmissionDate()); + + // Granted more recently, never renewed + Date recentGrantOn = new Date(System.currentTimeMillis() - 100 * day); + Integer freshCollection = createDarCollection(requester.getUserId()); + DataAccessRequest recentGrant = + createDataAccessRequest(freshCollection, requester.getUserId(), recentGrantOn); + dataAccessRequestDAO.insertDARDatasetRelation( + recentGrant.getReferenceId(), dataset.getDatasetId()); + approve( + createDataAccessElection(recentGrant.getReferenceId(), dataset.getDatasetId()), + dataset, + recentGrantOn); + + List byStudy = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + + assertEquals(2, byStudy.size()); + assertEquals( + recentGrantOn.getTime(), + byStudy.getFirst().submissionDate().getTime(), + "The collection granted most recently comes first"); + assertEquals( + oldGrantOn.getTime(), + byStudy.getLast().submissionDate().getTime(), + "A renewal does not move an old grant to the top of the list"); + } + + private void assertSourcesTheDar( + List summaries, DataAccessRequest dar, User requester, String message) { + assertEquals(1, summaries.size(), message); + assertEquals(dar.getReferenceId(), summaries.getFirst().referenceId(), message); + assertEquals( + institutionDAO.findInstitutionById(requester.getInstitutionId()).getName(), + summaries.getFirst().institutionName(), + message); + } + + /** + * The research use statement and the non-technical summary are different texts, and each query + * returns both. Distinct values, so a query that selected the same key twice or swapped the two + * columns would fail here. + */ + @Test + void testFindSummaryMetricApprovedDARsReturnsTheRusAndTheNonTechnicalSummary() { + User requester = createUserWithInstitution(); + Integer studyId = createStudy(requester); + Dataset dataset = createStudyDataset(studyId); + Date grantedOn = new Date(System.currentTimeMillis() - 60_000); + Integer collectionId = createDarCollection(requester.getUserId()); + DataAccessRequest dar = createDataAccessRequest(collectionId, requester.getUserId(), grantedOn); + DataAccessRequestData data = dar.getData(); + data.setRus("The technical account of how the data will be used"); + data.setNonTechRus("A lay description of the research"); + dataAccessRequestDAO.updateDataByReferenceId( + dar.getReferenceId(), requester.getUserId(), grantedOn, grantedOn, data, null); + dataAccessRequestDAO.insertDARDatasetRelation(dar.getReferenceId(), dataset.getDatasetId()); + approve( + createDataAccessElection(dar.getReferenceId(), dataset.getDatasetId()), dataset, grantedOn); + + for (DarMetricsSummary summary : + List.of( + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(dataset.getDatasetId()) + .getFirst(), + dataAccessRequestDAO + .findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId) + .getFirst())) { + assertEquals("The technical account of how the data will be used", summary.rus()); + assertEquals("A lay description of the research", summary.nonTechRus()); + } + } + + private Integer createStudy(User creator) { + return studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + creator.getUserId(), + Instant.now(), + UUID.randomUUID()); + } + + private Dataset createStudyDataset(Integer studyId) { + Dataset dataset = createDataset(); + datasetDAO.updateStudyId(dataset.getDatasetId(), studyId); + return dataset; + } + + private void approve(Election election, Dataset dataset, Date decidedOn) { + castFinalVote(election, dataset, decidedOn, true); + } + + private void reject(Election election, Dataset dataset, Date decidedOn) { + castFinalVote(election, dataset, decidedOn, false); + } + + private void castFinalVote(Election election, Dataset dataset, Date decidedOn, boolean approved) { + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote( + approved, + "", + decidedOn, + vote.getVoteId(), + false, + election.getElectionId(), + decidedOn, + false); + } + + private void approveDarForDataset(User user, Dataset dataset, Date now) { + Integer collectionId = + darCollectionDAO.insertDarCollection( + "DAR-" + randomAlphabetic(10), user.getUserId(), new Date()); + DataAccessRequest dar = createDataAccessRequest(collectionId, user.getUserId(), now); + dataAccessRequestDAO.insertDARDatasetRelation(dar.getReferenceId(), dataset.getDatasetId()); + Election election = createDataAccessElection(dar.getReferenceId(), dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote(true, "", now, vote.getVoteId(), false, election.getElectionId(), now, false); + } + + @Test + void testFindProgressReportsByDatasetIdAndStudyId() { + User user = createUserWithInstitution(); + Dataset dataset = createDataset(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + datasetDAO.updateStudyId(dataset.getDatasetId(), studyId); + Integer collectionId = + darCollectionDAO.insertDarCollection( + "DAR-" + randomInt(1, 999999999), user.getUserId(), new Date()); + + // The originating DAR has no parent, so it is never a progress report + DataAccessRequest parentDar = createDataAccessRequest(user.getUserId(), collectionId); + dataAccessRequestDAO.insertDARDatasetRelation( + parentDar.getReferenceId(), dataset.getDatasetId()); + + DataAccessRequest olderReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, parentDar.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + olderReport.getReferenceId(), dataset.getDatasetId()); + // Reports chain: each DAR may only parent a single progress report + DataAccessRequest newerReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, olderReport.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + newerReport.getReferenceId(), dataset.getDatasetId()); + + // Archived progress reports are excluded + DataAccessRequest archivedReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, newerReport.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + archivedReport.getReferenceId(), dataset.getDatasetId()); + dataAccessRequestDAO.archiveByReferenceIds(List.of(archivedReport.getReferenceId())); + + // Progress reports on an unrelated dataset/study are excluded + Dataset otherDataset = createDataset(); + DataAccessRequest otherReport = + createProgressReport( + user.getEraCommonsId(), user.getUserId(), collectionId, archivedReport.getId()); + dataAccessRequestDAO.insertDARDatasetRelation( + otherReport.getReferenceId(), otherDataset.getDatasetId()); + + List byDataset = + dataAccessRequestDAO.findProgressReportsByDatasetId(dataset.getDatasetId()); + List byStudy = dataAccessRequestDAO.findProgressReportsByStudyId(studyId); + + // Both queries return only the two live reports, most recent first + assertEquals( + List.of(newerReport.getReferenceId(), olderReport.getReferenceId()), + byDataset.stream().map(DataAccessRequest::getReferenceId).toList()); + assertEquals( + List.of(newerReport.getReferenceId(), olderReport.getReferenceId()), + byStudy.stream().map(DataAccessRequest::getReferenceId).toList()); + assertTrue(byStudy.stream().allMatch(dar -> dar.getParentId() != null)); } // findAllDraftDataAccessRequests should exclude archived DARs @@ -1619,6 +2529,62 @@ private static DataAccessRequest createDataAccessRequest( return dataAccessRequestDAO.findByReferenceId(referenceId); } + /** + * The study query prunes to the study's datasets inside the window over elections and votes, so + * the partition it sorts is bounded by the study rather than by the whole table. The partition + * was already per dataset, so this must not change what comes back: a vote cast on a dataset + * outside the study still has no bearing on one inside it, in either direction. + */ + @Test + void testFindSummaryMetricApprovedDARsByStudyIdIsUnaffectedByVotesOnOtherStudiesDatasets() { + User user = createUserWithInstitution(); + Date now = new Date(); + Integer studyId = + studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + Dataset inStudy = createDataset(); + Dataset outsideStudy = createDataset(); + datasetDAO.updateStudyId(inStudy.getDatasetId(), studyId); + + // Approved on the study's dataset, denied later on one outside it + Integer grantedCollection = createDarCollection(user.getUserId()); + DataAccessRequest granted = createDataAccessRequest(user.getUserId(), grantedCollection); + dataAccessRequestDAO.insertDARDatasetRelation(granted.getReferenceId(), inStudy.getDatasetId()); + dataAccessRequestDAO.insertDARDatasetRelation( + granted.getReferenceId(), outsideStudy.getDatasetId()); + castFinalVote(granted.getReferenceId(), inStudy, now, true); + castFinalVote(granted.getReferenceId(), outsideStudy, new Date(), false); + + // Approved only outside the study, and denied on the study's dataset + Integer deniedCollection = createDarCollection(user.getUserId()); + DataAccessRequest denied = createDataAccessRequest(user.getUserId(), deniedCollection); + dataAccessRequestDAO.insertDARDatasetRelation(denied.getReferenceId(), inStudy.getDatasetId()); + dataAccessRequestDAO.insertDARDatasetRelation( + denied.getReferenceId(), outsideStudy.getDatasetId()); + castFinalVote(denied.getReferenceId(), inStudy, now, false); + castFinalVote(denied.getReferenceId(), outsideStudy, new Date(), true); + + List summaries = + dataAccessRequestDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(studyId); + + assertEquals(1, summaries.size(), "only the DAR approved on the study's own dataset"); + assertEquals(granted.getReferenceId(), summaries.getFirst().referenceId()); + } + + private void castFinalVote(String referenceId, Dataset dataset, Date on, boolean approved) { + Election election = createDataAccessElection(referenceId, dataset.getDatasetId()); + Vote vote = createFinalVote(dataset.getCreateUserId(), election.getElectionId()); + updateVote(approved, "", on, vote.getVoteId(), false, election.getElectionId(), on, false); + } + private Integer createDarCollection(Integer createUserId) { String darCode = randomAlphabetic(20); return darCollectionDAO.insertDarCollection(darCode, createUserId, new Date()); diff --git a/src/test/java/org/broadinstitute/consent/http/db/DatasetDAOTest.java b/src/test/java/org/broadinstitute/consent/http/db/DatasetDAOTest.java index 568549d28c..2b9bd7378d 100644 --- a/src/test/java/org/broadinstitute/consent/http/db/DatasetDAOTest.java +++ b/src/test/java/org/broadinstitute/consent/http/db/DatasetDAOTest.java @@ -49,6 +49,7 @@ import org.broadinstitute.consent.http.models.DatasetStudySummary; import org.broadinstitute.consent.http.models.Dictionary; import org.broadinstitute.consent.http.models.FileStorageObject; +import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.rules.DACAutomationRule; @@ -307,6 +308,10 @@ void testFindDatasetByIdPopulatesStudyUpdateUser() { study.getDescription(), study.getPiName(), study.getPiEmail(), + null, + null, + null, + null, study.getDataTypes(), study.getPublicVisibility(), updateUser.getUserId(), @@ -1755,6 +1760,47 @@ private Study insertStudyWithProperties() { return insertStudyWithProperties(u); } + /** + * The dataset lookup path carries the PI's profile links and institution. + * + *

Dataset.yaml reuses Study.yaml and documents these fields, but findDatasetStudyById selects + * the study's columns explicitly rather than with s.*, so a column it omits reads as null on this + * route even when stored. The links use the s_ prefix the Study bean mapper is registered with; + * the institution columns are read unprefixed by StudyReducer. + */ + @Test + void testFindDatasetByIdCarriesThePiDetailsFromTheStudy() { + User user = createUserWithInstitution(); + Institution institution = institutionDAO.findInstitutionById(user.getInstitutionId()); + Study study = insertStudyWithProperties(user); + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + institution.getId(), + "0000-0002-1825-0097", + "https://linkedin.com/in/example", + "https://example.org", + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + Dataset dataset = createDataset(true); + datasetDAO.updateStudyId(dataset.getDatasetId(), study.getStudyId()); + + Study found = datasetDAO.findDatasetById(dataset.getDatasetId()).getStudy(); + + assertNotNull(found); + assertEquals("0000-0002-1825-0097", found.getPiOrcid()); + assertEquals("https://linkedin.com/in/example", found.getPiLinkedinUrl()); + assertEquals("https://example.org", found.getPiWebsiteUrl()); + assertNotNull(found.getPiInstitution()); + assertEquals(institution.getId(), found.getPiInstitution().getId()); + assertEquals(institution.getName(), found.getPiInstitution().getName()); + } + private Study insertStudyWithProperties(User user) { String name = randomAlphabetic(20); diff --git a/src/test/java/org/broadinstitute/consent/http/db/StudyAssetsMigrationTest.java b/src/test/java/org/broadinstitute/consent/http/db/StudyAssetsMigrationTest.java new file mode 100644 index 0000000000..43b0c1400c --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/db/StudyAssetsMigrationTest.java @@ -0,0 +1,239 @@ +package org.broadinstitute.consent.http.db; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.broadinstitute.consent.http.enumeration.PropertyType; +import org.broadinstitute.consent.http.models.StudyAssets; +import org.broadinstitute.consent.http.models.User; +import org.junit.jupiter.api.Test; + +/** + * Exercises the data migration in changelog-consent-2026-09-02-study-assets.xml, which splits the + * legacy client-managed `assets` object into one study_property row per promoted asset type. + * + *

Liquibase runs the changeset against an empty schema at test startup, which proves the SQL + * parses but never exercises it against data. This test reads the SQL out of the shipped changeset + * and runs it over legacy-shaped rows, so the test cannot drift from what actually ships. + */ +class StudyAssetsMigrationTest extends DAOTestHelper { + + private static final Path CHANGESET = + Path.of("src/main/resources/changesets/changelog-consent-2026-09-02-study-assets.xml"); + + @Test + void testPromotesAssetListsAndLeavesUnpromotedKeysBehind() throws Exception { + Integer studyId = insertStudy(); + insertProperty( + studyId, + StudyAssets.ASSETS, + """ + {"models": [{"modelId": "m-1"}, {"modelId": "m-2"}], + "publications": [{"title": "A publication"}], + "workspaces": [], + "uiLabels": {"tab": "Assets"}} + """); + + runMigration(); + + // Promoted keys become their own rows, preserving the stored array + assertEquals( + JsonParser.parseString("[{\"modelId\": \"m-1\"}, {\"modelId\": \"m-2\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.MODELS).orElseThrow())); + assertEquals( + JsonParser.parseString("[{\"title\": \"A publication\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.PUBLICATIONS).orElseThrow())); + + // An empty list and an absent key produce no row + assertTrue(property(studyId, StudyAssets.WORKSPACES).isEmpty()); + assertTrue(property(studyId, StudyAssets.BIOSPECIMENS).isEmpty()); + + // The legacy object survives, holding only what was not promoted. The empty workspaces list + // stays with it: a key is stripped only once its value is stored elsewhere, and an empty list + // is never promoted. Nothing reads it either way - StudyAssets reports no assets of a type + // whose only value is an empty list, from the promoted row or the legacy object alike. + assertEquals( + JsonParser.parseString("{\"uiLabels\": {\"tab\": \"Assets\"}, \"workspaces\": []}"), + JsonParser.parseString(property(studyId, StudyAssets.ASSETS).orElseThrow())); + } + + @Test + void testDropsTheLegacyObjectWhenNothingIsLeft() throws Exception { + Integer studyId = insertStudy(); + insertProperty(studyId, StudyAssets.ASSETS, "{\"funding\": [{\"grant\": \"R01\"}]}"); + + runMigration(); + + assertEquals( + JsonParser.parseString("[{\"grant\": \"R01\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.FUNDING).orElseThrow())); + assertTrue(property(studyId, StudyAssets.ASSETS).isEmpty()); + } + + @Test + void testLeavesAlreadyPromotedAndNonObjectValuesAlone() throws Exception { + Integer studyId = insertStudy(); + insertProperty(studyId, StudyAssets.ASSETS, "{\"models\": [{\"modelId\": \"from-assets\"}]}"); + insertProperty(studyId, StudyAssets.MODELS, "[{\"modelId\": \"already-promoted\"}]"); + + Integer nonObjectStudyId = insertStudy(); + insertProperty(nonObjectStudyId, StudyAssets.ASSETS, "\"not an object\""); + + runMigration(); + + // A study already carrying a promoted row keeps it, and gains no duplicate + assertEquals( + JsonParser.parseString("[{\"modelId\": \"already-promoted\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.MODELS).orElseThrow())); + assertEquals(1, propertyCount(studyId, StudyAssets.MODELS)); + + // A value that is not a JSON object is skipped rather than failing the migration + assertEquals("\"not an object\"", property(nonObjectStudyId, StudyAssets.ASSETS).orElseThrow()); + } + + /** + * A String-typed study property is bare text, not JSON. Postgres does not promise to apply `key = + * 'assets'` before a cast sitting in the same qual list, so casting every row's value would raise + * "invalid input syntax for type json" on one of these and abort the whole migration - taking + * every valid study's promotion with it. + */ + @Test + void testSurvivesStudyPropertiesWhoseValueIsNotJson() throws Exception { + Integer bystanderId = insertStudy(); + // The shapes the String-typed registration properties actually store + studyDAO.insertStudyProperty( + bystanderId, "phenotypeIndication", PropertyType.String.toString(), "Cancer"); + studyDAO.insertStudyProperty( + bystanderId, "species", PropertyType.String.toString(), "Homo sapiens"); + studyDAO.insertStudyProperty( + bystanderId, "dbGaPPhsID", PropertyType.String.toString(), "phs000123"); + + Integer studyId = insertStudy(); + insertProperty(studyId, StudyAssets.ASSETS, "{\"models\": [{\"modelId\": \"m1\"}]}"); + + runMigration(); + + // The migration completed, so the valid study still promoted + assertEquals( + JsonParser.parseString("[{\"modelId\": \"m1\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.MODELS).orElseThrow())); + // ...and the non-JSON neighbours are untouched + assertEquals("Cancer", property(bystanderId, "phenotypeIndication").orElseThrow()); + assertEquals("Homo sapiens", property(bystanderId, "species").orElseThrow()); + } + + /** + * The `assets` object was declared unvalidated, so a promoted-name key is free to hold something + * that is not a list. Promotion skips such a value, so stripping the key would delete the only + * copy of it. It has to survive, while its well-shaped siblings still promote and strip. + */ + @Test + void testRetainsPromotedNameKeysWhoseValueIsNotAList() throws Exception { + Integer studyId = insertStudy(); + insertProperty( + studyId, + StudyAssets.ASSETS, + """ + {"models": "custom-value", + "biospecimens": {"note": "an object, not a list"}, + "funding": 7, + "workspaces": [{"workspaceId": "ws-1"}], + "uiLabel": "keep me"} + """); + + runMigration(); + + // The well-shaped list is promoted out and stripped from the legacy object + assertEquals( + JsonParser.parseString("[{\"workspaceId\": \"ws-1\"}]"), + JsonParser.parseString(property(studyId, StudyAssets.WORKSPACES).orElseThrow())); + + // Every wrong-shaped value is left exactly where it was, alongside the unpromoted key + assertEquals( + JsonParser.parseString( + """ + {"models": "custom-value", + "biospecimens": {"note": "an object, not a list"}, + "funding": 7, + "uiLabel": "keep me"} + """), + JsonParser.parseString(property(studyId, StudyAssets.ASSETS).orElseThrow())); + + // ...and none of them was promoted to a first-class property + assertEquals(0, propertyCount(studyId, StudyAssets.MODELS)); + assertEquals(0, propertyCount(studyId, StudyAssets.BIOSPECIMENS)); + assertEquals(0, propertyCount(studyId, StudyAssets.FUNDING)); + } + + @Test + void testIsIdempotent() throws Exception { + Integer studyId = insertStudy(); + insertProperty(studyId, StudyAssets.ASSETS, "{\"models\": [{\"modelId\": \"m-1\"}]}"); + + runMigration(); + runMigration(); + + assertEquals(1, propertyCount(studyId, StudyAssets.MODELS)); + assertTrue(property(studyId, StudyAssets.ASSETS).isEmpty()); + } + + /** Runs every <sql> block from the shipped changeset, in order. */ + private void runMigration() throws Exception { + String changeset = Files.readString(CHANGESET); + Matcher matcher = Pattern.compile("(.*?)", Pattern.DOTALL).matcher(changeset); + List blocks = matcher.results().map(result -> result.group(1)).toList(); + assertEquals(2, blocks.size(), "changeset should ship two blocks"); + jdbi.useHandle(handle -> blocks.forEach(block -> handle.createScript(block.trim()).execute())); + } + + private Integer insertStudy() { + User user = createUser(); + return studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + } + + private void insertProperty(Integer studyId, String key, String value) { + studyDAO.insertStudyProperty(studyId, key, PropertyType.Json.toString(), value); + } + + private Optional property(Integer studyId, String key) { + return jdbi.withHandle( + handle -> + handle + .createQuery( + "SELECT value FROM study_property WHERE study_id = :studyId AND key = :key") + .bind("studyId", studyId) + .bind("key", key) + .mapTo(String.class) + .findFirst()); + } + + private int propertyCount(Integer studyId, String key) { + return jdbi.withHandle( + handle -> + handle + .createQuery( + "SELECT COUNT(*) FROM study_property WHERE study_id = :studyId AND key = :key") + .bind("studyId", studyId) + .bind("key", key) + .mapTo(Integer.class) + .one()); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/db/StudyCommentDAOTest.java b/src/test/java/org/broadinstitute/consent/http/db/StudyCommentDAOTest.java new file mode 100644 index 0000000000..2cb429c6bd --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/db/StudyCommentDAOTest.java @@ -0,0 +1,262 @@ +package org.broadinstitute.consent.http.db; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.broadinstitute.consent.http.models.StudyComment; +import org.broadinstitute.consent.http.models.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StudyCommentDAOTest extends DAOTestHelper { + + private StudyCommentDAO studyCommentDAO; + + @BeforeEach + void setUpDao() { + studyCommentDAO = jdbi.onDemand(StudyCommentDAO.class); + } + + @Test + void testUpsertInsertsAndReturnsId() { + Integer studyId = insertStudy(); + User user = createUserWithInstitution(); + + Integer commentId = studyCommentDAO.upsert(studyId, user.getUserId(), 4, "Great study"); + + assertNotNull(commentId); + List comments = studyCommentDAO.findByStudyId(studyId, 100, 0); + assertEquals(1, comments.size()); + StudyComment comment = comments.getFirst(); + assertEquals(commentId, comment.studyCommentId()); + assertEquals(studyId, comment.studyId()); + assertEquals(user.getUserId(), comment.userId()); + assertEquals(4, comment.rating()); + assertEquals("Great study", comment.commentText()); + assertEquals(user.getDisplayName(), comment.displayName()); + assertNotNull(comment.institutionName()); + assertNotNull(comment.createDate()); + assertNotNull(comment.updateDate()); + } + + @Test + void testUpsertOnConflictUpdatesExistingCommentAndReturnsSameId() { + Integer studyId = insertStudy(); + User user = createUserWithInstitution(); + + Integer firstId = studyCommentDAO.upsert(studyId, user.getUserId(), 2, "First take"); + Integer secondId = studyCommentDAO.upsert(studyId, user.getUserId(), 5, "Changed my mind"); + + assertEquals(firstId, secondId); + List comments = studyCommentDAO.findByStudyId(studyId, 100, 0); + assertEquals(1, comments.size()); + assertEquals(5, comments.getFirst().rating()); + assertEquals("Changed my mind", comments.getFirst().commentText()); + } + + @Test + void testFindByStudyIdScopedToStudy() { + Integer studyId = insertStudy(); + Integer otherStudyId = insertStudy(); + User user = createUserWithInstitution(); + User otherUser = createUser(); + + studyCommentDAO.upsert(studyId, user.getUserId(), 3, "On the study"); + studyCommentDAO.upsert(otherStudyId, user.getUserId(), 1, "On another study"); + studyCommentDAO.upsert(studyId, otherUser.getUserId(), 5, null); + + List comments = studyCommentDAO.findByStudyId(studyId, 100, 0); + + assertEquals(2, comments.size()); + assertTrue(comments.stream().allMatch(c -> studyId.equals(c.studyId()))); + // A commenter without an institution still lists, with a null institution name + StudyComment noInstitution = + comments.stream() + .filter(c -> otherUser.getUserId().equals(c.userId())) + .findFirst() + .orElseThrow(); + assertNull(noInstitution.institutionName()); + assertNull(noInstitution.commentText()); + } + + @Test + void testDeleteOwn() { + Integer studyId = insertStudy(); + User user = createUserWithInstitution(); + User otherUser = createUser(); + Integer commentId = studyCommentDAO.upsert(studyId, user.getUserId(), 4, "text"); + + // Another user cannot delete the comment + assertEquals(0, studyCommentDAO.deleteOwn(studyId, commentId, otherUser.getUserId())); + assertEquals(1, studyCommentDAO.findByStudyId(studyId, 100, 0).size()); + + // Nor can the author reach it through a different study's id + Integer otherStudyId = insertStudy(); + assertEquals(0, studyCommentDAO.deleteOwn(otherStudyId, commentId, user.getUserId())); + assertEquals(1, studyCommentDAO.findByStudyId(studyId, 100, 0).size()); + + // The author can + assertEquals(1, studyCommentDAO.deleteOwn(studyId, commentId, user.getUserId())); + assertTrue(studyCommentDAO.findByStudyId(studyId, 100, 0).isEmpty()); + } + + /** + * The moderation delete is not scoped to the author, but it is still scoped to the study in the + * path - the same way deleteOwn is - so a comment cannot be reached through another study's id. + */ + @Test + void testDeleteAny() { + Integer studyId = insertStudy(); + User author = createUserWithInstitution(); + Integer commentId = studyCommentDAO.upsert(studyId, author.getUserId(), 4, "text"); + + Integer otherStudyId = insertStudy(); + assertEquals(0, studyCommentDAO.deleteAny(otherStudyId, commentId)); + assertEquals(1, studyCommentDAO.findByStudyId(studyId, 100, 0).size()); + + // Deletes it without being the author + assertEquals(1, studyCommentDAO.deleteAny(studyId, commentId)); + assertTrue(studyCommentDAO.findByStudyId(studyId, 100, 0).isEmpty()); + } + + /** + * ck_study_comment_rating is what actually holds the 1-5 range. The service checks it too, but a + * second writer bypassing the service must not be able to store a rating outside it. + */ + @Test + void testRatingRangeIsEnforcedByTheDatabase() { + Integer studyId = insertStudy(); + User user = createUserWithInstitution(); + + for (int rating : new int[] {0, 6}) { + Exception thrown = + assertThrows( + Exception.class, + () -> studyCommentDAO.upsert(studyId, user.getUserId(), rating, "text")); + assertTrue(thrown.getMessage().contains("ck_study_comment_rating")); + } + } + + /** + * Revising a comment keeps its place in the list. Ordering is by create_date, so the upsert must + * leave that alone and move only update_date - otherwise an edit would jump a comment to the top. + */ + @Test + void testUpsertPreservesCreateDateAndAdvancesUpdateDate() { + Integer studyId = insertStudy(); + User user = createUserWithInstitution(); + Integer commentId = studyCommentDAO.upsert(studyId, user.getUserId(), 3, "first"); + backdate(commentId); + StudyComment before = studyCommentDAO.findByStudyId(studyId, 100, 0).getFirst(); + + studyCommentDAO.upsert(studyId, user.getUserId(), 5, "revised"); + StudyComment after = studyCommentDAO.findByStudyId(studyId, 100, 0).getFirst(); + + assertEquals(before.createDate(), after.createDate()); + assertTrue(after.updateDate().after(before.updateDate())); + assertEquals(5, after.rating()); + } + + /** Newest first by creation, so an edited comment does not jump the queue. */ + @Test + void testFindByStudyIdOrdersNewestFirstByCreation() { + Integer studyId = insertStudy(); + User first = createUserWithInstitution(); + User second = createUserWithInstitution(); + Integer firstId = studyCommentDAO.upsert(studyId, first.getUserId(), 3, "older"); + backdate(firstId); + Integer secondId = studyCommentDAO.upsert(studyId, second.getUserId(), 4, "newer"); + + assertEquals( + List.of(secondId, firstId), + studyCommentDAO.findByStudyId(studyId, 100, 0).stream() + .map(StudyComment::studyCommentId) + .toList()); + + // Editing the older one does not move it, because the order is by create_date + studyCommentDAO.upsert(studyId, first.getUserId(), 5, "older, revised"); + + assertEquals( + List.of(secondId, firstId), + studyCommentDAO.findByStudyId(studyId, 100, 0).stream() + .map(StudyComment::studyCommentId) + .toList()); + } + + @Test + void testFindByStudyIdPagesThroughTheList() { + Integer studyId = insertStudy(); + for (int i = 0; i < 3; i++) { + studyCommentDAO.upsert(studyId, createUserWithInstitution().getUserId(), 3, "c" + i); + } + + assertEquals(2, studyCommentDAO.findByStudyId(studyId, 2, 0).size()); + assertEquals(1, studyCommentDAO.findByStudyId(studyId, 2, 2).size()); + assertEquals(3, studyCommentDAO.countByStudyId(studyId)); + } + + /** The average covers every comment, so paging cannot change it. */ + @Test + void testAverageRatingCoversEveryCommentAndIsNullWhenThereAreNone() { + Integer studyId = insertStudy(); + assertNull(studyCommentDAO.averageRatingByStudyId(studyId)); + + studyCommentDAO.upsert(studyId, createUserWithInstitution().getUserId(), 2, "a"); + studyCommentDAO.upsert(studyId, createUserWithInstitution().getUserId(), 4, "b"); + + assertEquals(3.0, studyCommentDAO.averageRatingByStudyId(studyId)); + } + + /** A comment is not reachable through another study's id. */ + @Test + void testFindByIdIsScopedToTheStudy() { + Integer studyId = insertStudy(); + Integer otherStudyId = insertStudy(); + User user = createUserWithInstitution(); + Integer commentId = studyCommentDAO.upsert(studyId, user.getUserId(), 4, "text"); + + assertNotNull(studyCommentDAO.findById(studyId, commentId)); + assertNull(studyCommentDAO.findById(otherStudyId, commentId)); + } + + private Integer insertStudy() { + User user = createUser(); + return studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + } + + /** + * Move a comment's timestamps a second into the past, so a following upsert cannot land on the + * same clock tick. + * + *

These tests slept to get that separation. A sleep buys it only probabilistically - short + * enough to be cheap is short enough for a loaded machine to miss - and every run pays the wait + * whether it needed it or not. Backdating states the precondition instead of hoping for it. + */ + private void backdate(Integer studyCommentId) { + jdbi.useHandle( + handle -> + handle.execute( + """ + UPDATE study_comment + SET create_date = create_date - interval '1 second', + update_date = update_date - interval '1 second' + WHERE study_comment_id = ? + """, + studyCommentId)); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/db/StudyDAOTest.java b/src/test/java/org/broadinstitute/consent/http/db/StudyDAOTest.java index 70aa26dfd5..29f742ca03 100644 --- a/src/test/java/org/broadinstitute/consent/http/db/StudyDAOTest.java +++ b/src/test/java/org/broadinstitute/consent/http/db/StudyDAOTest.java @@ -19,6 +19,7 @@ import java.util.stream.IntStream; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomUtils; +import org.broadinstitute.consent.http.db.mapper.StudyReducer; import org.broadinstitute.consent.http.enumeration.FileCategory; import org.broadinstitute.consent.http.enumeration.PropertyType; import org.broadinstitute.consent.http.models.DataUse; @@ -26,10 +27,12 @@ import org.broadinstitute.consent.http.models.Dataset; import org.broadinstitute.consent.http.models.DatasetProperty; import org.broadinstitute.consent.http.models.FileStorageObject; +import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; import org.broadinstitute.consent.http.models.StudyDatasetCountRecord; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.User; +import org.jdbi.v3.core.mapper.reflect.BeanMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -88,6 +91,71 @@ void testCreateAndFindStudy() { assertNotNull(u.getCreateDate()); } + @Test + void testUpdateStudyWritesPiDetails() { + Study study = insertStudyWithProperties(); + User user = createUserWithInstitution(); + Integer institutionId = user.getInstitutionId(); + String orcid = "0000-0001-2345-6789"; + String linkedinUrl = "https://linkedin.com/in/pi"; + String websiteUrl = "https://pi.example.com"; + + // PI details default to null + assertNull(study.getPiInstitution()); + assertNull(study.getPiOrcid()); + assertNull(study.getPiLinkedinUrl()); + assertNull(study.getPiWebsiteUrl()); + + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + institutionId, + orcid, + linkedinUrl, + websiteUrl, + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + + Study found = studyDAO.findStudyById(study.getStudyId()); + assertEquals(institutionId, found.getPiInstitution().getId()); + assertEquals( + institutionDAO.findInstitutionById(institutionId).getName(), + found.getPiInstitution().getName()); + assertEquals(orcid, found.getPiOrcid()); + assertEquals(linkedinUrl, found.getPiLinkedinUrl()); + assertEquals(websiteUrl, found.getPiWebsiteUrl()); + + Study foundByName = studyDAO.findStudyByName(study.getName()); + assertEquals(institutionId, foundByName.getPiInstitution().getId()); + assertEquals(orcid, foundByName.getPiOrcid()); + + // Clearing the details persists nulls + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + null, + null, + null, + null, + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + Study cleared = studyDAO.findStudyById(study.getStudyId()); + assertNull(cleared.getPiInstitution()); + assertNull(cleared.getPiOrcid()); + assertNull(cleared.getPiLinkedinUrl()); + assertNull(cleared.getPiWebsiteUrl()); + } + @Test void testStudyProps() { User u = createUser(); @@ -309,6 +377,10 @@ void testUpdateStudy() { newDescription, newPiName, null, + null, + null, + null, + null, newDataTypes, true, user.getUserId(), @@ -571,4 +643,77 @@ private void insertAccessManagementDatasetProperty( PropertyType.String, Date.from(Instant.now())))); } + + /** + * Item 5: the Institution constructor seeds createDate with "now", so StudyReducer always + * overwrites it. hasOptionalColumn swallows every exception and returns empty, which means a + * broken alias would silently produce null rather than failing - so both outcomes are pinned: the + * stored timestamps when the query joins the institution, and null when it does not. + */ + @Test + void testPiInstitutionTimestampsComeFromTheInstitutionRow() { + User user = createUserWithInstitution(); + Institution institution = institutionDAO.findInstitutionById(user.getInstitutionId()); + Integer studyId = insertStudyWithInstitution(user, institution.getId()); + + Study study = studyDAO.findStudyById(studyId); + + // Compared against the stored column rather than the Institution the institution DAO maps, + // which narrows create_date to a date-only value. + Timestamp stored = + jdbi.withHandle( + handle -> + handle + .createQuery("SELECT create_date FROM institution WHERE institution_id = :id") + .bind("id", institution.getId()) + .mapTo(Timestamp.class) + .one()); + + assertEquals(institution.getId(), study.getPiInstitution().getId()); + assertEquals(institution.getName(), study.getPiInstitution().getName()); + assertEquals(stored, study.getPiInstitution().getCreateDate()); + // A never-updated institution has no update_date, and that null is the stored one rather + // than a swallowed mapping failure - the create_date beside it proves the read works. + assertNull(study.getPiInstitution().getUpdateDate()); + } + + @Test + void testPiInstitutionTimestampsAreNullWhenTheQueryDoesNotJoinTheInstitution() { + User user = createUserWithInstitution(); + Integer studyId = insertStudyWithInstitution(user, user.getInstitutionId()); + + // The same reducer over a row view that carries the id but no institution columns, which is + // what a query selecting only the study's own columns produces. + Study study = + jdbi.withHandle( + handle -> + handle + .createQuery("SELECT s.* FROM study s WHERE s.study_id = :studyId") + .bind("studyId", studyId) + .registerRowMapper(BeanMapper.factory(Study.class)) + .reduceRows(new StudyReducer()) + .findFirst() + .orElseThrow()); + + assertEquals(user.getInstitutionId(), study.getPiInstitution().getId()); + assertNull(study.getPiInstitution().getName()); + assertNull(study.getPiInstitution().getCreateDate()); + assertNull(study.getPiInstitution().getUpdateDate()); + } + + private Integer insertStudyWithInstitution(User user, Integer institutionId) { + Integer studyId = + studyDAO.insertStudy( + RandomStringUtils.secure().nextAlphabetic(20), + "description", + "piName", + "piEmail", + List.of("dataType"), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + studyDAO.updateStudyPiInstitutionId(studyId, institutionId); + return studyId; + } } diff --git a/src/test/java/org/broadinstitute/consent/http/db/StudyPiInstitutionBackfillMigrationTest.java b/src/test/java/org/broadinstitute/consent/http/db/StudyPiInstitutionBackfillMigrationTest.java new file mode 100644 index 0000000000..97838cac8d --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/db/StudyPiInstitutionBackfillMigrationTest.java @@ -0,0 +1,165 @@ +package org.broadinstitute.consent.http.db; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.broadinstitute.consent.http.enumeration.PropertyType; +import org.broadinstitute.consent.http.models.Institution; +import org.broadinstitute.consent.http.models.User; +import org.junit.jupiter.api.Test; + +/** + * Exercises the data migration in changelog-consent-2026-09-02-study-pi-institution-backfill.xml, + * which copies the registration-time `piInstitution` study property into the + * study.pi_institution_id column the study page reads. + * + *

Liquibase runs the changeset against an empty schema at test startup, which proves the SQL + * parses but never exercises it against data. This test reads the SQL out of the shipped changeset + * and runs it over registration-shaped rows, so the test cannot drift from what actually ships. + */ +class StudyPiInstitutionBackfillMigrationTest extends DAOTestHelper { + + private static final Path CHANGESET = + Path.of( + "src/main/resources/changesets/changelog-consent-2026-09-02-study-pi-institution-backfill.xml"); + + @Test + void testBackfillsThePiInstitutionColumnFromTheRegistrationProperty() throws Exception { + User user = createUserWithInstitution(); + Institution institution = getUserInstitution(user); + Integer studyId = insertStudy(user); + insertProperty(studyId, "piInstitution", institution.getId().toString()); + + runMigration(); + + assertEquals(institution.getId(), piInstitutionId(studyId)); + } + + /** A study that never recorded a PI institution is left alone. */ + @Test + void testLeavesAStudyWithoutThePropertyAlone() throws Exception { + Integer studyId = insertStudy(createUserWithInstitution()); + + runMigration(); + + assertNull(piInstitutionId(studyId)); + } + + /** + * The property is stored in a text column, and fk_study_pi_institution would reject an id with no + * institution row, so both are skipped rather than failing the whole migration. + */ + @Test + void testSkipsValuesThatCannotBeAppliedToTheColumn() throws Exception { + Integer nonNumeric = insertStudy(createUserWithInstitution()); + insertProperty(nonNumeric, "piInstitution", "Broad Institute"); + Integer negative = insertStudy(createUserWithInstitution()); + insertProperty(negative, "piInstitution", "-1"); + // Digits, in range, and no institution row: the only value that reaches the EXISTS guard. + // "-1" fails the regex first, so on its own it never exercised that guard at all. + Integer orphanId = insertStudy(createUserWithInstitution()); + insertProperty(orphanId, "piInstitution", "2000000000"); + + runMigration(); + + assertNull(piInstitutionId(nonNumeric)); + assertNull(piInstitutionId(negative)); + assertNull(piInstitutionId(orphanId)); + } + + /** + * All digits but wider than the integer column. The cast raises "integer out of range", which + * would abort the changeset and take every valid row's backfill down with it, so the value has to + * be filtered out before the cast rather than skipped by the EXISTS guard. + */ + @Test + void testSkipsAValueTooLargeForTheColumnWithoutFailingTheChangeset() throws Exception { + User user = createUserWithInstitution(); + Institution institution = getUserInstitution(user); + Integer valid = insertStudy(user); + insertProperty(valid, "piInstitution", institution.getId().toString()); + Integer outOfRange = insertStudy(createUserWithInstitution()); + insertProperty(outOfRange, "piInstitution", "99999999999"); + + runMigration(); + + assertNull(piInstitutionId(outOfRange)); + // The point of the test: the valid row beside it still got backfilled. + assertEquals(institution.getId(), piInstitutionId(valid)); + } + + /** A PI institution already set on the column, by PATCH, is authoritative. */ + @Test + void testDoesNotOverwriteAnAlreadyPopulatedColumn() throws Exception { + User user = createUserWithInstitution(); + Institution institution = getUserInstitution(user); + Integer studyId = insertStudy(user); + studyDAO.updateStudyPiInstitutionId(studyId, institution.getId()); + // A stale registration property pointing somewhere else + User otherUser = createUserWithInstitution(); + insertProperty(studyId, "piInstitution", getUserInstitution(otherUser).getId().toString()); + + runMigration(); + + assertEquals(institution.getId(), piInstitutionId(studyId)); + } + + @Test + void testIsIdempotent() throws Exception { + User user = createUserWithInstitution(); + Institution institution = getUserInstitution(user); + Integer studyId = insertStudy(user); + insertProperty(studyId, "piInstitution", institution.getId().toString()); + + runMigration(); + runMigration(); + + assertEquals(institution.getId(), piInstitutionId(studyId)); + } + + /** Runs every <sql> block from the shipped changeset, in order. */ + private void runMigration() throws Exception { + String changeset = Files.readString(CHANGESET); + Matcher matcher = Pattern.compile("(.*?)", Pattern.DOTALL).matcher(changeset); + List blocks = matcher.results().map(result -> result.group(1)).toList(); + assertEquals(1, blocks.size(), "changeset should ship one block"); + jdbi.useHandle(handle -> blocks.forEach(block -> handle.createScript(block.trim()).execute())); + } + + private Integer insertStudy(User user) { + return studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + randomAlphabetic(20), + null, + List.of(randomAlphabetic(10)), + true, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + } + + private void insertProperty(Integer studyId, String key, String value) { + studyDAO.insertStudyProperty(studyId, key, PropertyType.Number.toString(), value); + } + + private Integer piInstitutionId(Integer studyId) { + Optional value = + jdbi.withHandle( + handle -> + handle + .createQuery("SELECT pi_institution_id FROM study WHERE study_id = :studyId") + .bind("studyId", studyId) + .mapTo(Integer.class) + .findFirst()); + return value.orElse(null); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/db/StudyRecommendationDAOTest.java b/src/test/java/org/broadinstitute/consent/http/db/StudyRecommendationDAOTest.java new file mode 100644 index 0000000000..a69f9dfc1d --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/db/StudyRecommendationDAOTest.java @@ -0,0 +1,173 @@ +package org.broadinstitute.consent.http.db; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.broadinstitute.consent.http.models.DataAccessRequestData; +import org.broadinstitute.consent.http.models.DataUseBuilder; +import org.broadinstitute.consent.http.models.StudyRecommendation; +import org.broadinstitute.consent.http.models.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StudyRecommendationDAOTest extends DAOTestHelper { + + private StudyRecommendationDAO studyRecommendationDAO; + + @BeforeEach + void setUpDao() { + studyRecommendationDAO = jdbi.onDemand(StudyRecommendationDAO.class); + } + + @Test + void testFindSimilar() { + String piName = randomAlphabetic(20); + String typeOne = randomAlphabetic(20); + String typeTwo = randomAlphabetic(20); + + Integer sourceId = insertStudy(piName, List.of(typeOne, typeTwo), true); + // Shares the PI and both data types — strongest match + Integer bothMatchId = insertStudy(piName, List.of(typeOne, typeTwo), true); + Integer bothMatchDatasetId = insertDatasetForStudy(bothMatchId); + // Shares one data type only + Integer typeMatchId = insertStudy(randomAlphabetic(20), List.of(typeOne), true); + // Shares the PI only + Integer piMatchId = insertStudy(piName, List.of(randomAlphabetic(20)), true); + // Matches but is not publicly visible + insertStudy(piName, List.of(typeOne), false); + // No relation to the source study + insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + + List similar = studyRecommendationDAO.findSimilar(sourceId); + + assertEquals( + List.of(bothMatchId, typeMatchId, piMatchId), + similar.stream().map(StudyRecommendation::studyId).toList()); + assertTrue(similar.stream().map(StudyRecommendation::studyId).noneMatch(sourceId::equals)); + + StudyRecommendation bothMatch = similar.getFirst(); + assertEquals(1L, bothMatch.datasetCount()); + assertEquals(List.of(bothMatchDatasetId), bothMatch.datasetIds()); + + StudyRecommendation typeMatch = similar.get(1); + assertEquals(0L, typeMatch.datasetCount()); + assertTrue(typeMatch.datasetIds().isEmpty()); + } + + @Test + void testFindFrequentlyRequestedWith() { + Integer sourceId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + Integer sourceDatasetId = insertDatasetForStudy(sourceId); + + Integer frequentId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + Integer frequentDatasetId = insertDatasetForStudy(frequentId); + Integer occasionalId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + Integer occasionalDatasetId = insertDatasetForStudy(occasionalId); + Integer privateId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), false); + Integer privateDatasetId = insertDatasetForStudy(privateId); + + Integer draftOnlyId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + Integer draftOnlyDatasetId = insertDatasetForStudy(draftOnlyId); + Integer archivedOnlyId = insertStudy(randomAlphabetic(20), List.of(randomAlphabetic(20)), true); + Integer archivedOnlyDatasetId = insertDatasetForStudy(archivedOnlyId); + + // Two DARs request the source dataset together with the frequent study's dataset, one of + // them also with the private study's dataset; one DAR pairs it with the occasional study's. + insertSubmittedDarForDatasets(sourceDatasetId, frequentDatasetId, privateDatasetId); + insertSubmittedDarForDatasets(sourceDatasetId, frequentDatasetId); + insertSubmittedDarForDatasets(sourceDatasetId, occasionalDatasetId); + // A DAR not touching the source dataset never counts + insertSubmittedDarForDatasets(occasionalDatasetId); + // An unsubmitted draft cart is not a request, and an archived DAR stops counting + insertDraftDarForDatasets(sourceDatasetId, draftOnlyDatasetId); + insertArchivedDarForDatasets(sourceDatasetId, archivedOnlyDatasetId); + + List recommendations = + studyRecommendationDAO.findFrequentlyRequestedWith(sourceId); + + List recommendedIds = + recommendations.stream().map(StudyRecommendation::studyId).toList(); + assertEquals(List.of(frequentId, occasionalId), recommendedIds); + assertFalse(recommendedIds.contains(draftOnlyId)); + assertFalse(recommendedIds.contains(archivedOnlyId)); + assertEquals(1L, recommendations.getFirst().datasetCount()); + assertEquals(List.of(frequentDatasetId), recommendations.getFirst().datasetIds()); + } + + /** A blank pi_name is not an identity, so blank-PI studies must not match each other. */ + @Test + void testFindSimilarDoesNotMatchOnBlankPiNames() { + Integer sourceId = insertStudy("", List.of(randomAlphabetic(20)), true); + insertStudy("", List.of(randomAlphabetic(20)), true); + + assertTrue(studyRecommendationDAO.findSimilar(sourceId).isEmpty()); + } + + private Integer insertStudy(String piName, List dataTypes, boolean publicVisibility) { + User user = createUser(); + return studyDAO.insertStudy( + randomAlphabetic(20), + randomAlphabetic(20), + piName, + null, + dataTypes, + publicVisibility, + user.getUserId(), + Instant.now(), + UUID.randomUUID()); + } + + private Integer insertDatasetForStudy(Integer studyId) { + User user = createUser(); + Integer datasetId = + datasetDAO.insertDataset( + randomAlphabetic(20), + new Timestamp(new Date().getTime()), + user.getUserId(), + randomAlphabetic(20), + new DataUseBuilder().setGeneralUse(true).build().toString(), + null); + datasetDAO.updateStudyId(datasetId, studyId); + return datasetId; + } + + private void insertSubmittedDarForDatasets(Integer... datasetIds) { + insertDarForDatasets(new DataAccessRequestData(), true, datasetIds); + } + + private void insertDraftDarForDatasets(Integer... datasetIds) { + insertDarForDatasets(new DataAccessRequestData(), false, datasetIds); + } + + private void insertArchivedDarForDatasets(Integer... datasetIds) { + DataAccessRequestData data = new DataAccessRequestData(); + data.setStatus("Archived"); + insertDarForDatasets(data, true, datasetIds); + } + + private void insertDarForDatasets( + DataAccessRequestData data, boolean submitted, Integer... datasetIds) { + User user = createUser(); + String referenceId = UUID.randomUUID().toString(); + Date now = new Date(); + if (submitted) { + Integer collectionId = + darCollectionDAO.insertDarCollection( + "DAR-" + randomAlphabetic(10), user.getUserId(), now); + dataAccessRequestDAO.insertDataAccessRequest( + collectionId, referenceId, user.getUserId(), now, now, now, data, randomAlphabetic(10)); + } else { + dataAccessRequestDAO.insertDraftDataAccessRequest( + referenceId, user.getUserId(), now, now, data); + } + for (Integer datasetId : datasetIds) { + dataAccessRequestDAO.insertDARDatasetRelation(referenceId, datasetId); + } + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/models/DatasetRegistrationSchemaV1BuilderTest.java b/src/test/java/org/broadinstitute/consent/http/models/DatasetRegistrationSchemaV1BuilderTest.java index 1562313b81..d39cf613db 100644 --- a/src/test/java/org/broadinstitute/consent/http/models/DatasetRegistrationSchemaV1BuilderTest.java +++ b/src/test/java/org/broadinstitute/consent/http/models/DatasetRegistrationSchemaV1BuilderTest.java @@ -83,6 +83,22 @@ void testBuildEmptySchema() { assertNotNull(schemaV1); } + /** + * A null study yields an empty schema rather than a failure. The build used to express that by + * wrapping every assignment in a nonNull check; it returns early now, and this pins the behaviour + * that inversion has to preserve. + */ + @Test + void testBuildSchemaWithNullStudy() { + DatasetRegistrationSchemaV1Builder builder = new DatasetRegistrationSchemaV1Builder(); + + DatasetRegistrationSchemaV1 schemaV1 = builder.build(null, List.of()); + + assertNotNull(schemaV1); + assertNull(schemaV1.getStudyName()); + assertNull(schemaV1.getStudyId()); + } + @Test void testBuildSchemaWithStudyProps() { DatasetRegistrationSchemaV1Builder builder = new DatasetRegistrationSchemaV1Builder(); diff --git a/src/test/java/org/broadinstitute/consent/http/models/StudyAssetsTest.java b/src/test/java/org/broadinstitute/consent/http/models/StudyAssetsTest.java new file mode 100644 index 0000000000..e97c604aae --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/models/StudyAssetsTest.java @@ -0,0 +1,192 @@ +package org.broadinstitute.consent.http.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.broadinstitute.consent.http.enumeration.PropertyType; +import org.junit.jupiter.api.Test; + +class StudyAssetsTest { + + private final StudyAssets studyAssets = new StudyAssets(); + + @Test + void testFindAssetListReadsThePromotedProperty() { + Set properties = + Set.of(jsonProperty(StudyAssets.PUBLICATIONS, "[{\"title\": \"promoted\"}]")); + + assertEquals(1, studyAssets.findAssetList(properties, StudyAssets.PUBLICATIONS).size()); + } + + /** A study whose registration has not been rewritten since the promotion still reads. */ + @Test + void testFindAssetListFallsBackToTheLegacyObject() { + Set properties = + Set.of(jsonProperty(StudyAssets.ASSETS, "{\"publications\": [{\"title\": \"legacy\"}]}")); + + assertEquals(1, studyAssets.findAssetList(properties, StudyAssets.PUBLICATIONS).size()); + } + + /** + * An explicitly empty promoted property means the submitter removed the last asset of that type. + * Falling back to the legacy object here would resurrect it. + */ + @Test + void testAnEmptyPromotedPropertyIsNotOverriddenByTheLegacyObject() { + Set properties = + Set.of( + jsonProperty(StudyAssets.PUBLICATIONS, "[]"), + jsonProperty(StudyAssets.ASSETS, "{\"publications\": [{\"title\": \"removed\"}]}")); + + assertTrue(studyAssets.findAssetList(properties, StudyAssets.PUBLICATIONS).isEmpty()); + } + + /** A malformed promoted value is treated as absent, so the legacy object is still consulted. */ + @Test + void testAMalformedPromotedPropertyFallsBackToTheLegacyObject() { + StudyProperty malformed = new StudyProperty(); + malformed.setKey(StudyAssets.PUBLICATIONS); + malformed.setType(PropertyType.String); + malformed.setValue("not json"); + Set properties = + Set.of( + malformed, + jsonProperty(StudyAssets.ASSETS, "{\"publications\": [{\"title\": \"legacy\"}]}")); + + assertEquals(1, studyAssets.findAssetList(properties, StudyAssets.PUBLICATIONS).size()); + } + + /** An emptied list is stored as an empty list, not dropped back to the legacy copy. */ + @Test + void testPromotedValueKeepsAnExplicitlyEmptyTopLevelList() { + List legacyCopy = List.of(Map.of("title", "removed")); + + assertEquals( + List.of(), + StudyAssets.promotedValue( + List.of(), Map.of(StudyAssets.PUBLICATIONS, legacyCopy), StudyAssets.PUBLICATIONS)); + } + + /** An omitted field is still filled in from the deprecated object. */ + @Test + void testPromotedValueFallsBackWhenTheTopLevelFieldIsAbsent() { + List legacyCopy = List.of(Map.of("title", "legacy")); + + assertEquals( + legacyCopy, + StudyAssets.promotedValue( + null, Map.of(StudyAssets.PUBLICATIONS, legacyCopy), StudyAssets.PUBLICATIONS)); + assertNull(StudyAssets.promotedValue(null, Map.of(), StudyAssets.PUBLICATIONS)); + } + + /** An emptied list drops out of the assembled object, so a later read has nothing to restore. */ + @Test + void testAssembleOmitsAnEmptyPromotedList() { + Set properties = Set.of(jsonProperty(StudyAssets.PUBLICATIONS, "[]")); + + assertTrue(studyAssets.assemble(properties).isEmpty()); + } + + /** An explicit empty promoted property also removes a stale copy from the legacy object. */ + @Test + void testAssembleDoesNotResurrectLegacyAssets() { + Set properties = + Set.of( + jsonProperty(StudyAssets.PUBLICATIONS, "[]"), + jsonProperty( + StudyAssets.ASSETS, + "{\"publications\": [{\"title\": \"removed\"}], \"other\": true}")); + + assertEquals(Map.of("other", true), studyAssets.assemble(properties)); + } + + /** + * The round trip the migration's preservation depends on. A legacy value under a promoted name + * that is not a list cannot be promoted, so the migration keeps it in the assets object. It has + * to survive a read and the next write too: dropping it from assemble would remove it from + * registration and search responses, and stripping it on write would then delete the only copy. + */ + @Test + void testAWrongShapedLegacyValueSurvivesAReadAndTheNextWrite() { + Set properties = + Set.of(jsonProperty(StudyAssets.ASSETS, "{\"models\": \"custom-value\", \"other\": true}")); + + Map assembled = studyAssets.assemble(properties); + assertEquals(Map.of("models", "custom-value", "other", true), assembled); + + // What a client would send back is what it was given, so the next write sees the same object. + assertNull(StudyAssets.promotedValue(null, assembled, StudyAssets.MODELS)); + assertEquals( + Map.of("models", "custom-value", "other", true), StudyAssets.stripPromoted(assembled)); + } + + /** A list under a promoted name is still stripped: it is stored as the promoted property. */ + @Test + void testStripPromotedStillRemovesAPromotableList() { + Map assets = + Map.of(StudyAssets.MODELS, List.of(Map.of("name", "a")), "other", true); + + assertEquals(Map.of("other", true), StudyAssets.stripPromoted(assets)); + } + + /** + * A promoted property is the newer intent, so it wins the key in the assembled object. The legacy + * value is not stripped on write, so it is masked rather than destroyed. + */ + @Test + void testAPromotedPropertyWinsOverAWrongShapedLegacyValue() { + Set properties = + Set.of( + jsonProperty(StudyAssets.MODELS, "[{\"name\": \"promoted\"}]"), + jsonProperty(StudyAssets.ASSETS, "{\"models\": \"custom-value\"}")); + + assertEquals( + List.of(Map.of("name", "promoted")), studyAssets.assemble(properties).get("models")); + } + + /** + * The legacy object is client-supplied, so its casing is not guaranteed. A differently cased + * promoted name has to be treated the same by every path: a lookup that missed "Models" while the + * removal matched it would drop the list from the assets object without promoting it. + */ + @Test + void testADifferentlyCasedPromotedKeyRoundTrips() { + List models = List.of(Map.of("name", "a")); + Map assets = Map.of("Models", models, "other", true); + + // Promoted from the differently cased key, not ignored + assertEquals(models, StudyAssets.promotedValue(null, assets, StudyAssets.MODELS)); + // And therefore safe to strip, because it is now stored as the promoted property + assertEquals(Map.of("other", true), StudyAssets.stripPromoted(assets)); + } + + /** The same casing tolerance on the read path. */ + @Test + void testFindAssetListReadsADifferentlyCasedLegacyKey() { + Set properties = + Set.of(jsonProperty(StudyAssets.ASSETS, "{\"Publications\": [{\"title\": \"legacy\"}]}")); + + assertEquals(1, studyAssets.findAssetList(properties, StudyAssets.PUBLICATIONS).size()); + } + + /** A wrong-shaped value under a differently cased name is kept by both paths, not just one. */ + @Test + void testADifferentlyCasedWrongShapedValueIsNotStripped() { + Map assets = Map.of("Models", "custom-value", "other", true); + + assertNull(StudyAssets.promotedValue(null, assets, StudyAssets.MODELS)); + assertEquals(assets, StudyAssets.stripPromoted(assets)); + } + + private StudyProperty jsonProperty(String key, String value) { + StudyProperty property = new StudyProperty(); + property.setKey(key); + property.setType(PropertyType.Json); + property.setValue(PropertyType.Json.coerce(value)); + return property; + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/models/StudyPatchBuilder.java b/src/test/java/org/broadinstitute/consent/http/models/StudyPatchBuilder.java new file mode 100644 index 0000000000..9522a3a3ea --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/models/StudyPatchBuilder.java @@ -0,0 +1,165 @@ +package org.broadinstitute.consent.http.models; + +import java.util.List; +import java.util.Set; +import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1.StudyType; + +/** + * Builds a {@link StudyPatch} for tests by naming the fields it sets. + * + *

StudyPatch has eighteen components, several of them adjacent Strings, so a positional call + * site is a row of nulls with a value buried in it - unreadable, and it silently keeps compiling if + * two neighbours are transposed or a component is inserted. Tests that care about one or two fields + * should say which ones. + * + *

Deliberately test-only: the production callers build a patch from a request body through + * {@link StudyPatch#fromJson(String)}, which needs no builder. + */ +public final class StudyPatchBuilder { + + private String name; + private StudyType studyType; + private String description; + private List dataTypes; + private String phenotypeIndication; + private String species; + private String piName; + private String piEmail; + private Integer piInstitutionId; + private String piOrcid; + private String piLinkedinUrl; + private String piWebsiteUrl; + private List dataCustodianEmail; + private String alternativeDataSharingPlanTargetDeliveryDate; + private String alternativeDataSharingPlanTargetPublicReleaseDate; + private Boolean publicVisibility; + private String externalIdentifier; + private String externalIdentifierType; + private Set explicitNulls = Set.of(); + + public static StudyPatchBuilder patch() { + return new StudyPatchBuilder(); + } + + public StudyPatchBuilder name(String value) { + this.name = value; + return this; + } + + public StudyPatchBuilder studyType(StudyType value) { + this.studyType = value; + return this; + } + + public StudyPatchBuilder description(String value) { + this.description = value; + return this; + } + + public StudyPatchBuilder dataTypes(List value) { + this.dataTypes = value; + return this; + } + + public StudyPatchBuilder phenotypeIndication(String value) { + this.phenotypeIndication = value; + return this; + } + + public StudyPatchBuilder species(String value) { + this.species = value; + return this; + } + + public StudyPatchBuilder piName(String value) { + this.piName = value; + return this; + } + + public StudyPatchBuilder piEmail(String value) { + this.piEmail = value; + return this; + } + + public StudyPatchBuilder piInstitutionId(Integer value) { + this.piInstitutionId = value; + return this; + } + + public StudyPatchBuilder piOrcid(String value) { + this.piOrcid = value; + return this; + } + + public StudyPatchBuilder piLinkedinUrl(String value) { + this.piLinkedinUrl = value; + return this; + } + + public StudyPatchBuilder piWebsiteUrl(String value) { + this.piWebsiteUrl = value; + return this; + } + + public StudyPatchBuilder dataCustodianEmail(List value) { + this.dataCustodianEmail = value; + return this; + } + + public StudyPatchBuilder targetDeliveryDate(String value) { + this.alternativeDataSharingPlanTargetDeliveryDate = value; + return this; + } + + public StudyPatchBuilder targetPublicReleaseDate(String value) { + this.alternativeDataSharingPlanTargetPublicReleaseDate = value; + return this; + } + + public StudyPatchBuilder publicVisibility(Boolean value) { + this.publicVisibility = value; + return this; + } + + public StudyPatchBuilder externalIdentifier(String value) { + this.externalIdentifier = value; + return this; + } + + public StudyPatchBuilder externalIdentifierType(String value) { + this.externalIdentifierType = value; + return this; + } + + /** + * The fields the body sent as an explicit JSON null, which the PI columns read as "clear". A + * patch built without them behaves like one built directly rather than from a request body. + */ + public StudyPatchBuilder explicitNulls(String... fields) { + this.explicitNulls = Set.of(fields); + return this; + } + + public StudyPatch build() { + return new StudyPatch( + name, + studyType, + description, + dataTypes, + phenotypeIndication, + species, + piName, + piEmail, + piInstitutionId, + piOrcid, + piLinkedinUrl, + piWebsiteUrl, + dataCustodianEmail, + alternativeDataSharingPlanTargetDeliveryDate, + alternativeDataSharingPlanTargetPublicReleaseDate, + publicVisibility, + externalIdentifier, + externalIdentifierType, + explicitNulls); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/models/StudyPatchTest.java b/src/test/java/org/broadinstitute/consent/http/models/StudyPatchTest.java index bbf034aba5..ba9e5df826 100644 --- a/src/test/java/org/broadinstitute/consent/http/models/StudyPatchTest.java +++ b/src/test/java/org/broadinstitute/consent/http/models/StudyPatchTest.java @@ -10,10 +10,12 @@ import static org.broadinstitute.consent.http.models.StudyPatch.STUDY_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Set; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.enumeration.PropertyType; import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1.StudyType; @@ -36,7 +38,11 @@ class StudyPatchTest extends AbstractTestHelper { "{ \"studyType\": \"not a study type\" }", "{ \"publicVisibility\": \"not a boolean\" }", "{ \"publicVisibility\": \"true\" }", - "{ \"publicVisibility\": \"false\" }" + "{ \"publicVisibility\": \"false\" }", + // Item 7b: an integer field is as strict as the String and Boolean fields beside it + "{ \"piInstitutionId\": \"123\" }", + "{ \"piInstitutionId\": 1.5 }", + "{ \"piInstitutionId\": true }" }) void testFromJson(String json) { assertThrows(Exception.class, () -> StudyPatch.fromJson(json)); @@ -47,7 +53,8 @@ void testIsPatchableNoValues() { Study study = mockStudy(); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null); assertFalse(patch.isPatchable(study)); } @@ -64,6 +71,10 @@ void testIsPatchableSameValues() { "HUMAN", mockStudy.getPiName(), mockStudy.getPiEmail(), + null, + null, + null, + null, List.of("EMAIL1", "EMAIL2"), "01/01/2020", "01/01/2020", @@ -78,7 +89,8 @@ void testIsPatchableName() { Study study = mockStudy(); StudyPatch patch = new StudyPatch( - "Name", null, null, null, null, null, null, null, null, null, null, null, null, null); + "Name", null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null); assertTrue(patch.isPatchable(study)); } @@ -100,6 +112,10 @@ void testIsPatchableStudyType() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -122,6 +138,10 @@ void testIsPatchableDescription() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -144,6 +164,10 @@ void testIsPatchableDataTypes() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -166,6 +190,10 @@ void testIsPatchablePhenotype() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -188,6 +216,10 @@ void testIsPatchableSpecies() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -210,6 +242,10 @@ void testIsPatchablePIName() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -232,6 +268,10 @@ void testIsPatchablePIEmail() { null, null, null, + null, + null, + null, + null, null); assertTrue(patch.isPatchable(study)); } @@ -249,6 +289,10 @@ void testIsPatchableDataCustodians() { null, null, null, + null, + null, + null, + null, List.of("new_email1", "new_email2"), null, null, @@ -272,6 +316,10 @@ void testIsPatchableTargetDeliveryDate() { null, null, null, + null, + null, + null, + null, "New Date", null, null, @@ -295,6 +343,10 @@ void testIsPatchableTargetReleaseDate() { null, null, null, + null, + null, + null, + null, "New Date", null, null, @@ -307,7 +359,8 @@ void testIsPatchablePublicVisibility() { Study study = mockStudy(); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, false, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, false, null, null); assertTrue(patch.isPatchable(study)); } @@ -316,8 +369,8 @@ void testIsPatchableExternalIdentifier() { Study study = mockStudy(); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, "SCP1671", - null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, "SCP1671", null); assertTrue(patch.isPatchable(study)); } @@ -339,6 +392,10 @@ void testIsPatchableExternalIdentifierType() { null, null, null, + null, + null, + null, + null, "Single Cell Portal"); assertTrue(patch.isPatchable(study)); } @@ -349,7 +406,8 @@ void testIsPatchableExternalIdentifierDifferentValue() { study.addProperty(new StudyProperty(EXTERNAL_IDENTIFIER, "OLD_ID", PropertyType.String)); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, "NEW_ID", null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, "NEW_ID", null); assertTrue(patch.isPatchable(study)); } @@ -372,6 +430,10 @@ void testIsPatchableSameExternalIdentifier() { null, null, null, + null, + null, + null, + null, existingValue, null); assertFalse(patch.isPatchable(study)); @@ -396,6 +458,10 @@ void testIsPatchableExternalIdentifierTypeDifferentValue() { null, null, null, + null, + null, + null, + null, "New Type"); assertTrue(patch.isPatchable(study)); } @@ -421,6 +487,10 @@ void testIsPatchableSameExternalIdentifierType() { null, null, null, + null, + null, + null, + null, existingValue); assertFalse(patch.isPatchable(study)); } @@ -431,7 +501,8 @@ void testIsPatchableExternalIdentifierBlankIsFalseWhenPropertyAbsent(String blan Study study = mockStudy(); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, blank, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, blank, null); assertFalse(patch.isPatchable(study)); } @@ -442,7 +513,8 @@ void testIsPatchableExternalIdentifierBlankIsTrueWhenPropertyExists(String blank study.addProperty(new StudyProperty(EXTERNAL_IDENTIFIER, "SCP1671", PropertyType.String)); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, blank, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, blank, null); assertTrue(patch.isPatchable(study)); } @@ -452,7 +524,8 @@ void testIsPatchableExternalIdentifierTypeBlankIsFalseWhenPropertyAbsent(String Study study = mockStudy(); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, blank); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, blank); assertFalse(patch.isPatchable(study)); } @@ -464,10 +537,163 @@ void testIsPatchableExternalIdentifierTypeBlankIsTrueWhenPropertyExists(String b new StudyProperty(EXTERNAL_IDENTIFIER_TYPE, "Single Cell Portal", PropertyType.String)); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, blank); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, blank); assertTrue(patch.isPatchable(study)); } + @Test + void testIsPatchablePiDetailsChanged() { + Study study = mockStudy(); + + assertTrue(patchWithPiDetails(1, null, null, null).isPatchable(study)); + assertTrue(patchWithPiDetails(null, "0000-0001-2345-6789", null, null).isPatchable(study)); + assertTrue( + patchWithPiDetails(null, null, "https://linkedin.com/in/pi", null).isPatchable(study)); + assertTrue(patchWithPiDetails(null, null, null, "https://pi.example.com").isPatchable(study)); + } + + @Test + void testIsPatchablePiDetailsSameValues() { + Study study = mockStudy(); + Institution institution = new Institution(); + institution.setId(1); + study.setPiInstitution(institution); + study.setPiOrcid("0000-0001-2345-6789"); + study.setPiLinkedinUrl("https://linkedin.com/in/pi"); + study.setPiWebsiteUrl("https://pi.example.com"); + + StudyPatch patch = + patchWithPiDetails( + 1, "0000-0001-2345-6789", "https://linkedin.com/in/pi", "https://pi.example.com"); + assertFalse(patch.isPatchable(study)); + } + + @Test + void testIsPatchablePiInstitutionChanged() { + Study study = mockStudy(); + Institution institution = new Institution(); + institution.setId(1); + study.setPiInstitution(institution); + + assertFalse(patchWithPiDetails(1, null, null, null).isPatchable(study)); + assertTrue(patchWithPiDetails(2, null, null, null).isPatchable(study)); + } + + /** + * The PI columns are columns on the study row, not patchable properties, so they follow the JSON + * convention: an absent field is a no-op and an explicit null clears. A blank string is + * normalized to a clear rather than stored. + */ + @Test + void testIsPatchableClearsPiDetailsOnExplicitNull() { + Study study = mockStudy(); + Institution institution = new Institution(); + institution.setId(1); + study.setPiInstitution(institution); + study.setPiOrcid("0000-0001-2345-6789"); + + // An explicit null clears a stored value, so it is patchable + assertTrue(StudyPatch.fromJson("{\"piOrcid\": null}").isPatchable(study)); + assertTrue(StudyPatch.fromJson("{\"piInstitutionId\": null}").isPatchable(study)); + // A blank string is normalized to the same clear + assertTrue(StudyPatch.fromJson("{\"piOrcid\": \"\"}").isPatchable(study)); + + // An absent field is a no-op, even when a value is stored + assertFalse(StudyPatch.fromJson("{}").isPatchable(study)); + + // Clearing a column that is already empty is not a change + assertFalse(StudyPatch.fromJson("{\"piLinkedinUrl\": null}").isPatchable(study)); + assertFalse(StudyPatch.fromJson("{\"piWebsiteUrl\": \"\"}").isPatchable(study)); + + Study noPiDetails = mockStudy(); + assertFalse(StudyPatch.fromJson("{\"piOrcid\": null}").isPatchable(noPiDetails)); + assertFalse(StudyPatch.fromJson("{\"piInstitutionId\": null}").isPatchable(noPiDetails)); + } + + @Test + void testResolvePiDetailConventions() { + // Absent = keep the stored value + StudyPatch absent = StudyPatch.fromJson("{}"); + assertEquals("existing", absent.resolvePiOrcid("existing")); + assertEquals("existing", absent.resolvePiLinkedinUrl("existing")); + assertEquals("existing", absent.resolvePiWebsiteUrl("existing")); + assertEquals(5, absent.resolvePiInstitutionId(5)); + + // Explicit null = clear + StudyPatch cleared = + StudyPatch.fromJson( + """ + {"piInstitutionId": null, "piOrcid": null, + "piLinkedinUrl": null, "piWebsiteUrl": null} + """); + assertNull(cleared.resolvePiOrcid("existing")); + assertNull(cleared.resolvePiLinkedinUrl("existing")); + assertNull(cleared.resolvePiWebsiteUrl("existing")); + assertNull(cleared.resolvePiInstitutionId(5)); + + // Blank = clear; a value = set + assertNull(StudyPatch.fromJson("{\"piOrcid\": \"\"}").resolvePiOrcid("existing")); + assertNull(StudyPatch.fromJson("{\"piOrcid\": \" \"}").resolvePiOrcid("existing")); + assertEquals("new", StudyPatch.fromJson("{\"piOrcid\": \"new\"}").resolvePiOrcid("existing")); + assertEquals(6, StudyPatch.fromJson("{\"piInstitutionId\": 6}").resolvePiInstitutionId(5)); + } + + /** + * Item 7c: the explicit-null convention is deliberately not universal. The PI *columns* honour + * it, but piName and piEmail are older plain columns whose patch treats null as "absent", so + * {"piName": null} keeps the stored name where {"piOrcid": null} clears the orcid. Pinning the + * asymmetry here so a later attempt to unify it has to change a test that says why. + */ + @Test + void testExplicitNullClearsPiColumnsButIsANoOpForPiName() { + Study study = mockStudy(); + study.setPiName("Dr Existing"); + study.setPiOrcid("0000-0001-2345-6789"); + + StudyPatch nullPiName = StudyPatch.fromJson("{\"piName\": null}"); + assertTrue(nullPiName.explicitNulls().contains("piName")); + // Recorded as an explicit null, but piName is not resolved through that convention + assertNull(nullPiName.piName()); + assertFalse(nullPiName.isPatchable(study), "an explicit null on piName changes nothing"); + + StudyPatch nullPiOrcid = StudyPatch.fromJson("{\"piOrcid\": null}"); + assertTrue(nullPiOrcid.isPatchable(study), "an explicit null on piOrcid clears it"); + assertNull(nullPiOrcid.resolvePiOrcid(study.getPiOrcid())); + } + + /** A patch built directly, rather than from a body, has no explicitly nulled fields. */ + @Test + void testDirectlyBuiltPatchHasNoExplicitNulls() { + StudyPatch patch = patchWithPiDetails(null, null, null, null); + assertEquals(Set.of(), patch.explicitNulls()); + assertEquals("existing", patch.resolvePiOrcid("existing")); + assertEquals(5, patch.resolvePiInstitutionId(5)); + } + + private StudyPatch patchWithPiDetails( + Integer piInstitutionId, String piOrcid, String piLinkedinUrl, String piWebsiteUrl) { + return new StudyPatch( + null, + null, + null, + null, + null, + null, + null, + null, + piInstitutionId, + piOrcid, + piLinkedinUrl, + piWebsiteUrl, + null, + null, + null, + null, + null, + null); + } + @Test void testFromJsonExternalIdentifierFields() { String json = diff --git a/src/test/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapperTest.java b/src/test/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapperTest.java index 3e979e4f00..34f171cf27 100644 --- a/src/test/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapperTest.java +++ b/src/test/java/org/broadinstitute/consent/http/models/dto/registration/RegistrationRequestMapperTest.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.enumeration.PropertyType; import org.broadinstitute.consent.http.models.DataUse; @@ -203,6 +204,79 @@ void testStudyAssetsAndDataRoundTripNormalizeNumericValues() { assertEquals(5L, roundTripped.get("count")); } + /** A promoted asset list sent as a top-level field is stored in its own study property. */ + @Test + void testPromotedAssetListsBecomeTheirOwnStudyProperties() { + StudyRegistrationRequest request = new StudyRegistrationRequest(); + request.setModels(List.of(Map.of("modelId", "m-1"))); + request.setFunding(List.of(Map.of("grant", "R01"))); + + List props = mapper.toStudyProperties(request); + + assertTrue(findProp(props, "models").isPresent()); + assertTrue(findProp(props, "funding").isPresent()); + // Nothing unpromoted was sent, so no legacy assets property is stored + assertTrue(findProp(props, "assets").isEmpty()); + } + + /** + * Until clients move to the top-level fields, a promoted list sent inside the deprecated assets + * object is still stored as its promoted property, and stripped from the object. + */ + @Test + void testPromotedAssetListsSentInsideTheLegacyObjectAreStillPromoted() { + StudyRegistrationRequest request = new StudyRegistrationRequest(); + request.setAssets( + Map.of( + "models", List.of(Map.of("modelId", "m-1")), + "uiLabels", Map.of("tab", "Assets"))); + + List props = mapper.toStudyProperties(request); + + assertTrue(findProp(props, "models").isPresent()); + Map remaining = + GsonUtil.getInstance() + .fromJson( + findProp(props, "assets").orElseThrow().getValue().toString(), + new TypeToken>() {}.getType()); + assertEquals(Set.of("uiLabels"), remaining.keySet()); + } + + /** A top-level field wins over the same key inside the deprecated object. */ + @Test + void testTopLevelPromotedFieldWinsOverTheLegacyObject() { + StudyRegistrationRequest request = new StudyRegistrationRequest(); + request.setModels(List.of(Map.of("modelId", "top-level"))); + request.setAssets(Map.of("models", List.of(Map.of("modelId", "legacy")))); + + List props = mapper.toStudyProperties(request); + + assertTrue(findProp(props, "models").orElseThrow().getValue().toString().contains("top-level")); + assertTrue(findProp(props, "assets").isEmpty()); + } + + /** + * Registration reads return every promoted list both top-level and inside the deprecated assets + * object, so an edit that removes the last entry arrives as an empty top-level list next to the + * pre-edit legacy copy. The empty list is the submitter's intent and has to win, or the removed + * entry comes straight back on save. + */ + @Test + void testClearedTopLevelPromotedFieldIsNotRestoredFromTheLegacyObject() { + StudyRegistrationRequest request = new StudyRegistrationRequest(); + request.setPublications(List.of()); + request.setAssets(Map.of("publications", List.of(Map.of("title", "removed")))); + + List props = mapper.toStudyProperties(request); + + assertEquals("[]", findProp(props, "publications").orElseThrow().getValue().toString()); + assertTrue(findProp(props, "assets").isEmpty()); + } + + private Optional findProp(List props, String key) { + return props.stream().filter(p -> p.getKey().equals(key)).findFirst(); + } + private void assertDataUse(ConsentGroupRequest consentGroup, DataUse dataUse) { assertEquals(consentGroup.getCol(), dataUse.getCollaboratorRequired()); assertEquals(consentGroup.getDiseaseSpecificUse(), dataUse.getDiseaseRestrictions()); diff --git a/src/test/java/org/broadinstitute/consent/http/resources/MetricsResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/MetricsResourceTest.java index 0fe859fa21..096de0f506 100644 --- a/src/test/java/org/broadinstitute/consent/http/resources/MetricsResourceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/resources/MetricsResourceTest.java @@ -1,18 +1,24 @@ package org.broadinstitute.consent.http.resources; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import com.google.api.client.http.HttpStatusCodes; +import com.google.gson.JsonParser; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; +import java.sql.Timestamp; import java.util.List; +import java.util.Set; import java.util.UUID; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.models.DarMetricsSummary; import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.models.StudyResearchOutputs; import org.broadinstitute.consent.http.service.MetricsService; +import org.broadinstitute.consent.http.util.gson.GsonUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,7 +40,8 @@ void setUp() { @Test void testGenerateDarSummaries() { - when(service.generateDarSummaries(any())).thenReturn(List.of(generateDarMetricsSummary())); + when(service.generateDarSummaries(any(), any())) + .thenReturn(List.of(generateDarMetricsSummary())); Response response = resource.getDarSummaryData(duosUser, 1); assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -42,12 +49,112 @@ void testGenerateDarSummaries() { @Test void testGenerateDarSummariesNotFound() { - when(service.generateDarSummaries(any())).thenThrow(new NotFoundException()); + when(service.generateDarSummaries(any(), any())).thenThrow(new NotFoundException()); Response response = resource.getDarSummaryData(duosUser, 1); assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); } + @Test + void testGenerateStudyDarSummaries() { + when(service.generateStudyDarSummaries(any(), any())) + .thenReturn(List.of(generateDarMetricsSummary())); + + Response response = resource.getStudyDarSummaryData(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + + @Test + void testGenerateStudyDarSummariesNotFound() { + when(service.generateStudyDarSummaries(any(), any())).thenThrow(new NotFoundException()); + + Response response = resource.getStudyDarSummaryData(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + @Test + void testGetStudyResearchOutputs() { + when(service.generateStudyResearchOutputs(any(), any())) + .thenReturn(new StudyResearchOutputs(List.of(), List.of(), List.of())); + + Response response = resource.getStudyResearchOutputs(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + + @Test + void testGetStudyResearchOutputsNotFound() { + when(service.generateStudyResearchOutputs(any(), any())).thenThrow(new NotFoundException()); + + Response response = resource.getStudyResearchOutputs(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + @Test + void testGetSimilarStudies() { + when(service.getSimilarStudies(any(), any())).thenReturn(List.of()); + + Response response = resource.getSimilarStudies(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + + @Test + void testGetSimilarStudiesNotFound() { + when(service.getSimilarStudies(any(), any())).thenThrow(new NotFoundException()); + + Response response = resource.getSimilarStudies(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + @Test + void testGetFrequentlyRequestedWith() { + when(service.getFrequentlyRequestedWith(any(), any())).thenReturn(List.of()); + + Response response = resource.getFrequentlyRequestedWith(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + + @Test + void testGetFrequentlyRequestedWithNotFound() { + when(service.getFrequentlyRequestedWith(any(), any())).thenThrow(new NotFoundException()); + + Response response = resource.getFrequentlyRequestedWith(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + /** + * The requester's name is not in this payload, and the assertion is on the serialized response so + * that it can still fail: pinning it to a record component would only restate the shape of a type + * that no longer carries one. Comparing the whole field set means re-adding a name, under any + * spelling, breaks this rather than passing unnoticed. + */ + @Test + void testDarSummariesCarryNoRequesterName() { + Timestamp now = new Timestamp(System.currentTimeMillis()); + DarMetricsSummary summary = + new DarMetricsSummary( + now, now, "Project", "DAR-1", "Summary", "RUS", "ref-1", "Broad", false); + when(service.generateDarSummaries(any(), any())).thenReturn(List.of(summary)); + + Response response = resource.getDarSummaryData(duosUser, 1); + String json = GsonUtil.getInstance().toJson(response.getEntity()); + Set fields = + JsonParser.parseString(json).getAsJsonArray().get(0).getAsJsonObject().keySet(); + + assertEquals( + Set.of( + "updateDate", + "submissionDate", + "projectTitle", + "darCode", + "nonTechRus", + "rus", + "referenceId", + "institutionName", + "expired"), + fields); + assertFalse(json.toLowerCase().contains("piname"), "No PI name is served with a DAR summary"); + } + private DarMetricsSummary generateDarMetricsSummary() { return new DarMetricsSummary( null, diff --git a/src/test/java/org/broadinstitute/consent/http/resources/ResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/ResourceTest.java index 880360aff9..7baae5beb8 100644 --- a/src/test/java/org/broadinstitute/consent/http/resources/ResourceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/resources/ResourceTest.java @@ -2,12 +2,18 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import jakarta.ws.rs.ForbiddenException; +import java.util.List; +import org.broadinstitute.consent.http.enumeration.UserRoles; import org.broadinstitute.consent.http.models.Error; +import org.broadinstitute.consent.http.models.User; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; import org.jdbi.v3.core.statement.StatementContext; import org.jdbi.v3.core.statement.StatementExceptions; @@ -108,4 +114,34 @@ void testValidateFileDetailsFileSize() { abstractResource.validateFileDetails(fileDetail); }); } + + /** + * A user with no user_role rows has a null role list, not an empty one. Streaming it threw a + * NullPointerException, which Jersey renders as a 500 where the caller should simply have been + * denied. The same shape was fixed in AuthorizationHelper; this is the other site. + */ + @Test + void testValidateAuthedRoleUserDeniesAUserWhoseRolesAreNull() { + User noRoles = new User(); + noRoles.setUserId(1); + assertNull(noRoles.getRoles(), "the case under test is a null role list, not an empty one"); + + Resource resource = new Resource() {}; + List privileged = List.of(UserRoles.ADMIN); + + assertThrows( + ForbiddenException.class, () -> resource.validateAuthedRoleUser(privileged, noRoles, 2)); + } + + /** The same caller asking about themselves is allowed through without consulting roles. */ + @Test + void testValidateAuthedRoleUserAllowsSelfWhenRolesAreNull() { + User noRoles = new User(); + noRoles.setUserId(1); + + Resource resource = new Resource() {}; + List privileged = List.of(UserRoles.ADMIN); + + assertDoesNotThrow(() -> resource.validateAuthedRoleUser(privileged, noRoles, 1)); + } } diff --git a/src/test/java/org/broadinstitute/consent/http/resources/StudyAssetResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/StudyAssetResourceTest.java new file mode 100644 index 0000000000..40f0d768fa --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/resources/StudyAssetResourceTest.java @@ -0,0 +1,85 @@ +package org.broadinstitute.consent.http.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.gson.JsonSyntaxException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import java.util.List; +import org.broadinstitute.consent.http.AbstractTestHelper; +import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.service.StudyAssetService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyAssetResourceTest extends AbstractTestHelper { + + @Mock private StudyAssetService service; + @Mock private DuosUser duosUser; + + private final User user = new User(); + + private StudyAssetResource resource; + + @BeforeEach + void setUp() { + when(duosUser.getUser()).thenReturn(user); + resource = new StudyAssetResource(service); + } + + @Test + void testPublications() { + when(service.getAssetsByType(1, user, "publications")).thenReturn(List.of("pub")); + + Response response = resource.publications(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + assertEquals(List.of("pub"), response.getEntity()); + } + + @Test + void testPublicationsNotFound() { + when(service.getAssetsByType(1, user, "publications")).thenThrow(new NotFoundException()); + + Response response = resource.publications(duosUser, 1); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + @Test + void testListEndpointsDelegateByAssetKey() { + when(service.getAssetsByType(1, user, "models")).thenReturn(List.of("model")); + when(service.getAssetsByType(1, user, "workspaces")).thenReturn(List.of("workspace")); + when(service.getAssetsByType(1, user, "presentations")).thenReturn(List.of("presentation")); + when(service.getAssetsByType(1, user, "clinicalTrials")).thenReturn(List.of("trial")); + when(service.getAssetsByType(1, user, "intellectualProperties")).thenReturn(List.of("ip")); + when(service.getAssetsByType(1, user, "funding")).thenReturn(List.of("grant")); + + assertEquals(List.of("model"), resource.models(duosUser, 1).getEntity()); + assertEquals(List.of("workspace"), resource.workspaces(duosUser, 1).getEntity()); + assertEquals(List.of("presentation"), resource.presentations(duosUser, 1).getEntity()); + assertEquals(List.of("trial"), resource.clinicalTrials(duosUser, 1).getEntity()); + assertEquals(List.of("ip"), resource.intellectualProperty(duosUser, 1).getEntity()); + assertEquals(List.of("grant"), resource.fundingResources(duosUser, 1).getEntity()); + } + + /** + * Every endpoint on this resource reports errors the same way, through createExceptionResponse. + */ + @Test + void testListEndpointsReturnErrorResponsesRatherThanThrowing() { + when(service.getAssetsByType(1, user, "models")).thenThrow(new NotFoundException()); + when(service.getAssetsByType(1, user, "funding")) + .thenThrow(new JsonSyntaxException("malformed assets")); + + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, resource.models(duosUser, 1).getStatus()); + assertEquals( + HttpStatusCodes.STATUS_CODE_BAD_REQUEST, + resource.fundingResources(duosUser, 1).getStatus()); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/resources/StudyCommentResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/StudyCommentResourceTest.java new file mode 100644 index 0000000000..2d5e0d522b --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/resources/StudyCommentResourceTest.java @@ -0,0 +1,254 @@ +package org.broadinstitute.consent.http.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.client.http.HttpStatusCodes; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import java.util.List; +import org.broadinstitute.consent.http.AbstractTestHelper; +import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.models.Error; +import org.broadinstitute.consent.http.models.StudyComment; +import org.broadinstitute.consent.http.models.StudyCommentsSummary; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.service.StudyCommentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyCommentResourceTest extends AbstractTestHelper { + + @Mock private StudyCommentService service; + @Mock private DuosUser duosUser; + + private final User user = new User(); + + private StudyCommentResource resource; + + @BeforeEach + void setUp() { + resource = new StudyCommentResource(service); + } + + @Test + void testList() { + when(duosUser.getUser()).thenReturn(user); + when(service.list(1, user, 25, 0)) + .thenReturn(new StudyCommentsSummary(List.of(), null, 0, null)); + + Response response = resource.list(duosUser, 1, null, null); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + + @Test + void testListNotFound() { + when(duosUser.getUser()).thenReturn(user); + when(service.list(1, user, 25, 0)).thenThrow(new NotFoundException()); + + Response response = resource.list(duosUser, 1, null, null); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } + + /** Absent paging parameters mean the default page, not an unbounded list. */ + @Test + void testListDefaultsToABoundedPage() { + when(duosUser.getUser()).thenReturn(user); + when(service.list(1, user, 25, 0)) + .thenReturn(new StudyCommentsSummary(List.of(), null, 0, null)); + + resource.list(duosUser, 1, null, null); + + verify(service).list(1, user, StudyCommentService.DEFAULT_PAGE_SIZE, 0); + } + + @Test + void testListPassesTheRequestedPage() { + when(duosUser.getUser()).thenReturn(user); + when(service.list(1, user, 10, 20)) + .thenReturn(new StudyCommentsSummary(List.of(), null, 30, null)); + + resource.list(duosUser, 1, 10, 20); + + verify(service).list(1, user, 10, 20); + } + + @ParameterizedTest + @CsvSource({"0, 0", "101, 0", "-1, 0", "25, -1"}) + void testListRejectsAnOutOfRangePage(int limit, int offset) { + Response response = resource.list(duosUser, 1, limit, offset); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + verify(service, never()).list(any(), any(), anyInt(), anyInt()); + } + + @Test + void testPost() { + when(duosUser.getUser()).thenReturn(user); + StudyComment comment = new StudyComment(7, 1, 10, 5, "Great", null, null, "Name", "Inst"); + when(service.post(1, user, 5, "Great")).thenReturn(comment); + + Response response = resource.post(duosUser, 1, "{\"rating\": 5, \"commentText\": \"Great\"}"); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + assertEquals(comment, response.getEntity()); + } + + @Test + void testPostForbidden() { + when(duosUser.getUser()).thenReturn(user); + when(service.post(any(), any(), any(), any())).thenThrow(new ForbiddenException()); + + Response response = resource.post(duosUser, 1, "{\"rating\": 5}"); + assertEquals(HttpStatusCodes.STATUS_CODE_FORBIDDEN, response.getStatus()); + } + + @Test + void testDelete() { + when(duosUser.getUser()).thenReturn(user); + + Response response = resource.delete(duosUser, 1, 7); + assertEquals(HttpStatusCodes.STATUS_CODE_NO_CONTENT, response.getStatus()); + } + + /** An empty or literal-null body is a client error, not a server error. */ + @Test + void testPostWithEmptyBodyIsABadRequest() { + assertEquals( + HttpStatusCodes.STATUS_CODE_BAD_REQUEST, resource.post(duosUser, 1, "").getStatus()); + assertEquals( + HttpStatusCodes.STATUS_CODE_BAD_REQUEST, resource.post(duosUser, 1, "null").getStatus()); + } + + /** + * The rating is an Integer on the payload, so by the time the service sees it there is no way to + * tell a number the client sent from one Gson coerced out of some other JSON type. Assert the + * type at the edge instead: anything that is not a JSON number is a 400, and nothing is written. + */ + @ParameterizedTest + @ValueSource( + strings = { + "{\"rating\": \"4\"}", // a string that looks like a number + "{\"rating\": \"four\"}", + "{\"rating\": true}", + "{\"rating\": [4]}", + "{\"rating\": {\"value\": 4}}", + "{\"rating\": 4.5}", // truncating to 4 would store a rating nobody sent + "{\"rating\": 4.0000001}", + "{\"rating\": 99999999999999999999}" // valid JSON number, too large for an int + }) + void testPostWithNonNumericRatingIsABadRequest(String body) { + Response response = resource.post(duosUser, 1, body); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + Error error = assertInstanceOf(Error.class, response.getEntity()); + assertEquals("Rating must be a whole number between 1 and 5.", error.message()); + verify(service, never()).post(any(), any(), any(), any()); + } + + /** A whole number sent as a JSON number is the only accepted form, including 4.0. */ + @ParameterizedTest + @ValueSource(strings = {"{\"rating\": 4}", "{\"rating\": 4.0}"}) + void testPostAcceptsWholeJsonNumberRatings(String body) { + when(duosUser.getUser()).thenReturn(user); + StudyComment comment = new StudyComment(7, 1, 10, 4, null, null, null, "Name", "Inst"); + when(service.post(1, user, 4, null)).thenReturn(comment); + + Response response = resource.post(duosUser, 1, body); + + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + verify(service).post(1, user, 4, null); + } + + /** + * An out-of-range rating is still the service's call to make, so a numeric rating has to reach it + * even when it is obviously invalid - otherwise the two error messages would swap. + */ + @Test + void testPostPassesOutOfRangeNumericRatingToTheService() { + when(duosUser.getUser()).thenReturn(user); + when(service.post(eq(1), eq(user), eq(9), any())) + .thenThrow(new BadRequestException("Rating must be between 1 and 5.")); + + Response response = resource.post(duosUser, 1, "{\"rating\": 9}"); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + verify(service).post(1, user, 9, null); + } + + /** An absent or explicitly null rating is the service's range error, not a type error. */ + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"rating\": null}", "{\"commentText\": \"no rating\"}"}) + void testPostWithMissingRatingReachesTheServiceAsNull(String body) { + when(duosUser.getUser()).thenReturn(user); + when(service.post(eq(1), eq(user), eq(null), any())) + .thenThrow(new BadRequestException("Rating must be between 1 and 5.")); + + Response response = resource.post(duosUser, 1, body); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + Error error = assertInstanceOf(Error.class, response.getEntity()); + assertEquals("Rating must be between 1 and 5.", error.message()); + } + + /** + * A body that parses but is not a JSON object gets the payload message, never a Gson stacktrace. + */ + @ParameterizedTest + @ValueSource(strings = {"\"hello\"", "[]", "4", "true", "", "null", "{not json}"}) + void testPostWithNonObjectBodyIsABadRequest(String body) { + Response response = resource.post(duosUser, 1, body); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + Error error = assertInstanceOf(Error.class, response.getEntity()); + assertEquals("Comment payload must be a JSON object", error.message()); + verify(service, never()).post(any(), any(), any(), any()); + } + + @Test + void testPostWithNonStringCommentTextIsABadRequest() { + Response response = resource.post(duosUser, 1, "{\"rating\": 4, \"commentText\": {\"a\": 1}}"); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + verify(service, never()).post(any(), any(), any(), any()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "{\"rating\": 4, \"commentText\": 123}", + "{\"rating\": 4, \"commentText\": true}", + "{\"rating\": 4, \"commentText\": [\"a\"]}" + }) + void testPostWithNonStringCommentTextPrimitiveIsABadRequest(String body) { + // A JSON number or boolean is a primitive too, and getAsString turned 123 into "123". + Response response = resource.post(duosUser, 1, body); + + assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); + verify(service, never()).post(any(), any(), any(), any()); + } + + @Test + void testDeleteNotFound() { + when(duosUser.getUser()).thenReturn(user); + doThrow(new NotFoundException()).when(service).delete(1, 7, user); + + Response response = resource.delete(duosUser, 1, 7); + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/resources/StudyResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/StudyResourceTest.java index cb93179550..6d3bf78d9e 100644 --- a/src/test/java/org/broadinstitute/consent/http/resources/StudyResourceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/resources/StudyResourceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -128,7 +129,7 @@ void testGetStudyByIdNoDatasets() { study.setName("asdfasdfasdfasdfasdfasdf"); when(datasetService.getStudyWithDatasetsById(user, study.getStudyId())).thenReturn(study); when(duosUser.getUser()).thenReturn(user); - when(datasetService.isCreatorCustodianOrAdmin(user, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, user)).thenReturn(study); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -154,7 +155,7 @@ void testGetStudyByIdWithDatasets() { when(datasetService.getStudyWithDatasetsById(user, study.getStudyId())).thenReturn(study); when(duosUser.getUser()).thenReturn(user); - when(datasetService.isCreatorCustodianOrAdmin(user, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, user)).thenReturn(study); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -181,7 +182,8 @@ void testGetStudyByIdNotPublicGeneralUser() { when(duosUser.getUser()).thenReturn(generalUser); when(datasetService.getStudyWithDatasetsById(duosUser.getUser(), study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(generalUser, study)).thenReturn(false); + when(datasetService.verifyStudyVisibilityAccess(study, generalUser)) + .thenThrow(new NotFoundException("Study not found")); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); @@ -197,7 +199,7 @@ void testGetStudyByIdNotPublicCreateUser() { when(duosUser.getUser()).thenReturn(createUser); when(datasetService.getStudyWithDatasetsById(duosUser.getUser(), study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(createUser, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, createUser)).thenReturn(study); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -209,7 +211,7 @@ void testGetRegistrationFromStudy() { Study study = createMockStudy(); when(datasetService.getStudyWithDatasetsById(user, study.getStudyId())).thenReturn(study); when(duosUser.getUser()).thenReturn(user); - when(datasetService.isCreatorCustodianOrAdmin(user, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, user)).thenReturn(study); try (var response = resource.getRegistrationFromStudy(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -222,7 +224,7 @@ void testGetRegistrationFromStudyNoDatasets() { study.getDatasets().clear(); when(datasetService.getStudyWithDatasetsById(user, study.getStudyId())).thenReturn(study); when(duosUser.getUser()).thenReturn(user); - when(datasetService.isCreatorCustodianOrAdmin(user, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, user)).thenReturn(study); try (var response = resource.getRegistrationFromStudy(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -249,7 +251,8 @@ void testGetRegistrationFromStudyNotPublicGeneralUser() { when(duosUser.getUser()).thenReturn(generalUser); when(datasetService.getStudyWithDatasetsById(generalUser, study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(generalUser, study)).thenReturn(false); + when(datasetService.verifyStudyVisibilityAccess(study, generalUser)) + .thenThrow(new NotFoundException("Study not found")); try (var response = resource.getRegistrationFromStudy(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); @@ -265,7 +268,7 @@ void testGetRegistrationFromStudyNotPublicCreateUser() { when(duosUser.getUser()).thenReturn(createUser); when(datasetService.getStudyWithDatasetsById(duosUser.getUser(), study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(createUser, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, createUser)).thenReturn(study); try (var response = resource.getRegistrationFromStudy(duosUser, study.getStudyId())) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); @@ -593,7 +596,7 @@ void testCheckPublicVisibilityForUser_PublicStudy_ApprovedRole() { approvedUser.setUserId(study.getCreateUserId()); when(datasetService.getStudyWithDatasetsById(approvedUser, study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(approvedUser, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, approvedUser)).thenReturn(study); when(duosUser.getUser()).thenReturn(approvedUser); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -609,7 +612,7 @@ void testCheckPublicVisibilityForUser_PublicStudy_NoApprovedRole() { generalUser.setUserId(randomInt(1000, 1100)); when(datasetService.getStudyWithDatasetsById(generalUser, study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(generalUser, study)).thenReturn(false); + when(datasetService.verifyStudyVisibilityAccess(study, generalUser)).thenReturn(study); when(duosUser.getUser()).thenReturn(generalUser); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -624,7 +627,7 @@ void testCheckPublicVisibilityForUser_PrivateStudy_Creator() { User creator = new User(); creator.setUserId(study.getCreateUserId()); when(datasetService.getStudyWithDatasetsById(creator, study.getStudyId())).thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(creator, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, creator)).thenReturn(study); when(duosUser.getUser()).thenReturn(creator); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -639,7 +642,7 @@ void testCheckPublicVisibilityForUser_PrivateStudy_Custodian() { User custodian = new User(); custodian.setUserId(randomInt(1000, 1100)); when(datasetService.getStudyWithDatasetsById(custodian, study.getStudyId())).thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(custodian, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, custodian)).thenReturn(study); when(duosUser.getUser()).thenReturn(custodian); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -655,7 +658,7 @@ void testCheckPublicVisibilityForUser_PrivateStudy_Admin() { admin.setUserId(randomInt(1000, 1100)); admin.setAdminRole(); when(datasetService.getStudyWithDatasetsById(admin, study.getStudyId())).thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); when(duosUser.getUser()).thenReturn(admin); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -671,7 +674,8 @@ void testCheckPublicVisibilityForUser_PrivateStudy_NoApprovedRole() { generalUser.setUserId(randomInt(1000, 1100)); when(datasetService.getStudyWithDatasetsById(generalUser, study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(generalUser, study)).thenReturn(false); + when(datasetService.verifyStudyVisibilityAccess(study, generalUser)) + .thenThrow(new NotFoundException("Study not found")); when(duosUser.getUser()).thenReturn(generalUser); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { @@ -679,19 +683,39 @@ void testCheckPublicVisibilityForUser_PrivateStudy_NoApprovedRole() { } } + // A study whose public_visibility is NULL (the column is nullable) reads as "not public". + // An approved user still sees it; anyone else gets a 404 rather than the 500 this used to + // produce by unboxing the null before checking the role. @Test - void testCheckPublicVisibilityForUser_PublicVisibilityNull_CausesError() { + void testCheckPublicVisibilityForUser_PublicVisibilityNull() { Study study = createMockStudy(); study.setPublicVisibility(null); User approvedUser = new User(); approvedUser.setUserId(study.getCreateUserId()); when(datasetService.getStudyWithDatasetsById(approvedUser, study.getStudyId())) .thenReturn(study); - when(datasetService.isCreatorCustodianOrAdmin(approvedUser, study)).thenReturn(true); + when(datasetService.verifyStudyVisibilityAccess(study, approvedUser)).thenReturn(study); when(duosUser.getUser()).thenReturn(approvedUser); try (var response = resource.getStudyById(duosUser, study.getStudyId())) { - assertEquals(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, response.getStatus()); + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + } + + @Test + void testCheckPublicVisibilityForUser_PublicVisibilityNull_NoApprovedRole() { + Study study = createMockStudy(); + study.setPublicVisibility(null); + User generalUser = new User(); + generalUser.setUserId(randomInt(1000, 1100)); + when(datasetService.getStudyWithDatasetsById(generalUser, study.getStudyId())) + .thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, generalUser)) + .thenThrow(new NotFoundException("Study not found")); + when(duosUser.getUser()).thenReturn(generalUser); + + try (var response = resource.getStudyById(duosUser, study.getStudyId())) { + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_FOUND, response.getStatus()); } } @@ -702,6 +726,10 @@ void testPatchStudyById() { admin.setAdminRole(); admin.setUserId(study.getCreateUserId()); when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); + // The PATCH gate additionally requires ownership of this study, not merely a + // study-editing role plus read visibility. + when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); when(duosUser.getUser()).thenReturn(admin); String patchJson = """ @@ -717,6 +745,84 @@ void testPatchStudyById() { } } + /** + * The role gate on the endpoint only says the caller holds a study-editing role somewhere in + * DUOS. A publicly visible study is readable by everyone, so read visibility cannot stand in for + * write authorization: patching it still requires being its creator, a custodian, or an admin. + */ + @Test + void testPatchStudyByIdForbiddenForNonOwnerOfPublicStudy() { + Study study = createMockStudy(); + User chairperson = new User(); + chairperson.setUserId(study.getCreateUserId() + 1); + when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.isCreatorCustodianOrAdmin(chairperson, study)).thenReturn(false); + when(duosUser.getUser()).thenReturn(chairperson); + String patchJson = + """ + { + "piOrcid": "0000-0002-1825-0097" + } + """; + try (var response = resource.patchStudyById(duosUser, study.getStudyId(), patchJson)) { + assertEquals(HttpStatusCodes.STATUS_CODE_FORBIDDEN, response.getStatus()); + } + verify(datasetService, never()).patchStudy(any(), any(), any()); + } + + /** + * Deleting an institution nulls study.pi_institution_id through the foreign key without any + * patch, leaving the legacy piInstitution property behind - and SchemaFromStudy falls back to + * that property whenever the column is null, so the deleted institution resurfaces in the next + * registration payload. Clearing it is a patch of {"piInstitutionId": null}, which changes no + * stored value. That must not be answered with 304, or the property can never be retired. + */ + @Test + void testPatchStudyByIdRetiresTheLegacyInstitutionPropertyWhenTheColumnIsAlreadyNull() { + Study study = createMockStudy(); + study.setPiInstitution(null); + StudyProperty legacy = new StudyProperty(); + legacy.setKey("piInstitution"); + legacy.setType(PropertyType.Number); + legacy.setValue(7); + study.addProperties(legacy); + + User admin = new User(); + admin.setAdminRole(); + admin.setUserId(study.getCreateUserId()); + when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); + when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); + when(duosUser.getUser()).thenReturn(admin); + when(datasetService.patchStudy(eq(study.getStudyId()), eq(admin), any())).thenReturn(study); + + try (var response = + resource.patchStudyById(duosUser, study.getStudyId(), "{\"piInstitutionId\": null}")) { + assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); + } + verify(datasetService).patchStudy(eq(study.getStudyId()), eq(admin), any()); + } + + /** With no legacy property there is nothing to retire, so the same patch is still 304. */ + @Test + void testPatchStudyByIdStillNotModifiedWithoutTheLegacyProperty() { + Study study = createMockStudy(); + study.setPiInstitution(null); + + User admin = new User(); + admin.setAdminRole(); + admin.setUserId(study.getCreateUserId()); + when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); + when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); + when(duosUser.getUser()).thenReturn(admin); + + try (var response = + resource.patchStudyById(duosUser, study.getStudyId(), "{\"piInstitutionId\": null}")) { + assertEquals(HttpStatusCodes.STATUS_CODE_NOT_MODIFIED, response.getStatus()); + } + } + @Test void testPatchStudyByIdNotFound() { Study study = createMockStudy(); @@ -736,6 +842,11 @@ void testPatchStudyByIdNotModified() { admin.setAdminRole(); admin.setUserId(study.getCreateUserId()); when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); + // The PATCH gate additionally requires ownership of this study, not merely a + // study-editing role plus read visibility. + when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); + when(duosUser.getUser()).thenReturn(admin); try (var response = resource.patchStudyById(duosUser, study.getStudyId(), "{}")) { assertEquals(HttpStatusCodes.STATUS_CODE_NOT_MODIFIED, response.getStatus()); } @@ -758,6 +869,11 @@ void testPatchStudyByIdInvalidPatch(String json) { admin.setAdminRole(); admin.setUserId(study.getCreateUserId()); when(datasetService.findStudy(study.getStudyId())).thenReturn(study); + when(datasetService.verifyStudyVisibilityAccess(study, admin)).thenReturn(study); + // The PATCH gate additionally requires ownership of this study, not merely a + // study-editing role plus read visibility. + when(datasetService.isCreatorCustodianOrAdmin(admin, study)).thenReturn(true); + when(duosUser.getUser()).thenReturn(admin); try (var response = resource.patchStudyById(duosUser, study.getStudyId(), json)) { assertEquals(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, response.getStatus()); } diff --git a/src/test/java/org/broadinstitute/consent/http/service/DatasetRegistrationServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/DatasetRegistrationServiceTest.java index d5592cb31e..4733d75dc6 100644 --- a/src/test/java/org/broadinstitute/consent/http/service/DatasetRegistrationServiceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/service/DatasetRegistrationServiceTest.java @@ -1075,6 +1075,41 @@ void testUpdateStudyFromRegistrationCapturesPiEmail() throws Exception { assertEquals(schema.getPiEmail(), studyUpdateCaptor.getValue().piEmail()); } + /** + * Registration carries piInstitution but not the PI's profile links, which are PATCH-only, so an + * update takes the institution from the payload and carries the stored links forward. Without + * this the study page's PI Institution row stays blank until someone PATCHes it. + */ + @Test + void testUpdateStudyFromRegistrationSetsThePiInstitutionAndKeepsTheProfileLinks() + throws Exception { + User user = mock(); + StudyUpdateRequest schema = + createRandomCompleteDatasetRegistration(user, StudyUpdateRequest::new); + schema.setPiInstitution(7); + Study study = mock(); + + when(dacDAO.findById(any())).thenReturn(new Dac()); + ArgumentCaptor studyUpdateCaptor = + ArgumentCaptor.forClass(DatasetServiceDAO.StudyUpdate.class); + when(datasetServiceDAO.updateStudy(studyUpdateCaptor.capture(), any(), any())) + .thenReturn(study); + when(study.getDatasets()).thenReturn(Set.of()); + + datasetRegistrationService.updateStudyFromRegistration(1, schema, user, Map.of()); + + // The institution comes from the payload; the links are marked to be filled in from the + // study the write transaction loads, rather than read here through a second whole-study fetch. + DatasetServiceDAO.StudyPiDetails piDetails = studyUpdateCaptor.getValue().piDetails(); + assertEquals(7, piDetails.piInstitutionId()); + assertTrue(piDetails.keepStoredLinks()); + assertNull(piDetails.piOrcid()); + assertNull(piDetails.piLinkedinUrl()); + assertNull(piDetails.piWebsiteUrl()); + // ...and the study is not read again just to copy three strings + verify(studyDAO, never()).findStudyById(1); + } + @Test void testUpdateStudyFromRegistrationExcludesAccessManagementForExistingConsentGroup() throws Exception { diff --git a/src/test/java/org/broadinstitute/consent/http/service/DatasetServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/DatasetServiceTest.java index e44bf615f5..ba6a281931 100644 --- a/src/test/java/org/broadinstitute/consent/http/service/DatasetServiceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/service/DatasetServiceTest.java @@ -59,6 +59,7 @@ import org.broadinstitute.consent.http.models.DatasetProperty; import org.broadinstitute.consent.http.models.DatasetStudySummary; import org.broadinstitute.consent.http.models.Dictionary; +import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; import org.broadinstitute.consent.http.models.StudyConversion; import org.broadinstitute.consent.http.models.StudyPatch; @@ -68,6 +69,8 @@ import org.broadinstitute.consent.http.models.dataset_registration_v1.ConsentGroup.AccessManagement; import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1.StudyType; import org.broadinstitute.consent.http.models.dataset_registration_v1.builder.DatasetRegistrationSchemaV1Builder; +import org.broadinstitute.consent.http.service.DatasetService.DatasetRead; +import org.broadinstitute.consent.http.service.DatasetService.DatasetReadBasis; import org.broadinstitute.consent.http.service.dao.DatasetServiceDAO; import org.broadinstitute.consent.http.util.gson.GsonUtil; import org.jdbi.v3.core.Jdbi; @@ -209,6 +212,61 @@ void testFindDatasetByIdForReadForbidden() { ForbiddenException.class, () -> datasetService.findDatasetByIdForRead(user, datasetId)); } + /** + * A study's data custodian reads its datasets on the study's terms, so the basis reported is + * STUDY_READABLE and the requester's institution survives. Custodianship is a property of the + * study - isCreatorOrCustodian answers false for a null one - so there is no custodian to + * distinguish on a dataset that has no study, and every caller there is NO_STUDY. + */ + @Test + void testFindDatasetByIdForReadWithBasisStudyCustodianReadsOnTheStudysTerms() { + User custodian = new User(); + custodian.setUserId(1); + custodian.setEmail("alice@custodiansRus.org"); + + Study study = new Study(); + study.setStudyId(7); + study.setCreateUserId(custodian.getUserId() + 1); + study.setPublicVisibility(false); + StudyProperty property = new StudyProperty(); + property.setKey(dataCustodianEmail); + property.setType(PropertyType.Json); + property.setValue(GsonUtil.getInstance().toJson(List.of(custodian.getEmail()))); + study.addProperties(property); + + Dataset dataset = new Dataset(); + dataset.setDatasetId(5); + dataset.setCreateUserId(study.getCreateUserId()); + dataset.setStudyId(study.getStudyId()); + dataset.setStudy(study); + when(datasetDAO.findDatasetById(dataset.getDatasetId())).thenReturn(dataset); + + DatasetRead read = + datasetService.findDatasetByIdForReadWithBasis(custodian, dataset.getDatasetId()); + + assertEquals(DatasetReadBasis.STUDY_READABLE, read.basis()); + assertEquals(dataset.getDatasetId(), read.dataset().getDatasetId()); + } + + /** The contrast: with no study there is nothing to be a custodian of, and nothing is checked. */ + @Test + void testFindDatasetByIdForReadWithBasisReportsNoStudy() { + User caller = new User(); + caller.setUserId(1); + caller.setEmail("alice@custodiansRus.org"); + + Dataset dataset = new Dataset(); + dataset.setDatasetId(6); + dataset.setCreateUserId(caller.getUserId() + 1); + dataset.setStudyId(null); + when(datasetDAO.findDatasetById(dataset.getDatasetId())).thenReturn(dataset); + + DatasetRead read = + datasetService.findDatasetByIdForReadWithBasis(caller, dataset.getDatasetId()); + + assertEquals(DatasetReadBasis.NO_STUDY, read.basis()); + } + @Test void testFindStudyByIdForRead() { User user = new User(); @@ -230,8 +288,12 @@ void testFindStudyByIdForReadNotFound() { assertThrows(NotFoundException.class, () -> datasetService.findStudyByIdForRead(mockUser, 99)); } + /** + * A study the caller may not read is reported absent, not forbidden - the same answer + * verifyStudyVisibilityAccess gives, so the study's files and its registration assets agree. + */ @Test - void testFindStudyByIdForReadForbidden() { + void testFindStudyByIdForReadHiddenStudyIsNotFound() { User user = new User(); user.setUserId(1); user.setEmail("user@email.com"); @@ -247,8 +309,7 @@ void testFindStudyByIdForReadForbidden() { int studyId = study.getStudyId(); when(studyDAO.findStudyById(study.getStudyId())).thenReturn(study); - assertThrows( - ForbiddenException.class, () -> datasetService.findStudyByIdForRead(user, studyId)); + assertThrows(NotFoundException.class, () -> datasetService.findStudyByIdForRead(user, studyId)); } @Test @@ -1097,8 +1158,11 @@ void testVerifyPublicVisibilityAccess_VisibleNull() { dataset.setStudy(study); dataset.setStudyId(study.getStudyId()); - Dataset verfiedDataset = datasetService.verifyPublicVisibilityAccess(dataset, user); - assertEquals(dataset.getDatasetId(), verfiedDataset.getDatasetId()); + // An unset public_visibility is no longer treated as published: the caller is neither the + // dataset's creator nor the study's, so the dataset is withheld. It previously came back, + // which is what let a null-visibility study stay readable here while the study endpoints + // returned 404 for it. + assertNull(datasetService.verifyPublicVisibilityAccess(dataset, user)); } @Test @@ -1180,11 +1244,94 @@ void testVerifyPublicVisibilityAccess_DatasetCreatorWithHiddenStudy() { study.setStudyId(dataset.getStudyId()); study.setCreateUserId(datasetCreator.getUserId() + 1); study.setPublicVisibility(Boolean.FALSE); - when(studyDAO.findStudyDetailsById(dataset.getStudyId())).thenReturn(study); Dataset verifiedDataset = datasetService.verifyPublicVisibilityAccess(dataset, datasetCreator); assertEquals(dataset.getDatasetId(), verifiedDataset.getDatasetId()); + // The creator is recognized before the study is consulted, so the hidden study is never read. + // Same answer as before, one query fewer. + verify(studyDAO, never()).findStudyDetailsById(dataset.getStudyId()); + } + + // verifyStudyVisibilityAccess is the single read-access gate shared by StudyResource and the + // study asset, comment, and metrics endpoints. + @Test + void testVerifyStudyVisibilityAccess_PublicStudyIsReadableByAnyone() { + Study study = new Study(); + study.setStudyId(1); + study.setCreateUserId(1); + study.setPublicVisibility(true); + User generalUser = new User(); + generalUser.setUserId(2); + generalUser.setEmail("general@email.com"); + + assertEquals(study, datasetService.verifyStudyVisibilityAccess(study, generalUser)); + } + + @Test + void testVerifyStudyVisibilityAccess_PrivateStudyIsHiddenFromOtherUsers() { + Study study = new Study(); + study.setStudyId(1); + study.setCreateUserId(1); + study.setCreateUserEmail("creator@email.com"); + study.setPublicVisibility(false); + User generalUser = new User(); + generalUser.setUserId(2); + generalUser.setEmail("general@email.com"); + + assertThrows( + NotFoundException.class, + () -> datasetService.verifyStudyVisibilityAccess(study, generalUser)); + } + + @Test + void testVerifyStudyVisibilityAccess_PrivateStudyIsReadableByCreatorAndAdmin() { + Study study = new Study(); + study.setStudyId(1); + study.setCreateUserId(1); + study.setCreateUserEmail("creator@email.com"); + study.setPublicVisibility(false); + User creator = new User(); + creator.setUserId(1); + creator.setEmail("creator@email.com"); + User admin = new User(); + admin.setUserId(3); + admin.setEmail("admin@email.com"); + admin.setAdminRole(); + + assertEquals(study, datasetService.verifyStudyVisibilityAccess(study, creator)); + assertEquals(study, datasetService.verifyStudyVisibilityAccess(study, admin)); + } + + // The public_visibility column is nullable; a null reads as "not public". + @Test + void testVerifyStudyVisibilityAccess_NullVisibilityIsNotPublic() { + Study study = new Study(); + study.setStudyId(1); + study.setCreateUserId(1); + study.setCreateUserEmail("creator@email.com"); + study.setPublicVisibility(null); + User generalUser = new User(); + generalUser.setUserId(2); + generalUser.setEmail("general@email.com"); + User creator = new User(); + creator.setUserId(1); + creator.setEmail("creator@email.com"); + + assertThrows( + NotFoundException.class, + () -> datasetService.verifyStudyVisibilityAccess(study, generalUser)); + assertEquals(study, datasetService.verifyStudyVisibilityAccess(study, creator)); + } + + @Test + void testVerifyStudyVisibilityAccess_NullStudyIsNotFound() { + User generalUser = new User(); + generalUser.setUserId(2); + + assertThrows( + NotFoundException.class, + () -> datasetService.verifyStudyVisibilityAccess(null, generalUser)); } @Test @@ -1312,6 +1459,10 @@ void testPatchStudy() throws Exception { null, null, null, + null, + null, + null, + null, true, null, null); @@ -1632,6 +1783,18 @@ void testConvertDatasetToStudy_AdminUpdatesExistingStudy() { Study existingStudy = new Study(); existingStudy.setStudyId(77); + // Stored PI details a conversion does not carry, so it must pass them straight through + Institution institution = new Institution(); + institution.setId(9); + existingStudy.setPiInstitution(institution); + existingStudy.setPiOrcid("0000-0002-1825-0097"); + existingStudy.setPiLinkedinUrl("https://linkedin.com/in/example"); + existingStudy.setPiWebsiteUrl("https://example.org"); + conversion.setDescription("A converted study"); + conversion.setPiName("Dr Convert"); + conversion.setPiEmail("convert@example.org"); + conversion.setPublicVisibility(true); + conversion.setDataTypes(List.of("Genomic")); when(studyDAO.findStudyByName("Existing Study")).thenReturn(existingStudy); when(studyDAO.findStudyById(77)).thenReturn(existingStudy); @@ -1640,7 +1803,24 @@ void testConvertDatasetToStudy_AdminUpdatesExistingStudy() { Study result = datasetService.convertDatasetToStudy(admin, dataset, conversion); assertNotNull(result); - verify(studyDAO).updateStudy(any(), any(), any(), any(), any(), any(), any(), any(), any()); + // Named rather than any() for every argument: with several adjacent String parameters, an + // any() assertion would pass just as happily if two of them were transposed. + verify(studyDAO) + .updateStudy( + eq(77), + eq("Existing Study"), + eq("A converted study"), + eq("Dr Convert"), + eq("convert@example.org"), + eq(9), + eq("0000-0002-1825-0097"), + eq("https://linkedin.com/in/example"), + eq("https://example.org"), + eq(List.of("Genomic")), + eq(true), + // The dataset's creator, not the admin performing the conversion + eq(5), + any(Instant.class)); verify(studyDAO, never()) .insertStudy(any(), any(), any(), any(), any(), any(), any(), any(), any()); } @@ -1782,7 +1962,8 @@ void testPatchStudy_ExceptionThrowsInternalServerError() throws Exception { user.setUserId(1); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null); when(studyDAO.findStudyById(1)).thenReturn(study); when(datasetServiceDAO.patchStudy(any(), any(), any())) @@ -1792,6 +1973,30 @@ void testPatchStudy_ExceptionThrowsInternalServerError() throws Exception { InternalServerErrorException.class, () -> datasetService.patchStudy(1, user, patch)); } + /** + * A rejected patch has to keep its own status. The catch-all above it used to rewrap every + * exception, so "PI institution 999999 does not exist" reached the caller as an opaque 500. + */ + @Test + void testPatchStudy_BadRequestKeepsItsStatus() throws Exception { + Study study = new Study(); + study.setStudyId(1); + User user = new User(); + user.setUserId(1); + StudyPatch patch = + new StudyPatch( + null, null, null, null, null, null, null, null, 999999, null, null, null, null, null, + null, null, null, null); + + when(studyDAO.findStudyById(1)).thenReturn(study); + when(datasetServiceDAO.patchStudy(any(), any(), any())) + .thenThrow(new BadRequestException("PI institution 999999 does not exist")); + + BadRequestException thrown = + assertThrows(BadRequestException.class, () -> datasetService.patchStudy(1, user, patch)); + assertEquals("PI institution 999999 does not exist", thrown.getMessage()); + } + // ==================== findDatasetsByIds ==================== @Test @@ -1832,6 +2037,55 @@ void testFindDatasetsByIds_FilteredByVisibility() { assertEquals(0, result.size()); } + // ============= verifyPublicVisibilityAccess(Dataset, User) – null visibility ============= + + /** + * The dataset route reaches the same rule through canReadStudy, so a study whose + * public_visibility is null is hidden here too. Before the rule was unified, such a study + * returned 404 from the study endpoints while its datasets stayed readable. + */ + @Test + void testVerifyPublicVisibilityAccess_Dataset_PublicVisibilityNull_NotCreator() { + User user = new User(); + user.setUserId(1); + user.setEmail("user@test.com"); + Study study = studyWithNullVisibility(99); + Dataset dataset = new Dataset(); + dataset.setDatasetId(5); + dataset.setCreateUserId(3); + dataset.setStudyId(study.getStudyId()); + dataset.setStudy(study); + + assertNull(datasetService.verifyPublicVisibilityAccess(dataset, user)); + } + + @Test + void testVerifyPublicVisibilityAccess_Dataset_PublicVisibilityNull_Creator() { + User creator = new User(); + creator.setUserId(1); + creator.setEmail("creator@test.com"); + Study study = studyWithNullVisibility(creator.getUserId()); + Dataset dataset = new Dataset(); + dataset.setDatasetId(5); + dataset.setCreateUserId(creator.getUserId()); + dataset.setStudyId(study.getStudyId()); + dataset.setStudy(study); + + assertEquals(dataset, datasetService.verifyPublicVisibilityAccess(dataset, creator)); + } + + private Study studyWithNullVisibility(Integer createUserId) { + Study study = new Study(); + study.setStudyId(7); + study.setCreateUserId(createUserId); + study.setPublicVisibility(null); + StudyProperty property = new StudyProperty(); + property.setKey("other"); + property.setValue("[]"); + study.addProperties(property); + return study; + } + // ==================== canReadStudy ==================== @Test @@ -1849,13 +2103,28 @@ void testCanReadStudy_Admin() { assertTrue(datasetService.canReadStudy(admin, study)); } + /** + * public_visibility is nullable, and a null now reads as "not published" rather than as public. + * It previously read as public here while the dataset study summaries treated it as private, so + * the same study was readable through one route and hidden on another. + */ @Test void testCanReadStudy_PublicVisibilityNull() { User user = new User(); user.setUserId(1); + user.setEmail("user@test.com"); Study study = new Study(); - // publicVisibility null → !Boolean.FALSE.equals(null) is true → readable - assertTrue(datasetService.canReadStudy(user, study)); + study.setCreateUserId(99); + assertFalse(datasetService.canReadStudy(user, study)); + } + + @Test + void testCanReadStudy_PublicVisibilityNullForCreator() { + User creator = new User(); + creator.setUserId(1); + Study study = new Study(); + study.setCreateUserId(creator.getUserId()); + assertTrue(datasetService.canReadStudy(creator, study)); } @Test diff --git a/src/test/java/org/broadinstitute/consent/http/service/MetricsServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/MetricsServiceTest.java index 98a3f0295f..5d77386b6e 100644 --- a/src/test/java/org/broadinstitute/consent/http/service/MetricsServiceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/service/MetricsServiceTest.java @@ -1,19 +1,37 @@ package org.broadinstitute.consent.http.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import java.util.UUID; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.db.DataAccessRequestDAO; -import org.broadinstitute.consent.http.db.DatasetDAO; +import org.broadinstitute.consent.http.db.StudyRecommendationDAO; import org.broadinstitute.consent.http.models.DarMetricsSummary; +import org.broadinstitute.consent.http.models.DataAccessRequest; +import org.broadinstitute.consent.http.models.DataAccessRequestData; import org.broadinstitute.consent.http.models.Dataset; +import org.broadinstitute.consent.http.models.IntellectualProperty; +import org.broadinstitute.consent.http.models.Presentation; +import org.broadinstitute.consent.http.models.Publication; +import org.broadinstitute.consent.http.models.Study; +import org.broadinstitute.consent.http.models.StudyRecommendation; +import org.broadinstitute.consent.http.models.StudyResearchOutputs; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.service.DatasetService.DatasetRead; +import org.broadinstitute.consent.http.service.DatasetService.DatasetReadBasis; import org.jdbi.v3.core.Jdbi; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,41 +44,334 @@ class MetricsServiceTest extends AbstractTestHelper { @Mock private Jdbi jdbi; - @Mock private DatasetDAO dataSetDAO; - @Mock private DataAccessRequestDAO darDAO; + @Mock private StudyRecommendationDAO recommendationDAO; + + @Mock private DatasetService datasetService; + + private final User user = new User(); + private MetricsService service; @BeforeEach void initService() { - when(jdbi.onDemand(DatasetDAO.class)).thenReturn(dataSetDAO); when(jdbi.onDemand(DataAccessRequestDAO.class)).thenReturn(darDAO); - service = new MetricsService(jdbi); + when(jdbi.onDemand(StudyRecommendationDAO.class)).thenReturn(recommendationDAO); + service = new MetricsService(jdbi, datasetService); + } + + /** The study exists and the requesting user may read it. */ + private void studyIsVisible() { + when(datasetService.requireReadableStudy(eq(1), any())).thenReturn(new Study()); + } + + /** + * The study is not readable by this caller - either it does not exist, or it is not publicly + * visible and the caller is neither a custodian nor an admin. The shared gate answers both the + * same way, so the study's metrics cannot be used to tell one case from the other. + */ + private void studyIsNotReadable() { + when(datasetService.requireReadableStudy(eq(1), any())) + .thenThrow(new NotFoundException("Study not found")); } @Test void testGenerateDarSummaries() { DarMetricsSummary summary = generateDarMetricsSummary(); Dataset dataset = generateDataset(); + dataset.setStudyId(10); - when(dataSetDAO.findDatasetIdById(dataset.getDatasetId())).thenReturn(dataset.getDatasetId()); + when(datasetService.findDatasetByIdForReadWithBasis(user, dataset.getDatasetId())) + .thenReturn(new DatasetRead(dataset, DatasetReadBasis.STUDY_READABLE)); when(darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any())) .thenReturn(List.of(summary)); - List metrics = service.generateDarSummaries(dataset.getDatasetId()); + List metrics = service.generateDarSummaries(dataset.getDatasetId(), user); assertEquals(summary.projectTitle(), metrics.getFirst().projectTitle()); assertEquals(summary.darCode(), metrics.getFirst().darCode()); - verify(dataSetDAO).findDatasetIdById(dataset.getDatasetId()); + verify(datasetService).findDatasetByIdForReadWithBasis(user, dataset.getDatasetId()); verify(darDAO).findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(dataset.getDatasetId()); } + /** + * A dataset that belongs to a study was gated on that study's visibility, so the requester it + * names was already readable by this caller. + */ + @Test + void testGenerateDarSummariesKeepsRequesterIdentityForAStudysDataset() { + Dataset dataset = generateDataset(); + dataset.setStudyId(10); + DarMetricsSummary summary = + new DarMetricsSummary(null, null, "Project", "DAR-1", null, null, "ref-1", "Broad", false); + + when(datasetService.findDatasetByIdForReadWithBasis(user, dataset.getDatasetId())) + .thenReturn(new DatasetRead(dataset, DatasetReadBasis.STUDY_READABLE)); + when(darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any())) + .thenReturn(List.of(summary)); + + List metrics = service.generateDarSummaries(dataset.getDatasetId(), user); + + assertEquals("Broad", metrics.getFirst().institutionName()); + } + + /** + * Nothing about this caller was checked: a dataset with no study is returned to everyone because + * there is no visibility to test. The request itself is readable under that rule; the requester's + * affiliation would otherwise be harvestable by walking dataset ids. + */ + @Test + void testGenerateDarSummariesWithholdsRequesterIdentityWithoutAStudy() { + Dataset dataset = generateDataset(); + DarMetricsSummary summary = + new DarMetricsSummary(null, null, "Project", "DAR-1", null, null, "ref-1", "Broad", false); + + when(datasetService.findDatasetByIdForReadWithBasis(user, dataset.getDatasetId())) + .thenReturn(new DatasetRead(dataset, DatasetReadBasis.NO_STUDY)); + when(darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any())) + .thenReturn(List.of(summary)); + + List metrics = service.generateDarSummaries(dataset.getDatasetId(), user); + + assertNull(metrics.getFirst().institutionName()); + // The request itself still comes through + assertEquals("Project", metrics.getFirst().projectTitle()); + assertEquals("DAR-1", metrics.getFirst().darCode()); + } + + /** + * The old rule keyed on the dataset having no study, which is a proxy for "nothing was checked" - + * and wrong for anyone allowed in on their own merits. An admin reading a study-less dataset was + * losing the institution for no reason. + */ + @Test + void testGenerateDarSummariesKeepsInstitutionForAnAdminOnAStudylessDataset() { + Dataset dataset = generateDataset(); + DarMetricsSummary summary = + new DarMetricsSummary(null, null, "Project", "DAR-1", null, null, "ref-1", "Broad", false); + + when(datasetService.findDatasetByIdForReadWithBasis(user, dataset.getDatasetId())) + .thenReturn(new DatasetRead(dataset, DatasetReadBasis.ADMIN)); + when(darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any())) + .thenReturn(List.of(summary)); + + List metrics = service.generateDarSummaries(dataset.getDatasetId(), user); + + assertEquals("Broad", metrics.getFirst().institutionName()); + } + + /** Likewise the person who created the dataset. */ + @Test + void testGenerateDarSummariesKeepsInstitutionForTheDatasetCreator() { + Dataset dataset = generateDataset(); + DarMetricsSummary summary = + new DarMetricsSummary(null, null, "Project", "DAR-1", null, null, "ref-1", "Broad", false); + + when(datasetService.findDatasetByIdForReadWithBasis(user, dataset.getDatasetId())) + .thenReturn(new DatasetRead(dataset, DatasetReadBasis.DATASET_CREATOR)); + when(darDAO.findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any())) + .thenReturn(List.of(summary)); + + List metrics = service.generateDarSummaries(dataset.getDatasetId(), user); + + assertEquals("Broad", metrics.getFirst().institutionName()); + } + + /** + * The copy is positional, so this asserts every surviving field - including the two timestamps, + * which are the same type and adjacent, and so the pair a reorder would swap without the compiler + * noticing. Distinct values, because equal ones would survive a swap. + */ + @Test + void testWithoutRequesterIdentityKeepsEverythingElse() { + Timestamp updated = new Timestamp(2_000_000_000L); + Timestamp submitted = new Timestamp(1_000_000_000L); + DarMetricsSummary summary = + new DarMetricsSummary( + updated, submitted, "Project", "DAR-1", "Summary", "RUS", "ref-1", "Broad", true); + + DarMetricsSummary redacted = summary.withoutRequesterIdentity(); + + assertNull(redacted.institutionName()); + assertEquals(updated, redacted.updateDate()); + assertEquals(submitted, redacted.submissionDate()); + assertEquals("Project", redacted.projectTitle()); + assertEquals("DAR-1", redacted.darCode()); + // Adjacent and both String, so a reorder would swap them without the compiler noticing + assertEquals("Summary", redacted.nonTechRus()); + assertEquals("RUS", redacted.rus()); + assertEquals("ref-1", redacted.referenceId()); + assertEquals(true, redacted.expired()); + } + + /** A dataset the caller may not read yields no summaries, rather than its DAR project titles. */ + @Test + void testGenerateDarSummariesIsGatedOnReadingTheDataset() { + when(datasetService.findDatasetByIdForReadWithBasis(user, 1)) + .thenThrow(new ForbiddenException("User does not have permission")); + + assertThrows(ForbiddenException.class, () -> service.generateDarSummaries(1, user)); + verify(darDAO, never()).findSummaryMetricApprovedDARsByDatasetIdIncludesExpired(any()); + } + @Test void testGenerateDarSummariesNotFound() { - when(dataSetDAO.findDatasetIdById(any())).thenReturn(null); + when(datasetService.findDatasetByIdForReadWithBasis(eq(user), any())) + .thenThrow(new NotFoundException("Entity not found")); - assertThrows(NotFoundException.class, () -> service.generateDarSummaries(1)); + assertThrows(NotFoundException.class, () -> service.generateDarSummaries(1, user)); + } + + @Test + void testGenerateStudyDarSummariesStudyNotFound() { + studyIsNotReadable(); + + assertThrows(NotFoundException.class, () -> service.generateStudyDarSummaries(1, user)); + } + + @Test + void testGenerateStudyDarSummariesStudyWithoutDatasets() { + studyIsVisible(); + when(darDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(1)).thenReturn(List.of()); + + assertTrue(service.generateStudyDarSummaries(1, user).isEmpty()); + } + + @Test + void testGenerateStudyDarSummariesUsesTheStudyScopedQuery() { + DarMetricsSummary summary = generateDarMetricsSummary(); + studyIsVisible(); + when(darDAO.findSummaryMetricApprovedDARsByStudyIdIncludesExpired(1)) + .thenReturn(List.of(summary)); + + assertEquals(List.of(summary), service.generateStudyDarSummaries(1, user)); + + // One round trip for the whole study. The service holds no DatasetDAO at all now, so it + // cannot fan out per dataset even by accident. + verify(darDAO).findSummaryMetricApprovedDARsByStudyIdIncludesExpired(1); + } + + @Test + void testStudyMetricsAreHiddenWhenTheStudyIsNotVisible() { + studyIsNotReadable(); + + assertThrows(NotFoundException.class, () -> service.generateStudyDarSummaries(1, user)); + assertThrows(NotFoundException.class, () -> service.generateStudyResearchOutputs(1, user)); + assertThrows(NotFoundException.class, () -> service.getSimilarStudies(1, user)); + assertThrows(NotFoundException.class, () -> service.getFrequentlyRequestedWith(1, user)); + } + + @Test + void testGenerateStudyResearchOutputsNotFound() { + studyIsNotReadable(); + + assertThrows(NotFoundException.class, () -> service.generateStudyResearchOutputs(1, user)); + } + + @Test + void testGenerateStudyResearchOutputsAggregatesAcrossReports() { + Presentation presentation = + new Presentation( + null, + null, + null, + null, + null, + null, + UUID.randomUUID().toString(), + null, + null, + null, + null, + null, + null, + null); + Publication publication = + new Publication( + null, + null, + null, + null, + null, + null, + null, + UUID.randomUUID().toString(), + null, + null, + null, + null, + null, + null); + IntellectualProperty ip = + new IntellectualProperty( + null, + null, + null, + null, + null, + null, + null, + null, + UUID.randomUUID().toString(), + null, + null); + + DataAccessRequestData dataWithOutputs = new DataAccessRequestData(); + dataWithOutputs.setPresentations(List.of(presentation)); + dataWithOutputs.setPublications(List.of(publication)); + dataWithOutputs.setIntellectualProperties(List.of(ip)); + DataAccessRequest reportWithOutputs = new DataAccessRequest(); + reportWithOutputs.setId(1); + reportWithOutputs.setSubmissionDate(Timestamp.from(Instant.now())); + reportWithOutputs.setData(dataWithOutputs); + + // A report carrying no data at all must not break the aggregation + DataAccessRequest reportWithoutData = new DataAccessRequest(); + reportWithoutData.setId(2); + reportWithoutData.setSubmissionDate(Timestamp.from(Instant.now().minusSeconds(60))); + + studyIsVisible(); + when(darDAO.findProgressReportsByStudyId(1)) + .thenReturn(List.of(reportWithOutputs, reportWithoutData)); + + StudyResearchOutputs outputs = service.generateStudyResearchOutputs(1, user); + + assertEquals(List.of(presentation), outputs.presentations()); + assertEquals(List.of(publication), outputs.publications()); + assertEquals(List.of(ip), outputs.intellectualProperties()); + } + + @Test + void testGetSimilarStudies() { + StudyRecommendation recommendation = generateStudyRecommendation(); + studyIsVisible(); + when(recommendationDAO.findSimilar(1)).thenReturn(List.of(recommendation)); + + assertEquals(List.of(recommendation), service.getSimilarStudies(1, user)); + } + + @Test + void testGetSimilarStudiesNotFound() { + studyIsNotReadable(); + + assertThrows(NotFoundException.class, () -> service.getSimilarStudies(1, user)); + } + + @Test + void testGetFrequentlyRequestedWith() { + StudyRecommendation recommendation = generateStudyRecommendation(); + studyIsVisible(); + when(recommendationDAO.findFrequentlyRequestedWith(1)).thenReturn(List.of(recommendation)); + + assertEquals(List.of(recommendation), service.getFrequentlyRequestedWith(1, user)); + } + + @Test + void testGetFrequentlyRequestedWithNotFound() { + studyIsNotReadable(); + + assertThrows(NotFoundException.class, () -> service.getFrequentlyRequestedWith(1, user)); } private DarMetricsSummary generateDarMetricsSummary() { @@ -73,6 +384,16 @@ private DarMetricsSummary generateDarMetricsSummary() { false); } + private StudyRecommendation generateStudyRecommendation() { + return new StudyRecommendation( + randomInt(2, 100), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + 1L, + List.of(randomInt(1, 100))); + } + private Dataset generateDataset() { Dataset d = new Dataset(); d.setAlias(1); diff --git a/src/test/java/org/broadinstitute/consent/http/service/StudyAssetServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/StudyAssetServiceTest.java new file mode 100644 index 0000000000..62d49d8e9c --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/service/StudyAssetServiceTest.java @@ -0,0 +1,165 @@ +package org.broadinstitute.consent.http.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import java.util.Map; +import org.broadinstitute.consent.http.enumeration.PropertyType; +import org.broadinstitute.consent.http.models.Study; +import org.broadinstitute.consent.http.models.StudyProperty; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.util.gson.GsonUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyAssetServiceTest { + + @Mock private DatasetService datasetService; + + private final User user = new User(); + + private StudyAssetService service; + + @BeforeEach + void setUp() { + service = new StudyAssetService(datasetService); + } + + /** The shared gate loads the study and passes it through when the user may read it. */ + private void allowVisibility(Study study) { + when(datasetService.requireReadableStudy(eq(1), any())).thenReturn(study); + } + + /** + * Not readable by this caller - either absent, or not publicly visible with the caller neither + * custodian nor admin. The shared gate answers both the same way, so the assets cannot be used to + * tell one from the other. + */ + private void denyVisibility() { + when(datasetService.requireReadableStudy(eq(1), any())) + .thenThrow(new NotFoundException("Study not found")); + } + + /** Assets are read from the promoted first-class property. */ + @Test + void returnsAssetsOfEachTypeFromPromotedProperties() { + Study study = new Study(); + study.setStudyId(1); + study.addProperty( + new StudyProperty( + "publications", + GsonUtil.getInstance() + .toJsonTree(List.of(Map.of("publicationId", "pub-1", "title", "A publication"))), + PropertyType.Json)); + study.addProperty( + new StudyProperty( + "models", + GsonUtil.getInstance().toJsonTree(List.of(Map.of("modelId", "model-1"))), + PropertyType.Json)); + allowVisibility(study); + + List publications = service.getAssetsByType(1, user, "publications"); + + assertEquals(1, publications.size()); + assertEquals("A publication", ((Map) publications.getFirst()).get("title")); + assertEquals(1, service.getAssetsByType(1, user, "models").size()); + // A type with no property reads as no assets of that type + assertEquals(List.of(), service.getAssetsByType(1, user, "workspaces")); + } + + /** + * Until every client writes the promoted fields, a study still carrying the legacy assets object + * must keep reading correctly. + */ + @Test + void returnsAssetsOfEachTypeFromLegacyAssetsProperty() { + Study study = new Study(); + study.setStudyId(1); + study.addProperty( + new StudyProperty( + "assets", + GsonUtil.getInstance() + .toJsonTree( + Map.of( + "publications", + List.of(Map.of("publicationId", "pub-1", "title", "A publication")), + "models", + List.of(Map.of("modelId", "model-1")))), + PropertyType.Json)); + allowVisibility(study); + + List publications = service.getAssetsByType(1, user, "publications"); + List models = service.getAssetsByType(1, user, "models"); + + assertEquals(1, publications.size()); + assertEquals("A publication", ((Map) publications.getFirst()).get("title")); + assertEquals(1, models.size()); + assertEquals("model-1", ((Map) models.getFirst()).get("modelId")); + // A key absent from the property reads as no assets of that type + assertEquals(List.of(), service.getAssetsByType(1, user, "workspaces")); + } + + @Test + void testStudyNotFound() { + denyVisibility(); + + assertThrows(NotFoundException.class, () -> service.getAssetsByType(1, user, "publications")); + } + + /** + * A study the user may not read must not leak its assets through the sub-resources, the same way + * StudyResource hides the study itself. + */ + @Test + void testStudyNotVisibleToUser() { + Study study = new Study(); + study.setStudyId(1); + study.setPublicVisibility(false); + denyVisibility(); + + assertThrows(NotFoundException.class, () -> service.getAssetsByType(1, user, "publications")); + } + + @Test + void testStudyWithoutAssetsProperty() { + Study study = new Study(); + study.setStudyId(1); + allowVisibility(study); + + assertEquals(List.of(), service.getAssetsByType(1, user, "publications")); + } + + @Test + void testNonCollectionAssetValueReadsAsNoAssets() { + Study study = new Study(); + study.setStudyId(1); + study.addProperty( + new StudyProperty( + "assets", + GsonUtil.getInstance().toJsonTree(Map.of("models", "not a list")), + PropertyType.Json)); + allowVisibility(study); + + assertEquals(List.of(), service.getAssetsByType(1, user, "models")); + } + + /** The assets property is client-managed and unvalidated, so a bad value must not 500. */ + @Test + void testMalformedAssetsPropertyReadsAsNoAssets() { + Study study = new Study(); + study.setStudyId(1); + study.addProperty(new StudyProperty("assets", "not json at all", PropertyType.String)); + allowVisibility(study); + + assertEquals(List.of(), service.getAssetsByType(1, user, "publications")); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/service/StudyCommentServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/StudyCommentServiceTest.java new file mode 100644 index 0000000000..e0ab603d60 --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/service/StudyCommentServiceTest.java @@ -0,0 +1,326 @@ +package org.broadinstitute.consent.http.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import org.broadinstitute.consent.http.AbstractTestHelper; +import org.broadinstitute.consent.http.db.LibraryCardDAO; +import org.broadinstitute.consent.http.db.StudyCommentDAO; +import org.broadinstitute.consent.http.enumeration.UserRoles; +import org.broadinstitute.consent.http.models.StudyComment; +import org.broadinstitute.consent.http.models.StudyCommentsSummary; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.models.UserRole; +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyCommentServiceTest extends AbstractTestHelper { + + @Mock private Jdbi jdbi; + @Mock private StudyCommentDAO commentDAO; + @Mock private LibraryCardDAO libraryCardDAO; + @Mock private DatasetService datasetService; + + private StudyCommentService service; + + @BeforeEach + void setUp() { + when(jdbi.onDemand(StudyCommentDAO.class)).thenReturn(commentDAO); + when(jdbi.onDemand(LibraryCardDAO.class)).thenReturn(libraryCardDAO); + service = new StudyCommentService(jdbi, datasetService); + } + + @Test + void testListStudyNotFound() { + User user = researcher(10); + when(datasetService.requireReadableStudy(1, user)).thenThrow(new NotFoundException()); + + assertThrows(NotFoundException.class, () -> service.list(1, user, 25, 0)); + } + + @Test + void testListNoCommentsHasNullAverage() { + when(commentDAO.findByStudyId(1, 25, 0)).thenReturn(List.of()); + when(commentDAO.averageRatingByStudyId(1)).thenReturn(null); + when(commentDAO.countByStudyId(1)).thenReturn(0); + + StudyCommentsSummary summary = service.list(1, researcher(10), 25, 0); + + assertEquals(List.of(), summary.comments()); + assertNull(summary.averageRating()); + assertEquals(0, summary.total()); + } + + /** + * The average and the total describe the whole study, not the page. A caller paging through must + * not see the average move under them, and needs the total to know more pages exist. + */ + @Test + void testListReportsTheAverageAndTotalAcrossEveryComment() { + when(commentDAO.findByStudyId(1, 2, 0)).thenReturn(List.of(comment(1, 1, 4), comment(2, 2, 2))); + when(commentDAO.averageRatingByStudyId(1)).thenReturn(3.0); + when(commentDAO.countByStudyId(1)).thenReturn(7); + + StudyCommentsSummary summary = service.list(1, researcher(10), 2, 0); + + assertEquals(2, summary.comments().size()); + assertEquals(3.0, summary.averageRating()); + assertEquals(7, summary.total()); + } + + /** The page a caller asks for is the page the DAO is asked for. */ + @Test + void testListPassesThePageThrough() { + when(commentDAO.findByStudyId(1, 10, 20)).thenReturn(List.of()); + when(commentDAO.countByStudyId(1)).thenReturn(30); + + service.list(1, researcher(10), 10, 20); + + verify(commentDAO).findByStudyId(1, 10, 20); + } + + /** + * Paging puts a reader's own comment on an unpredictable page, so it is carried alongside the + * page. Without it a client scanning only the returned page would offer to add a comment to + * someone who already has one, and the save would silently revise instead. + */ + @Test + void testListCarriesTheCallersOwnCommentEvenWhenItIsNotOnThePage() { + StudyComment own = comment(99, 10, 5); + when(commentDAO.findByStudyId(1, 25, 0)).thenReturn(List.of(comment(1, 11, 3))); + when(commentDAO.countByStudyId(1)).thenReturn(40); + when(commentDAO.findByStudyIdAndUserId(1, 10)).thenReturn(own); + + StudyCommentsSummary summary = service.list(1, researcher(10), 25, 0); + + assertEquals(own, summary.yourComment()); + assertEquals(List.of(comment(1, 11, 3)), summary.comments()); + } + + @Test + void testListLeavesYourCommentNullWhenTheCallerHasNone() { + when(commentDAO.findByStudyId(1, 25, 0)).thenReturn(List.of()); + when(commentDAO.countByStudyId(1)).thenReturn(0); + when(commentDAO.findByStudyIdAndUserId(1, 10)).thenReturn(null); + + assertNull(service.list(1, researcher(10), 25, 0).yourComment()); + } + + @Test + void testPostStudyNotFound() { + User user = researcher(10); + when(datasetService.requireReadableStudy(1, user)).thenThrow(new NotFoundException()); + + assertThrows(NotFoundException.class, () -> service.post(1, user, 5, "text")); + } + + @Test + void testPostRequiresResearcherRole() { + + User user = new User(); + user.setUserId(10); + + assertThrows(ForbiddenException.class, () -> service.post(1, user, 5, "text")); + } + + @Test + void testPostRequiresLibraryCard() { + + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(null); + + assertThrows(ForbiddenException.class, () -> service.post(1, user, 5, "text")); + } + + @Test + void testPostRejectsInvalidRatings() { + + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(100); + + assertThrows(BadRequestException.class, () -> service.post(1, user, null, "text")); + assertThrows(BadRequestException.class, () -> service.post(1, user, 0, "text")); + assertThrows(BadRequestException.class, () -> service.post(1, user, 6, "text")); + } + + @Test + void testPostUpsertsAndReturnsComment() { + + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(100); + when(commentDAO.upsert(1, 10, 5, "text")).thenReturn(7); + StudyComment expected = comment(7, 10, 5); + when(commentDAO.findById(1, 7)).thenReturn(expected); + + StudyComment posted = service.post(1, user, 5, "text"); + + assertEquals(expected, posted); + } + + /** + * The list carries every comment on the study, so one caller cannot make it arbitrarily large. + */ + @Test + void testPostRejectsAnOverlongComment() { + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(100); + String tooLong = "x".repeat(StudyCommentService.MAX_COMMENT_LENGTH + 1); + + assertThrows(BadRequestException.class, () -> service.post(1, user, 4, tooLong)); + verify(commentDAO, never()).upsert(any(), any(), any(), any()); + } + + @Test + void testPostAcceptsACommentAtTheLimit() { + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(100); + String atLimit = "x".repeat(StudyCommentService.MAX_COMMENT_LENGTH); + when(commentDAO.upsert(1, 10, 4, atLimit)).thenReturn(7); + when(commentDAO.findById(1, 7)).thenReturn(comment(7, 10, 4)); + + service.post(1, user, 4, atLimit); + + verify(commentDAO).upsert(1, 10, 4, atLimit); + } + + /** Deleted between the write and the read: absent, not a 500. */ + @Test + void testPostReportsAVanishedCommentAsNotFound() { + User user = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(100); + when(commentDAO.upsert(1, 10, 4, "text")).thenReturn(7); + when(commentDAO.findById(1, 7)).thenReturn(null); + + assertThrows(NotFoundException.class, () -> service.post(1, user, 4, "text")); + } + + @Test + void testDelete() { + User user = researcher(10); + when(commentDAO.deleteOwn(1, 7, 10)).thenReturn(1); + + service.delete(1, 7, user); + + verify(commentDAO).deleteOwn(1, 7, 10); + } + + @Test + void testDeleteNotFound() { + User user = researcher(10); + when(commentDAO.deleteOwn(1, 7, 10)).thenReturn(0); + + assertThrows(NotFoundException.class, () -> service.delete(1, 7, user)); + } + + /** The study id in the path is load-bearing: a comment is not reachable through another study. */ + @Test + void testDeleteStudyNotFound() { + User user = researcher(10); + when(datasetService.requireReadableStudy(999, user)).thenThrow(new NotFoundException()); + + assertThrows(NotFoundException.class, () -> service.delete(999, 7, user)); + verify(commentDAO, never()).deleteOwn(any(), any(), any()); + } + + /** + * A study the user may not read must not leak its comments, the same way StudyResource hides the + * study itself. + */ + @Test + void testStudyNotVisibleToUser() { + User user = researcher(10); + when(datasetService.requireReadableStudy(any(), any())) + .thenThrow(new NotFoundException("Study not found")); + + assertThrows(NotFoundException.class, () -> service.list(1, user, 25, 0)); + assertThrows(NotFoundException.class, () -> service.post(1, user, 5, "text")); + assertThrows(NotFoundException.class, () -> service.delete(1, 7, user)); + } + + /** + * Comments on a public study are readable by every authenticated user, so admins are the + * moderation path: an admin removes anyone's comment, not only their own. + */ + @Test + void testAdminDeletesSomeoneElsesComment() { + User admin = admin(99); + when(commentDAO.deleteAny(1, 7)).thenReturn(1); + + service.delete(1, 7, admin); + + verify(commentDAO).deleteAny(1, 7); + verify(commentDAO, never()).deleteOwn(any(), any(), any()); + } + + /** A moderation delete of a comment that is not there is still absent, not a server error. */ + @Test + void testAdminDeleteNotFound() { + User admin = admin(99); + when(commentDAO.deleteAny(1, 7)).thenReturn(0); + + assertThrows(NotFoundException.class, () -> service.delete(1, 7, admin)); + } + + /** + * A researcher stays scoped to their own comment. Without this the admin branch would be the only + * thing standing between a researcher and someone else's rating. + */ + @Test + void testResearcherDeleteStaysScopedToTheirOwnComment() { + User user = researcher(10); + when(commentDAO.deleteOwn(1, 7, 10)).thenReturn(1); + + service.delete(1, 7, user); + + verify(commentDAO, never()).deleteAny(any(), any()); + } + + /** + * A study's creator may rate it, given the Researcher role and a library card - the same bar as + * anyone else. requireStudy already lets them read a study they own, and post applies no extra + * distance test, so this pins the decision rather than leaving it to be read out of the gate. + */ + @Test + void testStudyCreatorMayRateTheirOwnStudy() { + User creator = researcher(10); + when(libraryCardDAO.findLibraryCardIdByUserId(10)).thenReturn(5); + when(commentDAO.upsert(1, 10, 4, "text")).thenReturn(7); + when(commentDAO.findById(1, 7)).thenReturn(comment(7, 10, 4)); + + assertEquals(comment(7, 10, 4), service.post(1, creator, 4, "text")); + } + + private User admin(Integer userId) { + User user = new User(); + user.setUserId(userId); + user.addRole(new UserRole(UserRoles.ADMIN.getRoleId(), UserRoles.ADMIN.getRoleName())); + return user; + } + + private User researcher(Integer userId) { + User user = new User(); + user.setUserId(userId); + user.addRole( + new UserRole(UserRoles.RESEARCHER.getRoleId(), UserRoles.RESEARCHER.getRoleName())); + return user; + } + + private StudyComment comment(Integer commentId, Integer userId, Integer rating) { + return new StudyComment( + commentId, 1, userId, rating, "text", null, null, "Name", "Institution"); + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAOTest.java b/src/test/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAOTest.java index 77efc2230f..ad31f39230 100644 --- a/src/test/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAOTest.java +++ b/src/test/java/org/broadinstitute/consent/http/service/dao/DatasetServiceDAOTest.java @@ -8,13 +8,16 @@ import static org.broadinstitute.consent.http.models.StudyPatch.PHENOTYPE_INDICATION; import static org.broadinstitute.consent.http.models.StudyPatch.SPECIES_KEY; import static org.broadinstitute.consent.http.models.StudyPatch.STUDY_TYPE; +import static org.broadinstitute.consent.http.models.StudyPatchBuilder.patch; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.cloud.storage.BlobId; +import jakarta.ws.rs.BadRequestException; import java.sql.Timestamp; import java.time.Instant; import java.util.ArrayList; @@ -41,11 +44,13 @@ import org.broadinstitute.consent.http.models.DatasetProperty; import org.broadinstitute.consent.http.models.Dictionary; import org.broadinstitute.consent.http.models.FileStorageObject; +import org.broadinstitute.consent.http.models.Institution; import org.broadinstitute.consent.http.models.Study; import org.broadinstitute.consent.http.models.StudyPatch; import org.broadinstitute.consent.http.models.StudyProperty; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.models.dataset_registration_v1.DatasetRegistrationSchemaV1.StudyType; +import org.broadinstitute.consent.http.models.dataset_registration_v1.builder.SchemaFromStudy; import org.broadinstitute.consent.http.service.dao.DatasetServiceDAO.DatasetInsert; import org.broadinstitute.consent.http.service.dao.DatasetServiceDAO.DatasetUpdate; import org.broadinstitute.consent.http.service.dao.DatasetServiceDAO.StudyInsert; @@ -227,6 +232,7 @@ void testInsertStudyWithDatasets() throws Exception { List.of(randomAlphabetic(10)), randomAlphabetic(10), randomAlphabetic(10), + null, true, user.getUserId(), List.of(), @@ -271,6 +277,97 @@ void testInsertStudyWithDatasets() throws Exception { assertEquals(studyInsert.uuid(), studyDAO.findStudyById(s.getStudyId()).getUuid()); } + /** + * Registration collects the PI institution, but the study page reads study.pi_institution_id, so + * the create path has to record the column and not only the `piInstitution` study property. + */ + @Test + void testInsertStudyRecordsThePiInstitution() throws Exception { + Dac dac = createDac(); + User user = createUserWithInstitution(); + Institution institution = getUserInstitution(user); + + StudyInsert studyInsert = + new StudyInsert( + randomAlphabetic(10), + randomAlphabetic(10), + List.of(randomAlphabetic(10)), + randomAlphabetic(10), + randomAlphabetic(10), + institution.getId(), + true, + user.getUserId(), + List.of(), + List.of(), + UUID.randomUUID()); + + DatasetInsert datasetInsert = + new DatasetInsert( + randomAlphabetic(20), + dac.getDacId(), + new DataUseBuilder().setGeneralUse(true).build(), + user.getUserId(), + List.of(), + List.of()); + + List createdIds = + serviceDAO.insertDatasetRegistration(studyInsert, List.of(datasetInsert)); + Study study = datasetDAO.findDatasetById(createdIds.getFirst()).getStudy(); + + assertEquals( + institution.getId(), studyDAO.findStudyById(study.getStudyId()).getPiInstitution().getId()); + } + + /** + * An id that names no institution used to fall through to fk_study_pi_institution, which failed + * the whole registration transaction. Both write paths now reject it as a bad request instead. + */ + @Test + void testInsertStudyRejectsAnInstitutionThatDoesNotExist() { + Dac dac = createDac(); + User user = createUserWithInstitution(); + + StudyInsert studyInsert = + new StudyInsert( + randomAlphabetic(10), + randomAlphabetic(10), + List.of(randomAlphabetic(10)), + randomAlphabetic(10), + randomAlphabetic(10), + 2000000000, + true, + user.getUserId(), + List.of(), + List.of(), + UUID.randomUUID()); + DatasetInsert datasetInsert = + new DatasetInsert( + randomAlphabetic(20), + dac.getDacId(), + new DataUseBuilder().setGeneralUse(true).build(), + user.getUserId(), + List.of(), + List.of()); + + // Built outside the lambda so the only call inside it is the one expected to throw + List inserts = List.of(datasetInsert); + + assertThrows( + BadRequestException.class, + () -> serviceDAO.insertDatasetRegistration(studyInsert, inserts)); + } + + @Test + void testPatchStudyRejectsAnInstitutionThatDoesNotExist() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + StudyPatch patch = patch().piInstitutionId(2000000000).build(); + + assertThrows(BadRequestException.class, () -> serviceDAO.patchStudy(study, user, patch)); + // Nothing was written before the rejection. + assertNull(studyDAO.findStudyById(study.getStudyId()).getPiInstitution()); + } + @Test void testInsertStudyWithProps() throws Exception { Dac dac = createDac(); @@ -293,6 +390,7 @@ void testInsertStudyWithProps() throws Exception { List.of(randomAlphabetic(10)), randomAlphabetic(10), randomAlphabetic(10), + null, true, user.getUserId(), List.of(prop1, prop2), @@ -377,6 +475,7 @@ void testInsertStudyWithAlternativeDataSharingFile() throws Exception { List.of(randomAlphabetic(10)), randomAlphabetic(10), randomAlphabetic(10), + null, true, user.getUserId(), List.of(prop1, prop2), @@ -562,6 +661,7 @@ void testUpdateStudyDetails() throws Exception { newDataTypes, newPIName, null, + null, !study.getPublicVisibility(), study.getCreateUserId(), List.copyOf(study.getProperties()), @@ -609,6 +709,7 @@ void testUpdateStudyWithPropUpdates() throws Exception { study.getDataTypes(), study.getPiName(), study.getPiEmail(), + null, !study.getPublicVisibility(), study.getCreateUserId(), List.of(newProp, prop1), @@ -656,6 +757,7 @@ void testUpdateStudyWithDatasetUpdates() throws Exception { study.getDataTypes(), study.getPiName(), study.getPiEmail(), + null, !study.getPublicVisibility(), study.getCreateUserId(), List.copyOf(study.getProperties()), @@ -747,6 +849,7 @@ void testUpdateStudyWithFileUpdates() throws Exception { study.getDataTypes(), study.getPiName(), study.getPiEmail(), + null, !study.getPublicVisibility(), study.getCreateUserId(), List.copyOf(study.getProperties()), @@ -845,6 +948,7 @@ void testUpdateStudyWithFileUpdatesOnSecondDataset() throws Exception { study.getDataTypes(), study.getPiName(), study.getPiEmail(), + null, !study.getPublicVisibility(), study.getCreateUserId(), List.copyOf(study.getProperties()), @@ -1177,6 +1281,10 @@ void testPatchStudyAllProperties() throws Exception { randomAlphabetic(10), randomAlphabetic(10), null, + null, + null, + null, + null, List.of("email1", "email2"), randomAlphabetic(10), randomAlphabetic(10), @@ -1241,7 +1349,8 @@ void testPatchStudyNoChanges() throws Exception { User user = userDAO.findUserById(study.getCreateUserId()); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); assertEquals(study.getDescription(), patched.getDescription()); @@ -1263,6 +1372,389 @@ void testPatchStudyNoChanges() throws Exception { p -> p.getKey().equals(ALTERNATIVE_DATA_SHARING_PLAN_TARGET_PUBLIC_RELEASE_DATE))); } + @Test + void testPatchStudyPiDetails() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + User institutionUser = createUserWithInstitution(); + Integer institutionId = institutionUser.getInstitutionId(); + StudyPatch patch = + new StudyPatch( + null, + null, + null, + null, + null, + null, + null, + null, + institutionId, + "0000-0001-2345-6789", + "https://linkedin.com/in/pi", + "https://pi.example.com", + null, + null, + null, + null, + null, + null); + + Study patched = serviceDAO.patchStudy(study, user, patch); + + assertEquals(institutionId, patched.getPiInstitution().getId()); + assertEquals("0000-0001-2345-6789", patched.getPiOrcid()); + assertEquals("https://linkedin.com/in/pi", patched.getPiLinkedinUrl()); + assertEquals("https://pi.example.com", patched.getPiWebsiteUrl()); + // Unpatched fields are untouched + assertEquals(study.getName(), patched.getName()); + assertEquals(study.getPiName(), patched.getPiName()); + } + + /** + * Item 3: a PATCH retires the legacy property rather than leaving a second, stale answer beside + * the column. Left in place it would be reported on raw study reads alongside the new value. + */ + @Test + void testPatchRemovesTheLegacyPiInstitutionProperty() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + Integer originalInstitutionId = createUserWithInstitution().getInstitutionId(); + Integer patchedInstitutionId = createUserWithInstitution().getInstitutionId(); + studyDAO.updateStudyPiInstitutionId(study.getStudyId(), originalInstitutionId); + studyDAO.insertStudyProperty( + study.getStudyId(), + "piInstitution", + PropertyType.Number.toString(), + originalInstitutionId.toString()); + + serviceDAO.patchStudy( + studyDAO.findStudyById(study.getStudyId()), user, piInstitutionPatch(patchedInstitutionId)); + + Study reloaded = studyDAO.findStudyById(study.getStudyId()); + assertEquals(patchedInstitutionId, reloaded.getPiInstitution().getId()); + assertTrue( + reloaded.getProperties().stream().noneMatch(p -> "piInstitution".equals(p.getKey())), + "the stale legacy property should be gone, leaving one answer"); + } + + /** + * And a PATCH that clears the institution retires it too. Otherwise SchemaFromStudy's fallback + * would read the property whenever the column is null and resurrect what was just cleared. + */ + @Test + void testPatchClearingThePiInstitutionAlsoRemovesTheLegacyProperty() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + Integer institutionId = createUserWithInstitution().getInstitutionId(); + studyDAO.updateStudyPiInstitutionId(study.getStudyId(), institutionId); + studyDAO.insertStudyProperty( + study.getStudyId(), + "piInstitution", + PropertyType.Number.toString(), + institutionId.toString()); + + // An explicit JSON null on piInstitutionId, which is how a client clears it. + StudyPatch clearing = patch().explicitNulls(StudyPatch.PI_INSTITUTION_ID).build(); + serviceDAO.patchStudy(studyDAO.findStudyById(study.getStudyId()), user, clearing); + + Study reloaded = studyDAO.findStudyById(study.getStudyId()); + assertNull(reloaded.getPiInstitution()); + assertNull(new SchemaFromStudy().build(reloaded).getPiInstitution()); + + // Third leg: pushing that registration payload back through an update must not restore the + // institution. This is the leg that made the old behaviour a P1 - the clear looked applied + // until the next registration PUT put the legacy value back into the column. + StudyUpdate update = + new StudyUpdate( + reloaded.getName(), + reloaded.getStudyId(), + reloaded.getDescription(), + reloaded.getDataTypes(), + reloaded.getPiName(), + reloaded.getPiEmail(), + new DatasetServiceDAO.StudyPiDetails( + new SchemaFromStudy().build(reloaded).getPiInstitution(), + reloaded.getPiOrcid(), + reloaded.getPiLinkedinUrl(), + reloaded.getPiWebsiteUrl()), + reloaded.getPublicVisibility(), + user.getUserId(), + List.copyOf(reloaded.getProperties()), + List.of()); + serviceDAO.updateStudy(update, List.of(), List.of()); + + assertNull(studyDAO.findStudyById(study.getStudyId()).getPiInstitution()); + } + + /** + * Item 7c, end to end: a patch body of {"piName": null, "piOrcid": null} keeps the stored PI name + * and clears the orcid. The two fields look alike on the wire and behave differently, so the + * difference is worth pinning where it is actually applied. + */ + @Test + void testExplicitNullKeepsPiNameAndClearsPiOrcid() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + StudyPatch seed = patch().piName("Dr Existing").piOrcid("0000-0001-2345-6789").build(); + serviceDAO.patchStudy(studyDAO.findStudyById(study.getStudyId()), user, seed); + + StudyPatch nulls = StudyPatch.fromJson("{\"piName\": null, \"piOrcid\": null}"); + serviceDAO.patchStudy(studyDAO.findStudyById(study.getStudyId()), user, nulls); + + Study reloaded = studyDAO.findStudyById(study.getStudyId()); + assertEquals("Dr Existing", reloaded.getPiName()); + assertNull(reloaded.getPiOrcid()); + } + + /** + * Item 6: a registration edit keeps the PI's profile links, which are PATCH-only, while taking + * the institution from the payload. The links used to be read by the service through a separate + * whole-study fetch; they are now filled in from the study this write transaction already has, so + * this is where the behaviour needs pinning. + */ + @Test + void testRegistrationUpdateKeepsStoredPiLinksAndTakesTheInstitutionFromThePayload() + throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + Integer institutionId = createUserWithInstitution().getInstitutionId(); + StudyPatch seed = + patch() + .piOrcid("0000-0002-1825-0097") + .piLinkedinUrl("https://linkedin.com/in/example") + .piWebsiteUrl("https://example.org") + .build(); + serviceDAO.patchStudy(studyDAO.findStudyById(study.getStudyId()), user, seed); + + Study stored = studyDAO.findStudyById(study.getStudyId()); + StudyUpdate update = + new StudyUpdate( + stored.getName(), + stored.getStudyId(), + stored.getDescription(), + stored.getDataTypes(), + stored.getPiName(), + stored.getPiEmail(), + DatasetServiceDAO.StudyPiDetails.institutionWithStoredLinks(institutionId), + stored.getPublicVisibility(), + user.getUserId(), + List.copyOf(stored.getProperties()), + List.of()); + serviceDAO.updateStudy(update, List.of(), List.of()); + + Study reloaded = studyDAO.findStudyById(study.getStudyId()); + assertEquals(institutionId, reloaded.getPiInstitution().getId()); + assertEquals("0000-0002-1825-0097", reloaded.getPiOrcid()); + assertEquals("https://linkedin.com/in/example", reloaded.getPiLinkedinUrl()); + assertEquals("https://example.org", reloaded.getPiWebsiteUrl()); + } + + private StudyPatch piInstitutionPatch(Integer piInstitutionId) { + return patch().piInstitutionId(piInstitutionId).build(); + } + + /** + * A study registered before the pi_institution_id column existed carries the institution as the + * legacy numeric `piInstitution` study property as well, and the backfill copies it into the + * column. PATCH then writes only the column, so if the registration payload were still built from + * the property, a registration GET would report the pre-PATCH institution and the next + * registration PUT would write it straight back - silently reverting the PATCH. The column is + * authoritative, so the payload must follow it. + */ + @Test + void testPatchedPiInstitutionSurvivesARegistrationRoundTrip() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + Integer originalInstitutionId = createUserWithInstitution().getInstitutionId(); + Integer patchedInstitutionId = createUserWithInstitution().getInstitutionId(); + + // Registration recorded the institution in both places, as it does today. + studyDAO.updateStudyPiInstitutionId(study.getStudyId(), originalInstitutionId); + studyDAO.insertStudyProperty( + study.getStudyId(), + "piInstitution", + PropertyType.Number.toString(), + originalInstitutionId.toString()); + + StudyPatch patch = + new StudyPatch( + null, + null, + null, + null, + null, + null, + null, + null, + patchedInstitutionId, + null, + null, + null, + null, + null, + null, + null, + null, + null); + serviceDAO.patchStudy(studyDAO.findStudyById(study.getStudyId()), user, patch); + + // The registration GET reports the patched institution, not the stale property. + Study reloaded = studyDAO.findStudyById(study.getStudyId()); + assertEquals(patchedInstitutionId, reloaded.getPiInstitution().getId()); + assertEquals(patchedInstitutionId, new SchemaFromStudy().build(reloaded).getPiInstitution()); + + // ...so pushing that payload back through an update leaves the column where PATCH put it. + StudyUpdate update = + new StudyUpdate( + reloaded.getName(), + reloaded.getStudyId(), + reloaded.getDescription(), + reloaded.getDataTypes(), + reloaded.getPiName(), + reloaded.getPiEmail(), + new DatasetServiceDAO.StudyPiDetails( + new SchemaFromStudy().build(reloaded).getPiInstitution(), + reloaded.getPiOrcid(), + reloaded.getPiLinkedinUrl(), + reloaded.getPiWebsiteUrl()), + reloaded.getPublicVisibility(), + user.getUserId(), + List.copyOf(reloaded.getProperties()), + List.of()); + serviceDAO.updateStudy(update, List.of(), List.of()); + + assertEquals( + patchedInstitutionId, + studyDAO.findStudyById(study.getStudyId()).getPiInstitution().getId()); + } + + @Test + void testPatchStudyPreservesExistingPiDetails() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + User institutionUser = createUserWithInstitution(); + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + institutionUser.getInstitutionId(), + "0000-0001-2345-6789", + "https://linkedin.com/in/pi", + "https://pi.example.com", + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + Study studyWithPiDetails = studyDAO.findStudyById(study.getStudyId()); + + // A patch that does not touch PI details must keep them intact + StudyPatch patch = + new StudyPatch( + randomAlphabetic(10), + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + Study patched = serviceDAO.patchStudy(studyWithPiDetails, user, patch); + + assertEquals(patch.name(), patched.getName()); + assertEquals(institutionUser.getInstitutionId(), patched.getPiInstitution().getId()); + assertEquals("0000-0001-2345-6789", patched.getPiOrcid()); + assertEquals("https://linkedin.com/in/pi", patched.getPiLinkedinUrl()); + assertEquals("https://pi.example.com", patched.getPiWebsiteUrl()); + } + + /** + * The PI columns follow the repo's blank-clears convention, and a patch that names none of them + * must leave them untouched. + */ + @Test + void testPatchStudyClearsPiDetails() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + User institutionUser = createUserWithInstitution(); + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + institutionUser.getInstitutionId(), + "0000-0001-2345-6789", + "https://linkedin.com/in/pi", + "https://pi.example.com", + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + Study studyWithPiDetails = studyDAO.findStudyById(study.getStudyId()); + + // Go through the wire path, since explicit JSON nulls are what signal a clear + Study patched = + serviceDAO.patchStudy( + studyWithPiDetails, + user, + StudyPatch.fromJson( + """ + {"piInstitutionId": null, "piOrcid": null, + "piLinkedinUrl": null, "piWebsiteUrl": ""} + """)); + + // Explicit nulls, and a blank, persist as NULL rather than as empty strings + assertNull(patched.getPiInstitution()); + assertNull(patched.getPiOrcid()); + assertNull(patched.getPiLinkedinUrl()); + assertNull(patched.getPiWebsiteUrl()); + } + + // A patch that names none of the PI fields must leave every one of them untouched. + @Test + void testPatchStudyPreservesPiDetailsWhenAbsentFromPatch() throws Exception { + Study study = createStudy(null, null, null); + User user = userDAO.findUserById(study.getCreateUserId()); + User institutionUser = createUserWithInstitution(); + studyDAO.updateStudy( + study.getStudyId(), + study.getName(), + study.getDescription(), + study.getPiName(), + study.getPiEmail(), + institutionUser.getInstitutionId(), + "0000-0001-2345-6789", + "https://linkedin.com/in/pi", + "https://pi.example.com", + study.getDataTypes(), + study.getPublicVisibility(), + user.getUserId(), + Instant.now()); + Study studyWithPiDetails = studyDAO.findStudyById(study.getStudyId()); + + Study patched = + serviceDAO.patchStudy( + studyWithPiDetails, user, StudyPatch.fromJson("{\"name\": \"A new name\"}")); + + assertEquals("A new name", patched.getName()); + assertEquals(institutionUser.getInstitutionId(), patched.getPiInstitution().getId()); + assertEquals("0000-0001-2345-6789", patched.getPiOrcid()); + assertEquals("https://linkedin.com/in/pi", patched.getPiLinkedinUrl()); + assertEquals("https://pi.example.com", patched.getPiWebsiteUrl()); + } + @Test void testPatchStudyName() throws Exception { Study study = createStudy(null, null, null); @@ -1282,6 +1774,10 @@ void testPatchStudyName() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(patch.name(), patched.getName()); @@ -1323,6 +1819,10 @@ void testPatchStudyDescription() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1364,6 +1864,10 @@ void testPatchStudyDataTypes() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1405,6 +1909,10 @@ void testPatchStudyPIName() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1433,7 +1941,8 @@ void testPatchStudyPublicVisibility() throws Exception { User user = userDAO.findUserById(study.getCreateUserId()); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, false, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, false, null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); assertEquals(study.getDescription(), patched.getDescription()); @@ -1474,6 +1983,10 @@ void testPatchStudyStudyType() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1521,6 +2034,10 @@ void testPatchStudyPhenotypeIndication() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1567,6 +2084,10 @@ void testPatchStudySpecies() throws Exception { null, null, null, + null, + null, + null, + null, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1609,6 +2130,10 @@ void testPatchStudyDataCustodianEmail() throws Exception { null, null, null, + null, + null, + null, + null, List.of("email1", "email2"), null, null, @@ -1657,6 +2182,10 @@ void testPatchStudyAlternativeDataSharingPlanTargetDeliveryDate() throws Excepti null, null, null, + null, + null, + null, + null, randomAlphabetic(10), null, null, @@ -1703,6 +2232,10 @@ void testPatchStudyAlternativeDataSharingPlanTargetPublicReleaseDate() throws Ex null, null, null, + null, + null, + null, + null, randomAlphabetic(10), null, null, @@ -1750,6 +2283,10 @@ void testPatchStudyExternalIdentifier() throws Exception { null, null, null, + null, + null, + null, + null, randomAlphabetic(10), null); Study patched = serviceDAO.patchStudy(study, user, patch); @@ -1782,6 +2319,10 @@ void testPatchStudyExternalIdentifierType() throws Exception { null, null, null, + null, + null, + null, + null, randomAlphabetic(10)); Study patched = serviceDAO.patchStudy(study, user, patch); assertEquals(study.getName(), patched.getName()); @@ -1801,7 +2342,8 @@ void testPatchStudyExternalIdentifierBlankIsIgnored(String blank) throws Excepti User user = userDAO.findUserById(study.getCreateUserId()); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, blank, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, blank, null); Study patched = serviceDAO.patchStudy(study, user, patch); assertTrue( patched.getProperties().stream().noneMatch(p -> p.getKey().equals(EXTERNAL_IDENTIFIER))); @@ -1814,7 +2356,8 @@ void testPatchStudyExternalIdentifierTypeBlankIsIgnored(String blank) throws Exc User user = userDAO.findUserById(study.getCreateUserId()); StudyPatch patch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, blank); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, blank); Study patched = serviceDAO.patchStudy(study, user, patch); assertTrue( patched.getProperties().stream() @@ -1840,13 +2383,18 @@ void testPatchStudyExternalIdentifierRemoval() throws Exception { null, null, null, + null, + null, + null, + null, randomAlphabetic(10), null); serviceDAO.patchStudy(study, user, setPatch); // Then remove it with a blank string StudyPatch removePatch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, "", null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, "", null); Study patched = serviceDAO.patchStudy(study, user, removePatch); assertTrue( patched.getProperties().stream().noneMatch(p -> p.getKey().equals(EXTERNAL_IDENTIFIER))); @@ -1872,12 +2420,17 @@ void testPatchStudyExternalIdentifierTypeRemoval() throws Exception { null, null, null, + null, + null, + null, + null, randomAlphabetic(10)); serviceDAO.patchStudy(study, user, setPatch); // Then remove it with a blank string StudyPatch removePatch = new StudyPatch( - null, null, null, null, null, null, null, null, null, null, null, null, null, ""); + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, ""); Study patched = serviceDAO.patchStudy(study, user, removePatch); assertTrue( patched.getProperties().stream() @@ -1903,11 +2456,16 @@ void testPatchStudyPhenotypeIndicationRemoval() throws Exception { null, null, null, + null, + null, + null, + null, null); serviceDAO.patchStudy(study, user, setPatch); StudyPatch removePatch = new StudyPatch( - null, null, null, null, "", null, null, null, null, null, null, null, null, null); + null, null, null, null, "", null, null, null, null, null, null, null, null, null, null, + null, null, null); Study patched = serviceDAO.patchStudy(study, user, removePatch); assertTrue( patched.getProperties().stream().noneMatch(p -> p.getKey().equals(PHENOTYPE_INDICATION))); @@ -1972,6 +2530,7 @@ private Study createStudy( List.of(randomAlphabetic(10)), randomAlphabetic(10), null, + null, true, user.getUserId(), List.of(prop1, prop2),