From bdd3c46fc6e19053d67d355dd5ea6e43d444362e Mon Sep 17 00:00:00 2001 From: Hunter Craft <118154470+hunterckx@users.noreply.github.com> Date: Sun, 9 Jun 2024 00:13:32 -0700 Subject: [PATCH] feat: add component atlas source dataset apis (#275) (#291) * feat: add component atlas source dataset apis (#275) * test: add tests for component atlas source dataset apis (#275) * style: fix variable name (#275) * chore: remove commented-out code (#275) * feat: add component atlas source datasets get api (#275) * test: add tests for component atlas source datasets get api (#275) * feat: add component atlas individual source dataset api (#275) * fix: remove inconsistent and unnecessary client parameter (#275) --- ...nent-atlases-id-source-datasets-id.test.ts | 442 ++++++++++++++++ ...mponent-atlases-id-source-datasets.test.ts | 480 ++++++++++++++++++ ...-source-studies-id-source-datasets.test.ts | 4 +- .../update-cellxgene-source-datasets.test.ts | 4 +- .../hca-atlas-tracker/common/schema.ts | 21 + app/services/component-atlases.ts | 89 +++- app/services/source-datasets.ts | 83 +++ .../[componentAtlasId]/source-datasets.ts | 62 +++ .../source-datasets/[sourceDatasetId].ts | 60 +++ testing/constants.ts | 34 ++ testing/db-utils.ts | 9 +- testing/entities.ts | 1 + 12 files changed, 1281 insertions(+), 8 deletions(-) create mode 100644 __tests__/api-atlases-id-component-atlases-id-source-datasets-id.test.ts create mode 100644 __tests__/api-atlases-id-component-atlases-id-source-datasets.test.ts create mode 100644 pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets.ts create mode 100644 pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId].ts diff --git a/__tests__/api-atlases-id-component-atlases-id-source-datasets-id.test.ts b/__tests__/api-atlases-id-component-atlases-id-source-datasets-id.test.ts new file mode 100644 index 00000000..680bccbd --- /dev/null +++ b/__tests__/api-atlases-id-component-atlases-id-source-datasets-id.test.ts @@ -0,0 +1,442 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import httpMocks from "node-mocks-http"; +import { + HCAAtlasTrackerDBComponentAtlas, + HCAAtlasTrackerSourceDataset, +} from "../app/apis/catalog/hca-atlas-tracker/common/entities"; +import { METHOD } from "../app/common/entities"; +import { endPgPool, query } from "../app/services/database"; +import sourceDatasetHandler from "../pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId]"; +import { + ATLAS_DRAFT, + ATLAS_PUBLIC, + COMPONENT_ATLAS_DRAFT_BAR, + COMPONENT_ATLAS_DRAFT_FOO, + SOURCE_DATASET_FOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + SOURCE_DATASET_FOOFOO, + TEST_SOURCE_STUDIES, + USER_CONTENT_ADMIN, + USER_STAKEHOLDER, + USER_UNREGISTERED, +} from "../testing/constants"; +import { resetDatabase } from "../testing/db-utils"; +import { + TestComponentAtlas, + TestSourceDataset, + TestUser, +} from "../testing/entities"; +import { withConsoleErrorHiding } from "../testing/utils"; + +jest.mock("../app/services/user-profile"); +jest.mock("../app/services/hca-projects"); +jest.mock("../app/services/cellxgene"); +jest.mock("../app/utils/pg-app-connect-config"); + +const SOURCE_DATASET_ID_NONEXISTENT = "52281fde-232c-4481-8b45-cc986570e7b9"; + +beforeAll(async () => { + await resetDatabase(); +}); + +afterAll(async () => { + endPgPool(); +}); + +describe("/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId]", () => { + it("returns error 405 for PUT request", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + undefined, + METHOD.PUT + ) + )._getStatusCode() + ).toEqual(405); + }); + + it("returns error 401 when source dataset is requested by logged out user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOBAZ.id + ) + )._getStatusCode() + ).toEqual(401); + }); + + it("returns error 403 when source dataset is requested by unregistered user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOBAZ.id, + USER_UNREGISTERED + ) + )._getStatusCode() + ).toEqual(403); + }); + + it("returns source dataset when requested by logged in user with STAKEHOLDER role", async () => { + const res = await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOBAZ.id, + USER_STAKEHOLDER + ); + expect(res._getStatusCode()).toEqual(200); + const sourceDataset = res._getJSONData() as HCAAtlasTrackerSourceDataset; + expectSourceDatasetsToMatch([sourceDataset], [SOURCE_DATASET_FOOBAZ]); + }); + + it("returns source dataset when requested by logged in user with CONTENT_ADMIN role", async () => { + const res = await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOBAZ.id, + USER_CONTENT_ADMIN + ); + expect(res._getStatusCode()).toEqual(200); + const sourceDataset = res._getJSONData() as HCAAtlasTrackerSourceDataset; + expectSourceDatasetsToMatch([sourceDataset], [SOURCE_DATASET_FOOBAZ]); + }); + + it("returns error 401 when POST requested from draft atlas by logged out user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + undefined, + METHOD.POST + ) + )._getStatusCode() + ).toEqual(401); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when POST requested from draft atlas by unregistered user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + USER_STAKEHOLDER, + METHOD.POST + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when POST requested from draft atlas by logged in user with STAKEHOLDER role", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + USER_STAKEHOLDER, + METHOD.POST + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 404 when POST requested from atlas the component atlas doesn't exist on", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_PUBLIC.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + true + ) + )._getStatusCode() + ).toEqual(404); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when POST requested from component atlas that already has the source dataset", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when POST requested with nonexistent source dataset", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_ID_NONEXISTENT, + USER_CONTENT_ADMIN, + METHOD.POST, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("adds source dataset when POST requested", async () => { + const sourceDatasetsBefore = await getComponentAtlasSourceDatasets( + COMPONENT_ATLAS_DRAFT_FOO.id + ); + + const res = await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST + ); + expect(res._getStatusCode()).toEqual(201); + expectComponentAtlasToHaveSourceDatasets(COMPONENT_ATLAS_DRAFT_FOO, [ + SOURCE_DATASET_FOOFOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + SOURCE_DATASET_FOO, + ]); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_BAR); + + await query("UPDATE hat.component_atlases SET source_datasets=$1", [ + sourceDatasetsBefore, + ]); + }); + + it("returns error 401 when DELETE requested from draft atlas by logged out user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + undefined, + METHOD.DELETE + ) + )._getStatusCode() + ).toEqual(401); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when DELETE requested from draft atlas by unregistered user", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + USER_STAKEHOLDER, + METHOD.DELETE + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when DELETE requested from draft atlas by logged in user with STAKEHOLDER role", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + USER_STAKEHOLDER, + METHOD.DELETE + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 404 when DELETE requested from atlas the component atlas doesn't exist on", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_PUBLIC.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + true + ) + )._getStatusCode() + ).toEqual(404); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when DELETE requested with nonexistent source dataset", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_ID_NONEXISTENT, + USER_CONTENT_ADMIN, + METHOD.DELETE, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when DELETE requested from component atlas the source dataset doesn't exist on", async () => { + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("deletes source dataset", async () => { + const sourceDatasetsBefore = await getComponentAtlasSourceDatasets( + COMPONENT_ATLAS_DRAFT_FOO.id + ); + + expect( + ( + await doSourceDatasetRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + SOURCE_DATASET_FOOFOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE + ) + )._getStatusCode() + ).toEqual(200); + expectComponentAtlasToHaveSourceDatasets(COMPONENT_ATLAS_DRAFT_FOO, [ + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + ]); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_BAR); + + await query("UPDATE hat.component_atlases SET source_datasets=$1", [ + sourceDatasetsBefore, + ]); + }); +}); + +async function doSourceDatasetRequest( + atlasId: string, + componentAtlasId: string, + sourceDatasetId: string, + user?: TestUser, + method = METHOD.GET, + hideConsoleError = false +): Promise> { + const { req, res } = httpMocks.createMocks({ + headers: { authorization: user?.authorization }, + method, + query: { atlasId, componentAtlasId, sourceDatasetId }, + }); + await withConsoleErrorHiding( + () => sourceDatasetHandler(req, res), + hideConsoleError + ); + return res; +} + +async function expectComponentAtlasToBeUnchanged( + componentAtlas: TestComponentAtlas +): Promise { + const componentAtlasFromDb = await getComponentAtlasFromDatabase( + componentAtlas.id + ); + expect(componentAtlasFromDb).toBeDefined(); + if (!componentAtlasFromDb) return; + expect(componentAtlasFromDb.atlas_id).toEqual(componentAtlas.atlasId); + expect(componentAtlasFromDb.component_info.title).toEqual( + componentAtlas.title + ); +} + +async function expectComponentAtlasToHaveSourceDatasets( + componentAtlas: TestComponentAtlas, + expectedSourceDatasets: TestSourceDataset[] +): Promise { + const sourceDatasets = await getComponentAtlasSourceDatasets( + componentAtlas.id + ); + expect(sourceDatasets).toHaveLength(expectedSourceDatasets.length); + for (const expectedDataset of expectedSourceDatasets) { + expect(sourceDatasets).toContain(expectedDataset.id); + } +} + +function expectSourceDatasetsToMatch( + sourceDatasets: HCAAtlasTrackerSourceDataset[], + expectedTestSourceDatasets: TestSourceDataset[] +): void { + for (const testSourceDataset of expectedTestSourceDatasets) { + const sourceDataset = sourceDatasets.find( + (c) => c.id === testSourceDataset.id + ); + expect(sourceDataset).toBeDefined(); + if (!sourceDataset) continue; + expect(sourceDataset.sourceStudyId).toEqual( + testSourceDataset.sourceStudyId + ); + expect(sourceDataset.title).toEqual(testSourceDataset.title); + const sourceStudy = TEST_SOURCE_STUDIES.find( + (s) => s.id === testSourceDataset.sourceStudyId + ); + if (sourceStudy) + expect(sourceDataset.sourceStudyTitle).toEqual( + "publication" in sourceStudy + ? sourceStudy.publication?.title + : sourceStudy.unpublishedInfo?.title ?? null + ); + } +} + +async function getComponentAtlasFromDatabase( + id: string +): Promise { + return ( + await query( + "SELECT * FROM hat.component_atlases WHERE id=$1", + [id] + ) + ).rows[0]; +} + +async function getComponentAtlasSourceDatasets(id: string): Promise { + return ( + await query( + "SELECT * FROM hat.component_atlases WHERE id=$1", + [id] + ) + ).rows[0].source_datasets; +} diff --git a/__tests__/api-atlases-id-component-atlases-id-source-datasets.test.ts b/__tests__/api-atlases-id-component-atlases-id-source-datasets.test.ts new file mode 100644 index 00000000..03097139 --- /dev/null +++ b/__tests__/api-atlases-id-component-atlases-id-source-datasets.test.ts @@ -0,0 +1,480 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import httpMocks from "node-mocks-http"; +import { + HCAAtlasTrackerDBComponentAtlas, + HCAAtlasTrackerSourceDataset, +} from "../app/apis/catalog/hca-atlas-tracker/common/entities"; +import { + ComponentAtlasAddSourceDatasetsData, + ComponentAtlasDeleteSourceDatasetsData, +} from "../app/apis/catalog/hca-atlas-tracker/common/schema"; +import { METHOD } from "../app/common/entities"; +import { endPgPool, query } from "../app/services/database"; +import sourceDatasetsHandler from "../pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets"; +import { + ATLAS_DRAFT, + ATLAS_PUBLIC, + COMPONENT_ATLAS_DRAFT_BAR, + COMPONENT_ATLAS_DRAFT_FOO, + SOURCE_DATASET_BAR, + SOURCE_DATASET_FOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + SOURCE_DATASET_FOOFOO, + TEST_SOURCE_STUDIES, + USER_CONTENT_ADMIN, + USER_STAKEHOLDER, + USER_UNREGISTERED, +} from "../testing/constants"; +import { resetDatabase } from "../testing/db-utils"; +import { + TestComponentAtlas, + TestSourceDataset, + TestUser, +} from "../testing/entities"; +import { withConsoleErrorHiding } from "../testing/utils"; + +jest.mock("../app/services/user-profile"); +jest.mock("../app/services/hca-projects"); +jest.mock("../app/services/cellxgene"); +jest.mock("../app/utils/pg-app-connect-config"); + +const SOURCE_DATASET_ID_NONEXISTENT = "52281fde-232c-4481-8b45-cc986570e7b9"; + +const NEW_DATASETS_DATA: ComponentAtlasAddSourceDatasetsData = { + sourceDatasetIds: [SOURCE_DATASET_FOO.id, SOURCE_DATASET_BAR.id], +}; + +const NEW_DATASETS_WITH_EXISTING_DATA: ComponentAtlasAddSourceDatasetsData = { + sourceDatasetIds: [SOURCE_DATASET_FOOFOO.id, SOURCE_DATASET_BAR.id], +}; + +const NEW_DATASETS_WITH_NONEXISTENT_DATA: ComponentAtlasAddSourceDatasetsData = + { + sourceDatasetIds: [SOURCE_DATASET_FOO.id, SOURCE_DATASET_ID_NONEXISTENT], + }; + +const DELETE_DATASETS_DATA: ComponentAtlasDeleteSourceDatasetsData = { + sourceDatasetIds: [SOURCE_DATASET_FOOFOO.id, SOURCE_DATASET_FOOBAR.id], +}; + +const DELETE_DATASETS_WITH_MISSING_DATA: ComponentAtlasDeleteSourceDatasetsData = + { + sourceDatasetIds: [SOURCE_DATASET_FOO.id, SOURCE_DATASET_FOOBAR.id], + }; + +const DELETE_DATASETS_WITH_NONEXISTENT_DATA: ComponentAtlasDeleteSourceDatasetsData = + { + sourceDatasetIds: [SOURCE_DATASET_FOO.id, SOURCE_DATASET_ID_NONEXISTENT], + }; + +beforeAll(async () => { + await resetDatabase(); +}); + +afterAll(async () => { + endPgPool(); +}); + +describe("/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId]", () => { + it("returns error 405 for PUT request", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + undefined, + METHOD.PUT + ) + )._getStatusCode() + ).toEqual(405); + }); + + it("returns error 401 when source datasets are requested by logged out user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id + ) + )._getStatusCode() + ).toEqual(401); + }); + + it("returns error 403 when source datasets are requested by unregistered user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_UNREGISTERED + ) + )._getStatusCode() + ).toEqual(403); + }); + + it("returns source datasets when requested by logged in user with STAKEHOLDER role", async () => { + const res = await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_STAKEHOLDER + ); + expect(res._getStatusCode()).toEqual(200); + const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[]; + expect(sourceDatasets).toHaveLength(3); + expectSourceDatasetsToMatch(sourceDatasets, [ + SOURCE_DATASET_FOOFOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + ]); + }); + + it("returns source datasets when requested by logged in user with CONTENT_ADMIN role", async () => { + const res = await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN + ); + expect(res._getStatusCode()).toEqual(200); + const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[]; + expect(sourceDatasets).toHaveLength(3); + expectSourceDatasetsToMatch(sourceDatasets, [ + SOURCE_DATASET_FOOFOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + ]); + }); + + it("returns error 401 when POST requested from draft atlas by logged out user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + undefined, + METHOD.POST, + NEW_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(401); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when POST requested from draft atlas by unregistered user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_STAKEHOLDER, + METHOD.POST, + NEW_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when POST requested from draft atlas by logged in user with STAKEHOLDER role", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_STAKEHOLDER, + METHOD.POST, + NEW_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 404 when POST requested from atlas the component atlas doesn't exist on", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_PUBLIC.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + NEW_DATASETS_DATA, + true + ) + )._getStatusCode() + ).toEqual(404); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when POST requested from component atlas that already has one of the source datasets", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + NEW_DATASETS_WITH_EXISTING_DATA, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 for POST requested where one of the source datasets doesn't exist", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + NEW_DATASETS_WITH_NONEXISTENT_DATA, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("adds source datasets when POST requested", async () => { + const sourceDatasetsBefore = await getComponentAtlasSourceDatasets( + COMPONENT_ATLAS_DRAFT_FOO.id + ); + + const res = await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.POST, + NEW_DATASETS_DATA + ); + expect(res._getStatusCode()).toEqual(201); + expectComponentAtlasToHaveSourceDatasets(COMPONENT_ATLAS_DRAFT_FOO, [ + SOURCE_DATASET_FOOFOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, + SOURCE_DATASET_FOO, + SOURCE_DATASET_BAR, + ]); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_BAR); + + await query("UPDATE hat.component_atlases SET source_datasets=$1", [ + sourceDatasetsBefore, + ]); + }); + + it("returns error 401 when DELETE requested from draft atlas by logged out user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + undefined, + METHOD.DELETE, + DELETE_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(401); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when DELETE requested from draft atlas by unregistered user", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_STAKEHOLDER, + METHOD.DELETE, + DELETE_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 403 when DELETE requested from draft atlas by logged in user with STAKEHOLDER role", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_STAKEHOLDER, + METHOD.DELETE, + DELETE_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(403); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 404 when DELETE requested from atlas the component atlas doesn't exist on", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_PUBLIC.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + DELETE_DATASETS_DATA, + true + ) + )._getStatusCode() + ).toEqual(404); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 when DELETE requested from component atlas that one of the source datasets doesn't exist on", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + DELETE_DATASETS_WITH_MISSING_DATA, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("returns error 400 for DELETE request where one of the source datasets doesn't exist", async () => { + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + DELETE_DATASETS_WITH_NONEXISTENT_DATA, + true + ) + )._getStatusCode() + ).toEqual(400); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_FOO); + }); + + it("deletes source datasets", async () => { + const sourceDatasetsBefore = await getComponentAtlasSourceDatasets( + COMPONENT_ATLAS_DRAFT_FOO.id + ); + + expect( + ( + await doSourceDatasetsRequest( + ATLAS_DRAFT.id, + COMPONENT_ATLAS_DRAFT_FOO.id, + USER_CONTENT_ADMIN, + METHOD.DELETE, + DELETE_DATASETS_DATA + ) + )._getStatusCode() + ).toEqual(200); + expectComponentAtlasToHaveSourceDatasets(COMPONENT_ATLAS_DRAFT_FOO, [ + SOURCE_DATASET_FOOBAZ, + ]); + expectComponentAtlasToBeUnchanged(COMPONENT_ATLAS_DRAFT_BAR); + + await query("UPDATE hat.component_atlases SET source_datasets=$1", [ + sourceDatasetsBefore, + ]); + }); +}); + +async function doSourceDatasetsRequest( + atlasId: string, + componentAtlasId: string, + user?: TestUser, + method = METHOD.GET, + body?: Record, + hideConsoleError = false +): Promise> { + const { req, res } = httpMocks.createMocks({ + body, + headers: { authorization: user?.authorization }, + method, + query: { atlasId, componentAtlasId }, + }); + await withConsoleErrorHiding( + () => sourceDatasetsHandler(req, res), + hideConsoleError + ); + return res; +} + +async function expectComponentAtlasToBeUnchanged( + componentAtlas: TestComponentAtlas +): Promise { + const componentAtlasFromDb = await getComponentAtlasFromDatabase( + componentAtlas.id + ); + expect(componentAtlasFromDb).toBeDefined(); + if (!componentAtlasFromDb) return; + expect(componentAtlasFromDb.atlas_id).toEqual(componentAtlas.atlasId); + expect(componentAtlasFromDb.component_info.title).toEqual( + componentAtlas.title + ); +} + +async function expectComponentAtlasToHaveSourceDatasets( + componentAtlas: TestComponentAtlas, + expectedSourceDatasets: TestSourceDataset[] +): Promise { + const sourceDatasets = await getComponentAtlasSourceDatasets( + componentAtlas.id + ); + expect(sourceDatasets).toHaveLength(expectedSourceDatasets.length); + for (const expectedDataset of expectedSourceDatasets) { + expect(sourceDatasets).toContain(expectedDataset.id); + } +} + +function expectSourceDatasetsToMatch( + sourceDatasets: HCAAtlasTrackerSourceDataset[], + expectedTestSourceDatasets: TestSourceDataset[] +): void { + for (const testSourceDataset of expectedTestSourceDatasets) { + const sourceDataset = sourceDatasets.find( + (c) => c.id === testSourceDataset.id + ); + expect(sourceDataset).toBeDefined(); + if (!sourceDataset) continue; + expect(sourceDataset.sourceStudyId).toEqual( + testSourceDataset.sourceStudyId + ); + expect(sourceDataset.title).toEqual(testSourceDataset.title); + const sourceStudy = TEST_SOURCE_STUDIES.find( + (s) => s.id === testSourceDataset.sourceStudyId + ); + if (sourceStudy) + expect(sourceDataset.sourceStudyTitle).toEqual( + "publication" in sourceStudy + ? sourceStudy.publication?.title + : sourceStudy.unpublishedInfo?.title ?? null + ); + } +} + +async function getComponentAtlasFromDatabase( + id: string +): Promise { + return ( + await query( + "SELECT * FROM hat.component_atlases WHERE id=$1", + [id] + ) + ).rows[0]; +} + +async function getComponentAtlasSourceDatasets(id: string): Promise { + return ( + await query( + "SELECT * FROM hat.component_atlases WHERE id=$1", + [id] + ) + ).rows[0].source_datasets; +} diff --git a/__tests__/api-atlases-id-source-studies-id-source-datasets.test.ts b/__tests__/api-atlases-id-source-studies-id-source-datasets.test.ts index 6e16cc6f..10c6b5e4 100644 --- a/__tests__/api-atlases-id-source-studies-id-source-datasets.test.ts +++ b/__tests__/api-atlases-id-source-studies-id-source-datasets.test.ts @@ -75,7 +75,7 @@ describe("/api/atlases/[id]/source-studies/[sourceStudyId]/source-datasets", () ); expect(res._getStatusCode()).toEqual(200); const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[]; - expect(sourceDatasets).toHaveLength(4); + expect(sourceDatasets).toHaveLength(8); expectSourceDatasetsToMatch(sourceDatasets, [ SOURCE_DATASET_FOO, SOURCE_DATASET_BAR, @@ -92,7 +92,7 @@ describe("/api/atlases/[id]/source-studies/[sourceStudyId]/source-datasets", () ); expect(res._getStatusCode()).toEqual(200); const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[]; - expect(sourceDatasets).toHaveLength(4); + expect(sourceDatasets).toHaveLength(8); expectSourceDatasetsToMatch(sourceDatasets, [ SOURCE_DATASET_FOO, SOURCE_DATASET_BAR, diff --git a/__tests__/update-cellxgene-source-datasets.test.ts b/__tests__/update-cellxgene-source-datasets.test.ts index 6d06aadf..a7a68fd7 100644 --- a/__tests__/update-cellxgene-source-datasets.test.ts +++ b/__tests__/update-cellxgene-source-datasets.test.ts @@ -32,7 +32,7 @@ describe("updateCellxGeneSourceDatasets", () => { SOURCE_STUDY_WITH_SOURCE_DATASETS.id ); - expect(sourceDatasetsBefore).toHaveLength(4); + expect(sourceDatasetsBefore).toHaveLength(8); expectSourceDatasetToMatch( findSourceDatasetById( @@ -61,7 +61,7 @@ describe("updateCellxGeneSourceDatasets", () => { SOURCE_STUDY_WITH_SOURCE_DATASETS.id ); - expect(sourceDatasetsAfter).toHaveLength(5); + expect(sourceDatasetsAfter).toHaveLength(9); expectSourceDatasetToMatch( findSourceDatasetById( diff --git a/app/apis/catalog/hca-atlas-tracker/common/schema.ts b/app/apis/catalog/hca-atlas-tracker/common/schema.ts index d2b003cc..be25714f 100644 --- a/app/apis/catalog/hca-atlas-tracker/common/schema.ts +++ b/app/apis/catalog/hca-atlas-tracker/common/schema.ts @@ -68,6 +68,27 @@ export const componentAtlasEditSchema = newComponentAtlasSchema; export type ComponentAtlasEditData = InferType; +/** + * Schema for data used to add source datasets to a component atlas. + */ +export const componentAtlasAddSourceDatasetsSchema = object({ + sourceDatasetIds: array(string().required()).required(), +}).strict(); + +export type ComponentAtlasAddSourceDatasetsData = InferType< + typeof componentAtlasAddSourceDatasetsSchema +>; + +/** + * Schema for data used to remove source datasets from a component atlas. + */ +export const componentAtlasDeleteSourceDatasetsSchema = + componentAtlasAddSourceDatasetsSchema; + +export type ComponentAtlasDeleteSourceDatasetsData = InferType< + typeof componentAtlasDeleteSourceDatasetsSchema +>; + /** * Create schema that combines an unpublished source study schema and a published source study schema. * @param publishedSchema - Published source study schema. diff --git a/app/services/component-atlases.ts b/app/services/component-atlases.ts index cfeea189..4af77c30 100644 --- a/app/services/component-atlases.ts +++ b/app/services/component-atlases.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from "app/utils/api-handler"; +import { InvalidOperationError, NotFoundError } from "app/utils/api-handler"; import { HCAAtlasTrackerDBComponentAtlas, HCAAtlasTrackerDBComponentAtlasInfo, @@ -9,6 +9,7 @@ import { } from "../apis/catalog/hca-atlas-tracker/common/schema"; import { confirmAtlasExists } from "./atlases"; import { query } from "./database"; +import { confirmSourceDatasetsExist } from "./source-datasets"; /** * Get all component atlases of the given atlas. @@ -119,7 +120,91 @@ export async function deleteComponentAtlas( throw getComponentAtlasNotFoundError(atlasId, componentAtlasId); } -function getComponentAtlasNotFoundError( +/** + * Add the given source datasets to the specified comonent atlas. + * @param atlasId - Atlas that the component atlas is accessed through. + * @param componentAtlasId - Component atlas ID. + * @param sourceDatasetIds - IDs of source datasets to add. + */ +export async function addSourceDatasetsToComponentAtlas( + atlasId: string, + componentAtlasId: string, + sourceDatasetIds: string[] +): Promise { + await confirmSourceDatasetsExist(sourceDatasetIds); + + const existingDatasetsResult = await query<{ array: string[] }>( + ` + SELECT ARRAY( + SELECT sd_id FROM unnest(source_datasets) AS sd_id WHERE sd_id=ANY($1) + ) FROM hat.component_atlases WHERE id=$2 AND atlas_id=$3 + `, + [sourceDatasetIds, componentAtlasId, atlasId] + ); + + if (existingDatasetsResult.rows.length === 0) + throw getComponentAtlasNotFoundError(atlasId, componentAtlasId); + + const existingSpecifiedDatasets = existingDatasetsResult.rows[0].array; + + if (existingSpecifiedDatasets.length !== 0) + throw new InvalidOperationError( + `Component atlas with ID ${componentAtlasId} already has source datasets with IDs: ${existingSpecifiedDatasets.join( + ", " + )}` + ); + + await query( + "UPDATE hat.component_atlases SET source_datasets=source_datasets||$1 WHERE id=$2", + [sourceDatasetIds, componentAtlasId] + ); +} + +/** + * Remove the given source datasets from the specified comonent atlas. + * @param atlasId - Atlas that the component atlas is accessed through. + * @param componentAtlasId - Component atlas ID. + * @param sourceDatasetIds - IDs of source datasets to remove. + */ +export async function deleteSourceDatasetsFromComponentAtlas( + atlasId: string, + componentAtlasId: string, + sourceDatasetIds: string[] +): Promise { + await confirmSourceDatasetsExist(sourceDatasetIds); + + const missingDatasetsResult = await query<{ array: string[] }>( + ` + SELECT ARRAY( + SELECT sd_id FROM unnest($1::uuid[]) AS sd_id WHERE NOT sd_id=ANY(source_datasets) + ) FROM hat.component_atlases WHERE id=$2 AND atlas_id=$3 + `, + [sourceDatasetIds, componentAtlasId, atlasId] + ); + + if (missingDatasetsResult.rows.length === 0) + throw getComponentAtlasNotFoundError(atlasId, componentAtlasId); + + const missingDatasets = missingDatasetsResult.rows[0].array; + + if (missingDatasets.length !== 0) + throw new InvalidOperationError( + `Component atlas with ID ${componentAtlasId} doesn't have source datasets with IDs: ${missingDatasets.join( + ", " + )}` + ); + + await query( + ` + UPDATE hat.component_atlases + SET source_datasets = ARRAY(SELECT unnest(source_datasets) EXCEPT SELECT unnest($1::uuid[])) + WHERE id=$2 + `, + [sourceDatasetIds, componentAtlasId] + ); +} + +export function getComponentAtlasNotFoundError( atlasId: string, componentAtlasId: string ): NotFoundError { diff --git a/app/services/source-datasets.ts b/app/services/source-datasets.ts index 660bbba8..e2c46f70 100644 --- a/app/services/source-datasets.ts +++ b/app/services/source-datasets.ts @@ -1,6 +1,7 @@ import { CellxGeneDataset } from "app/utils/cellxgene-api"; import pg from "pg"; import { + HCAAtlasTrackerDBComponentAtlas, HCAAtlasTrackerDBSourceDataset, HCAAtlasTrackerDBSourceDatasetInfo, HCAAtlasTrackerDBSourceDatasetWithStudyProperties, @@ -11,6 +12,7 @@ import { } from "../apis/catalog/hca-atlas-tracker/common/schema"; import { InvalidOperationError, NotFoundError } from "../utils/api-handler"; import { getCellxGeneDatasetsByCollectionId } from "./cellxgene"; +import { getComponentAtlasNotFoundError } from "./component-atlases"; import { doTransaction, query } from "./database"; import { confirmSourceStudyExistsOnAtlas } from "./source-studies"; @@ -33,6 +35,33 @@ export async function getSourceStudyDatasets( return queryResult.rows; } +/** + * Get all source datasets of the given component atlas. + * @param atlasId - ID of the atlas that the component atlas is accesed through. + * @param componentAtlasId - Component atlas ID. + * @returns database-model source datasets. + */ +export async function getComponentAtlasDatasets( + atlasId: string, + componentAtlasId: string +): Promise { + const componentAtlasResult = await query< + Pick + >( + "SELECT source_datasets FROM hat.component_atlases WHERE id=$1 AND atlas_id=$2", + [componentAtlasId, atlasId] + ); + if (componentAtlasResult.rows.length === 0) + throw getComponentAtlasNotFoundError(atlasId, componentAtlasId); + const sourceDatasetIds = componentAtlasResult.rows[0].source_datasets; + const queryResult = + await query( + "SELECT d.*, s.doi, s.study_info FROM hat.source_datasets d JOIN hat.source_studies s ON d.source_study_id = s.id WHERE d.id = ANY($1)", + [sourceDatasetIds] + ); + return queryResult.rows; +} + /** * Get a source dataset. * @param atlasId - ID of the atlas that the source dataset is accessed through. @@ -64,6 +93,40 @@ export async function getSourceDataset( return queryResult.rows[0]; } +/** + * Get a source dataset of a component atlas. + * @param atlasId - ID of the atlas that the source dataset is accessed through. + * @param componentAtlasId - ID of the component atlas that the source dataset is accessed through. + * @param sourceDatasetId - Source dataset ID. + * @returns database model of the source dataset. + */ +export async function getComponentAtlasSourceDataset( + atlasId: string, + componentAtlasId: string, + sourceDatasetId: string +): Promise { + const { exists } = ( + await query<{ exists: boolean }>( + "SELECT EXISTS(SELECT 1 FROM hat.component_atlases WHERE $1=ANY(source_datasets) AND id=$2 AND atlas_id=$3)", + [sourceDatasetId, componentAtlasId, atlasId] + ) + ).rows[0]; + if (!exists) + throw new NotFoundError( + `Source dataset with ID ${sourceDatasetId} doesn't exist on component atlas with ID ${componentAtlasId} on atlas with ID ${atlasId}` + ); + const queryResult = + await query( + "SELECT d.*, s.doi, s.study_info FROM hat.source_datasets d JOIN hat.source_studies s ON d.source_study_id = s.id WHERE d.id = $1", + [sourceDatasetId] + ); + if (queryResult.rows.length === 0) + throw new NotFoundError( + `Source dataset with ID ${sourceDatasetId} doesn't exist` + ); + return queryResult.rows[0]; +} + /** * Create a source dataset for the given source study. * @param atlasId - ID of the atlas that the source study is accessed through. @@ -250,6 +313,26 @@ async function confirmSourceDatasetIsNonCellxGene( ); } +/** + * Check whether a list of dataset IDs exist, and throw an error if any don't. + * @param sourceDatasetIds - Source dataset IDs to check for. + */ +export async function confirmSourceDatasetsExist( + sourceDatasetIds: string[] +): Promise { + const queryResult = await query>( + "SELECT id FROM hat.source_datasets WHERE id=ANY($1)", + [sourceDatasetIds] + ); + if (queryResult.rows.length < sourceDatasetIds.length) { + const foundIds = queryResult.rows.map((r) => r.id); + const missingIds = sourceDatasetIds.filter((id) => !foundIds.includes(id)); + throw new InvalidOperationError( + `No source datasets exist with IDs: ${missingIds.join(", ")}` + ); + } +} + function getSourceDatasetNotFoundError( sourceStudyId: string, sourceDatasetId: string diff --git a/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets.ts b/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets.ts new file mode 100644 index 00000000..d89fd87e --- /dev/null +++ b/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets.ts @@ -0,0 +1,62 @@ +import { ROLE_GROUP } from "app/apis/catalog/hca-atlas-tracker/common/constants"; +import { ROLE } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/entities"; +import { componentAtlasAddSourceDatasetsSchema } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/schema"; +import { dbSourceDatasetToApiSourceDataset } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/utils"; +import { METHOD } from "../../../../../../app/common/entities"; +import { + addSourceDatasetsToComponentAtlas, + deleteSourceDatasetsFromComponentAtlas, +} from "../../../../../../app/services/component-atlases"; +import { getComponentAtlasDatasets } from "../../../../../../app/services/source-datasets"; +import { + handleByMethod, + handler, + role, +} from "../../../../../../app/utils/api-handler"; + +const getHandler = handler(role(ROLE_GROUP.READ), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + res + .status(200) + .json( + (await getComponentAtlasDatasets(atlasId, componentAtlasId)).map( + dbSourceDatasetToApiSourceDataset + ) + ); +}); + +const postHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + const { sourceDatasetIds } = + await componentAtlasAddSourceDatasetsSchema.validate(req.body); + await addSourceDatasetsToComponentAtlas( + atlasId, + componentAtlasId, + sourceDatasetIds + ); + res.status(201).end(); +}); + +const deleteHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + const { sourceDatasetIds } = + await componentAtlasAddSourceDatasetsSchema.validate(req.body); + await deleteSourceDatasetsFromComponentAtlas( + atlasId, + componentAtlasId, + sourceDatasetIds + ); + res.status(200).end(); +}); + +/** + * API route for adding or deleting multiple source datasets on a component atlas. + */ +export default handleByMethod({ + [METHOD.GET]: getHandler, + [METHOD.POST]: postHandler, + [METHOD.DELETE]: deleteHandler, +}); diff --git a/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId].ts b/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId].ts new file mode 100644 index 00000000..879b5b06 --- /dev/null +++ b/pages/api/atlases/[atlasId]/component-atlases/[componentAtlasId]/source-datasets/[sourceDatasetId].ts @@ -0,0 +1,60 @@ +import { dbSourceDatasetToApiSourceDataset } from "app/apis/catalog/hca-atlas-tracker/common/utils"; +import { ROLE_GROUP } from "../../../../../../../app/apis/catalog/hca-atlas-tracker/common/constants"; +import { ROLE } from "../../../../../../../app/apis/catalog/hca-atlas-tracker/common/entities"; +import { METHOD } from "../../../../../../../app/common/entities"; +import { + addSourceDatasetsToComponentAtlas, + deleteSourceDatasetsFromComponentAtlas, +} from "../../../../../../../app/services/component-atlases"; +import { getComponentAtlasSourceDataset } from "../../../../../../../app/services/source-datasets"; +import { + handleByMethod, + handler, + role, +} from "../../../../../../../app/utils/api-handler"; + +const getHandler = handler(role(ROLE_GROUP.READ), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + const sourceDatasetId = req.query.sourceDatasetId as string; + res + .status(200) + .json( + dbSourceDatasetToApiSourceDataset( + await getComponentAtlasSourceDataset( + atlasId, + componentAtlasId, + sourceDatasetId + ) + ) + ); +}); + +const postHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + const sourceDatasetId = req.query.sourceDatasetId as string; + await addSourceDatasetsToComponentAtlas(atlasId, componentAtlasId, [ + sourceDatasetId, + ]); + res.status(201).end(); +}); + +const deleteHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => { + const atlasId = req.query.atlasId as string; + const componentAtlasId = req.query.componentAtlasId as string; + const sourceDatasetId = req.query.sourceDatasetId as string; + await deleteSourceDatasetsFromComponentAtlas(atlasId, componentAtlasId, [ + sourceDatasetId, + ]); + res.status(200).end(); +}); + +/** + * API route for adding or deleting a source dataset on a component atlas. + */ +export default handleByMethod({ + [METHOD.GET]: getHandler, + [METHOD.POST]: postHandler, + [METHOD.DELETE]: deleteHandler, +}); diff --git a/testing/constants.ts b/testing/constants.ts index e91aa79c..21a1c9e4 100644 --- a/testing/constants.ts +++ b/testing/constants.ts @@ -746,6 +746,8 @@ export const INITIAL_TEST_SOURCE_STUDIES = [ SOURCE_STUDY_WITH_SOURCE_DATASETS, ]; +export const TEST_SOURCE_STUDIES = [...INITIAL_TEST_SOURCE_STUDIES]; + // SOURCE DATASETS export const SOURCE_DATASET_FOO: TestSourceDataset = { @@ -760,6 +762,29 @@ export const SOURCE_DATASET_BAR: TestSourceDataset = { title: "Source Dataset Bar", }; +export const SOURCE_DATASET_BAZ: TestSourceDataset = { + id: "3682751a-7a97-48e1-a43e-d355c1707e26", + sourceStudyId: SOURCE_STUDY_WITH_SOURCE_DATASETS.id, + title: "Source Dataset Baz", +}; + +export const SOURCE_DATASET_FOOFOO: TestSourceDataset = { + id: "5c42bc65-93ad-4191-95bc-a40d56f2bb6b", + sourceStudyId: SOURCE_STUDY_WITH_SOURCE_DATASETS.id, + title: "Source Dataset Foofoo", +}; + +export const SOURCE_DATASET_FOOBAR: TestSourceDataset = { + id: "4de3dadd-a35c-4386-be62-4536934e9179", + sourceStudyId: SOURCE_STUDY_WITH_SOURCE_DATASETS.id, + title: "Source Dataset Foobar", +}; + +export const SOURCE_DATASET_FOOBAZ: TestSourceDataset = { + id: "7ac2afd8-493d-46e5-b9d8-cadc512bb2cc", + sourceStudyId: SOURCE_STUDY_WITH_SOURCE_DATASETS.id, + title: "Source Dataset Foobar", +}; export const SOURCE_DATASET_CELLXGENE_WITHOUT_UPDATE: TestSourceDataset = { cellxgeneDatasetId: CELLXGENE_ID_DATASET_WITHOUT_UPDATE, cellxgeneDatasetVersion: CELLXGENE_VERSION_DATASET_WITHOUT_UPDATE, @@ -780,6 +805,10 @@ export const SOURCE_DATASET_CELLXGENE_WITH_UPDATE: TestSourceDataset = { export const INITIAL_TEST_SOURCE_DATASETS = [ SOURCE_DATASET_FOO, SOURCE_DATASET_BAR, + SOURCE_DATASET_BAZ, + SOURCE_DATASET_FOOFOO, + SOURCE_DATASET_FOOBAR, + SOURCE_DATASET_FOOBAZ, SOURCE_DATASET_CELLXGENE_WITHOUT_UPDATE, SOURCE_DATASET_CELLXGENE_WITH_UPDATE, ]; @@ -911,6 +940,11 @@ export const INITIAL_TEST_ATLASES_BY_SOURCE_STUDY = INITIAL_TEST_ATLASES.reduce( export const COMPONENT_ATLAS_DRAFT_FOO: TestComponentAtlas = { atlasId: ATLAS_DRAFT.id, id: "b1820416-5886-4585-b0fe-7f70487331d8", + sourceDatasets: [ + SOURCE_DATASET_FOOFOO.id, + SOURCE_DATASET_FOOBAR.id, + SOURCE_DATASET_FOOBAZ.id, + ], title: "Component Atlas Draft Foo", }; diff --git a/testing/db-utils.ts b/testing/db-utils.ts index 2c03eae1..5ae39d6f 100644 --- a/testing/db-utils.ts +++ b/testing/db-utils.ts @@ -83,8 +83,13 @@ async function initDatabaseEntries(client: pg.PoolClient): Promise { title: componentAtlas.title, }; await client.query( - "INSERT INTO hat.component_atlases (atlas_id, component_info, id) VALUES ($1, $2, $3)", - [componentAtlas.atlasId, info, componentAtlas.id] + "INSERT INTO hat.component_atlases (atlas_id, component_info, id, source_datasets) VALUES ($1, $2, $3, $4)", + [ + componentAtlas.atlasId, + info, + componentAtlas.id, + componentAtlas.sourceDatasets ?? [], + ] ); } diff --git a/testing/entities.ts b/testing/entities.ts index 37b7f38e..9fff4b5d 100644 --- a/testing/entities.ts +++ b/testing/entities.ts @@ -33,6 +33,7 @@ export interface TestAtlas { export interface TestComponentAtlas { atlasId: string; id: string; + sourceDatasets?: string[]; title: string; }