diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a7f66..19694d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.1.0] - 2025-09-30 + +### Added + +- added `getStudyResearchAssistants` endpoint +- Updated `parseUser` function to handle `ResearchAssistant` role +- `is_descending` parameters to `getParticipantAccounts` endpoint +- Added `subdomain` optional parameter to `generateAnonymousAccounts` endpoint + +### Changed + +- `addResearcherToStudy` now requires a role + ## [2.0.7] - 2025-06-11 ### Fixed @@ -24,7 +37,7 @@ ### Fixed - Fixed serialization of unknown datastream `Data` -- Fixed `ParticipantDataInput.dateOfLastDataUpload` data type +- Fixed `ParticipantDataInput.dateOfLastDataUpload` data type ## [2.0.4] - 2025-03-10 @@ -57,4 +70,4 @@ ### Removed - Removed `InformedConsent` endpoints. Please use `ParticipantData` instead. -- Removed `DataPoints` endpoints. Please use `DataStreams` instead. \ No newline at end of file +- Removed `DataPoints` endpoints. Please use `DataStreams` instead. diff --git a/package.json b/package.json index 38d1096..e6c7c59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@carp-dk/client", "type": "module", - "version": "2.0.7", + "version": "2.1.0-dev.7", "description": "TypeScript API client for the CARP Web Services (CAWS).", "repository": { "type": "git", diff --git a/src/client/carpClient.ts b/src/client/carpClient.ts index a678be5..0f337f5 100644 --- a/src/client/carpClient.ts +++ b/src/client/carpClient.ts @@ -10,9 +10,10 @@ import { } from "@/endpoints"; import { CarpServiceError, sanitizeRequestConfig } from "@/shared"; import Protocols from "@/endpoints/protocols"; +import { CarpToken } from "@/endpoints/auth"; export default class CarpClient { - private instance: AxiosInstance; + private readonly instance: AxiosInstance; accounts: Accounts; @@ -32,6 +33,18 @@ export default class CarpClient { return this.instance; } + private token: CarpToken; + + // @internal + public get getInternalToken(): CarpToken { + return this.token; + } + + // @internal + public setInternalToken(token: CarpToken): void { + this.token = token; + } + constructor(protected readonly config: Config) { if (!config.baseUrl) { throw new Error( diff --git a/src/client/index.ts b/src/client/index.ts index 5e38818..da702b1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,4 @@ -import CarpTestClient from "./carpTestClient"; import CarpClient from "./carpClient"; -export { CarpTestClient, CarpClient }; +// eslint-disable-next-line import/prefer-default-export +export { CarpClient }; diff --git a/src/endpoints/accounts.ts b/src/endpoints/accounts.ts index 7a9a8fe..9a5f71b 100644 --- a/src/endpoints/accounts.ts +++ b/src/endpoints/accounts.ts @@ -14,11 +14,16 @@ class Accounts extends Endpoint { emailAddress: string; role: string; }) { - return this.actions.post(`${this.endpoint}/role`, { emailAddress, role }); + return this.actions.post(`${this.endpoint}/role`, { + emailAddress, + role, + }); } async getRedirectURIs() { - return this.actions.get(`${this.endpoint}/redirect-uris`); + return this.actions.get<{ [key: string]: string[] }>( + `${this.endpoint}/redirect-uris`, + ); } } diff --git a/src/endpoints/auth.ts b/src/endpoints/auth.ts index e7f48c2..e383f74 100644 --- a/src/endpoints/auth.ts +++ b/src/endpoints/auth.ts @@ -1,4 +1,3 @@ -import { CarpTestClient } from ".."; import Endpoint from "./endpoint"; export type CarpToken = { @@ -10,9 +9,9 @@ export type CarpToken = { }; class Auth extends Endpoint { - private realm: string = import.meta.env.VITE_AUTH_REALM; + private readonly realm: string = import.meta.env.VITE_AUTH_REALM; - private baseUrl: string = import.meta.env.VITE_AUTH_BASE_URL; + private readonly baseUrl: string = import.meta.env.VITE_AUTH_BASE_URL; async login(params: { username: string; @@ -33,7 +32,7 @@ class Auth extends Endpoint { }, ); - (this.client as CarpTestClient).setInternalToken(response.data); + this.client.setInternalToken(response.data); return response.data; } @@ -66,8 +65,7 @@ class Auth extends Endpoint { client_id: import.meta.env.VITE_AUTH_CLIENT_ID, client_secret: import.meta.env.VITE_AUTH_CLIENT_SECRET, grant_type: "refresh_token", - refresh_token: (this.client as CarpTestClient).getInternalToken - .refresh_token, + refresh_token: this.client.getInternalToken.refresh_token, }; const query = new URLSearchParams(params).toString(); const response = await this.actions.post( @@ -79,8 +77,7 @@ class Auth extends Endpoint { }, }, ); - - (this.client as CarpTestClient).setInternalToken(response.data); + this.client.setInternalToken(response.data); this.client.setAuthToken(response.data.access_token); return response.data; diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index 8558ad8..b555f0f 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -1,9 +1,9 @@ import Accounts from "./accounts"; +import Auth from "./auth"; import Study from "./study"; import Participation from "./participation"; -import Auth from "./auth"; import Studies from "./studies"; import Email from "./email"; import DataStreams from "./dataStreams"; -export { Auth, Accounts, Study, Studies, Participation, Email, DataStreams }; +export { Accounts, Auth, Study, Studies, Participation, Email, DataStreams }; diff --git a/src/endpoints/study/deployments.ts b/src/endpoints/study/deployments.ts index a271fd9..e0857c6 100644 --- a/src/endpoints/study/deployments.ts +++ b/src/endpoints/study/deployments.ts @@ -9,7 +9,6 @@ import { serialize, } from "@/shared"; import Endpoint from "../endpoint"; -import { Statistics } from "@/shared/models"; class Deployments extends Endpoint { coreEndpoint: string = "/api/deployment-service"; @@ -130,65 +129,6 @@ class Deployments extends Endpoint { return deviceDeployment; } - - /** - * Get device deployments - * @param studyDeploymentId The ID of the study deployment - */ - async getDeploymentStatistics({ - deploymentIds, - }: { - deploymentIds: string[]; - }) { - const response = await this.actions.post<{ statistics: any }>( - `${this.coreEndpoint}/statistic`, - { - deploymentIds, - }, - ); // add deployment ID to URL - const responseData: { - deployments: { deploymentId: string; uploads: any }[]; - }[] = []; - const res: { deploymentId: string; uploads: any }[] = []; - - const DeploymentIdArray: string[] = []; - const { statistics } = response.data; - Object.keys(statistics).forEach((id) => { - DeploymentIdArray.push(id); - }); - - DeploymentIdArray.forEach((id) => { - const value = statistics[id]; - - const dataTypes: string[] = []; - Object.keys(value).forEach((dataType) => { - dataTypes.push(dataType); - }); - - const uploads: any[] = []; - dataTypes.forEach((element) => { - const obj: { - dataType: string; - uploads: { count: number; uploads: {} }; - } = { - dataType: element, - uploads: value[element], - }; - uploads.push(obj); - }); - - const obj: { deploymentId: string; uploads: any[] } = { - deploymentId: id, - uploads, - }; - res.push(obj); - responseData.push({ - deployments: res, - }); - }); - - return responseData as Statistics[]; - } } export default Deployments; diff --git a/src/endpoints/study/exports.ts b/src/endpoints/study/exports.ts index 4cb378c..4ee2e99 100644 --- a/src/endpoints/study/exports.ts +++ b/src/endpoints/study/exports.ts @@ -29,10 +29,9 @@ class Exports extends Endpoint { const regex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const header = response.headers["content-disposition"] as string; const matches = regex.exec(header); - const filename = - matches != null && matches[1] - ? matches[1].replace(/['"]/g, "") - : "download.zip"; + const filename = matches?.[1] + ? matches[1].replace(/['"]/g, "") + : "download.zip"; const data = response.data as ExportData; return { data, filename } as ExportToDownload; diff --git a/src/endpoints/study/recruitment.ts b/src/endpoints/study/recruitment.ts index 18d290c..9f6283d 100644 --- a/src/endpoints/study/recruitment.ts +++ b/src/endpoints/study/recruitment.ts @@ -81,17 +81,25 @@ class Recruitment extends Endpoint { offset, search, response_as_dto, + is_descending, }: { studyId: string; limit?: number | null; offset?: number | null; search?: string | null; response_as_dto?: boolean | null; + is_descending?: boolean | null; }) { const response = await this.actions.get< ParticipantAccount[] | PaginatedParticipantAccounts >(`${this.wsEndpoint}/${studyId}/participants/accounts`, { - params: { limit, offset, search, response_as_dto }, + params: { + limit, + offset, + search, + response_as_dto, + is_descending, + }, }); return response.data; @@ -271,7 +279,9 @@ class Recruitment extends Endpoint { studyId, amountOfAccounts, expirationSeconds, + clientId, redirectUri, + subdomain, participantRoleName, }: AnonymousLinksRequest) { const response = await this.actions.post( @@ -279,7 +289,9 @@ class Recruitment extends Endpoint { { amountOfAccounts, expirationSeconds, + clientId, redirectUri, + subdomain, participantRoleName, }, ); @@ -287,10 +299,9 @@ class Recruitment extends Endpoint { const header = response.headers["content-disposition"] as string; const regex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const matches = regex.exec(header); - const filename = - matches != null && matches[1] - ? matches[1].replace(/['"]/g, "") - : "accounts.csv"; + const filename = matches?.[1] + ? matches[1].replace(/['"]/g, "") + : "accounts.csv"; return { filename, diff --git a/src/endpoints/study/researchers.ts b/src/endpoints/study/researchers.ts index d069c15..eb835fa 100644 --- a/src/endpoints/study/researchers.ts +++ b/src/endpoints/study/researchers.ts @@ -1,22 +1,25 @@ -import { User } from "@/shared/models"; +import { Role, User } from "@/shared/models"; import Endpoint from "../endpoint"; class Researchers extends Endpoint { endpoint: string = "/api/studies"; /** - * Add researcher to a study + * Add user with email to a study with a role * @param studyId The ID of the study * @param email The email of the researcher to add + * @param role The role of the researcher to add (RESEARCHER or RESEARCH_ASSISTANT) */ async addResearcherToStudy({ studyId, email, + role, }: { studyId: string; email: string; + role: Role; }) { - const query = new URLSearchParams({ email }).toString(); + const query = new URLSearchParams({ email, role }).toString(); await this.actions.post( `${this.endpoint}/${studyId}/researchers/add`, query, @@ -40,6 +43,18 @@ class Researchers extends Endpoint { return response.data as User[]; } + /** + * Get all research assistants for a study + * @param studyId The ID of the study + * @returns The list of research assistants + */ + async getStudyResearchAssistants({ studyId }: { studyId: string }) { + const response = await this.actions.get( + `${this.endpoint}/${studyId}/research-assistants`, + ); + return response.data as User[]; + } + /** * Remove a researcher from a study * @param studyId The ID of the study diff --git a/src/shared/models/general.ts b/src/shared/models/general.ts index 4d722d5..c4f612f 100644 --- a/src/shared/models/general.ts +++ b/src/shared/models/general.ts @@ -10,4 +10,9 @@ export type User = { role: Role[]; }; -export type Role = "RESEARCHER" | "PARTICIPANT" | "SYSTEM_ADMIN" | "CARP_ADMIN"; +export type Role = + | "RESEARCHER" + | "RESEARCH_ASSISTANT" + | "PARTICIPANT" + | "SYSTEM_ADMIN" + | "CARP_ADMIN"; diff --git a/src/shared/models/studies.ts b/src/shared/models/studies.ts index 390bcc1..d043c4e 100644 --- a/src/shared/models/studies.ts +++ b/src/shared/models/studies.ts @@ -8,7 +8,7 @@ export type StudyOverview = { value$kotlinx_datetime: Date; nanosecondsOfSecond: number; }; - studyProtocolId: string | null; + studyProtocolId?: string | null; canSetInvitation: boolean; canSetStudyProtocol: boolean; canDeployToParticipants: boolean; @@ -19,7 +19,9 @@ export type AnonymousLinksRequest = { studyId: string; amountOfAccounts: number; expirationSeconds: number; + clientId: string; redirectUri: string; + subdomain?: string | null; participantRoleName: string; }; @@ -142,10 +144,6 @@ export interface PaginatedParticipantAccounts { participants: ParticipantAccount[]; } -export interface Statistics { - deployments: { deploymentId: string; uploads: any }[]; -} - export interface InactiveDeployment { deploymentId: UUID; dateOfLastDataUpload: { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index b78274d..6c718f9 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -53,6 +53,8 @@ export const parseUser = (accessToken: string): User => { role = "CARP_ADMIN"; } else if (userDecoded.realm_access.roles.includes("researcher")) { role = "RESEARCHER"; + } else if (userDecoded.realm_access.roles.includes("research_assistant")) { + role = "RESEARCH_ASSISTANT"; } else if (userDecoded.realm_access.roles.includes("participant")) { role = "PARTICIPANT"; } else { diff --git a/src/client/carpTestClient.ts b/src/test/client/carpTestClient.ts similarity index 83% rename from src/client/carpTestClient.ts rename to src/test/client/carpTestClient.ts index 3fcd11b..75ae670 100644 --- a/src/client/carpTestClient.ts +++ b/src/test/client/carpTestClient.ts @@ -1,8 +1,7 @@ import axios, { AxiosError } from "axios"; import { Config } from "@/config"; import { Auth } from "@/endpoints"; -import CarpClient from "./carpClient"; -import { CarpToken } from "@/endpoints/auth"; +import CarpClient from "../../client/carpClient"; import { sanitizeRequestConfig, CarpServiceError } from "@/shared"; /* @@ -14,29 +13,23 @@ import { sanitizeRequestConfig, CarpServiceError } from "@/shared"; export default class CarpTestClient extends CarpClient { public authentication: Auth; - private token: CarpToken; + private retryCount = 0; // Instance-level retry counter to prevent race conditions - public get getInternalToken(): CarpToken { - return this.token; - } - - public setInternalToken(token: CarpToken): void { - this.token = token; + public resetRetryCount(): void { + this.retryCount = 0; } constructor(protected readonly config: Config) { super(config); this.registerEndpoints(); - let retryCount = 0; // Add a counter to track the number of retries - this.getInstance.interceptors.response.use( (response) => response, async (e) => { if (e.code === 403) { - if (retryCount < 1) { + if (this.retryCount < 1) { // Only retry once - retryCount += 1; + this.retryCount += 1; try { await this.authentication.refresh(); const updatedConfig = e.config; @@ -46,7 +39,7 @@ export default class CarpTestClient extends CarpClient { }; return await this.getInstance.request(updatedConfig); } catch (error) { - retryCount = 0; + this.retryCount = 0; return Promise.reject(error); } } diff --git a/src/test/endpoints/accounts.test.ts b/src/test/endpoints/accounts.test.ts index ec48ab1..90a51a5 100644 --- a/src/test/endpoints/accounts.test.ts +++ b/src/test/endpoints/accounts.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, it } from "vitest"; +import CarpTestClient from "../client/carpTestClient"; import { setupTestClient } from "@/test/utils"; -import { CarpClient } from "@/client"; describe("Accounts service", () => { - let testClient: CarpClient; + let testClient: CarpTestClient; beforeAll(async () => { const { client } = await setupTestClient(); @@ -19,20 +19,19 @@ describe("Accounts service", () => { it("Should get redirect URIs", async () => { const redirectURIs = await testClient.accounts.getRedirectURIs(); - expect(redirectURIs.data.length).not.be.equal(0); - expect(redirectURIs.data).contains("https://dev.carp.dk/icat*"); + expect(Object.keys(redirectURIs.data)).contains("studies-app"); + expect(redirectURIs.data["studies-app"]).contains("carp-studies:/*"); }); // TODO: stop skipping when backend support works again it.todo( "Checking if a researcher account is a researcher should return true", async () => { - await expect( - testClient.accounts.isAccountOfRole({ - role: "RESEARCHER", - emailAddress: import.meta.env.VITE_RESEARCHER_EMAIL, - }), - ).resolves.toBeTruthy(); + const response = await testClient.accounts.isAccountOfRole({ + role: "RESEARCHER", + emailAddress: import.meta.env.VITE_RESEARCHER_EMAIL, + }); + expect(response.data).toBe(true); }, ); @@ -40,12 +39,11 @@ describe("Accounts service", () => { it.todo( "Checking if a participant account is a researcher should return false", async () => { - await expect( - testClient.accounts.isAccountOfRole({ - role: "RESEARCHER", - emailAddress: import.meta.env.VITE_PARTICIPANT_EMAIL, - }), - ).resolves.toBeFalsy(); + const response = await testClient.accounts.isAccountOfRole({ + role: "RESEARCHER", + emailAddress: import.meta.env.VITE_PARTICIPANT_EMAIL, + }); + expect(response.data).toBe(false); }, ); diff --git a/src/test/endpoints/auth.test.ts b/src/test/endpoints/auth.test.ts index c5144ba..b2513ff 100644 --- a/src/test/endpoints/auth.test.ts +++ b/src/test/endpoints/auth.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; -import { CarpTestClient } from "@/client"; +import CarpTestClient from "../client/carpTestClient"; import { CarpToken } from "@/endpoints/auth"; import { CarpServiceError } from "@/shared"; diff --git a/src/test/endpoints/dataStreams.test.ts b/src/test/endpoints/dataStreams.test.ts index 7dd3c55..bff5792 100644 --- a/src/test/endpoints/dataStreams.test.ts +++ b/src/test/endpoints/dataStreams.test.ts @@ -1,5 +1,6 @@ import { describe } from "node:test"; import { afterAll, beforeAll, expect, test } from "vitest"; +import CarpTestClient from "../client/carpTestClient"; import { StudyStatus, ParticipantGroupStatus, @@ -17,7 +18,6 @@ import { getSerializer, StudyProtocolSnapshot, } from "@/shared"; -import { CarpTestClient } from "@/client"; import { STUDY_PROTOCOL } from "../consts"; import { setupTestClient } from "../utils"; import CarpDataStreamBatch from "@/shared/models/carpDataStreamBatch"; @@ -107,7 +107,7 @@ describe("DataStreams", () => { ], }), ).resolves.not.toThrow(); - }, 25000); + }, 70000); test("should be able to append to a data stream", async () => { const batch = new CarpDataStreamBatch(); @@ -385,5 +385,5 @@ describe("DataStreams", () => { studyId: study.studyId.stringRepresentation, }); } - }); + }, 70000); }); diff --git a/src/test/endpoints/email.test.ts b/src/test/endpoints/email.test.ts index 8a506b8..9c20bb6 100644 --- a/src/test/endpoints/email.test.ts +++ b/src/test/endpoints/email.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, test } from "vitest"; -import { CarpTestClient } from "@/client"; +import CarpTestClient from "../client/carpTestClient"; import { generateRandomEmail, setupTestClient } from "@/test/utils"; describe("Email", () => { diff --git a/src/test/endpoints/participation.test.ts b/src/test/endpoints/participation.test.ts index 5b8e4da..9f70258 100644 --- a/src/test/endpoints/participation.test.ts +++ b/src/test/endpoints/participation.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Instant } from "@js-joda/core"; +import CarpTestClient from "../client/carpTestClient"; import { setupTestClient } from "@/test/utils"; import { STUDY_PROTOCOL } from "@/test/consts"; -import { CarpTestClient } from "@/client"; import { Sex, DefaultSerializer, diff --git a/src/test/endpoints/protocols.test.ts b/src/test/endpoints/protocols.test.ts index a1fba55..d70c6da 100644 --- a/src/test/endpoints/protocols.test.ts +++ b/src/test/endpoints/protocols.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "vitest"; +import CarpTestClient from "../client/carpTestClient"; import { STUDY_PROTOCOL } from "@/test/consts"; -import { CarpTestClient } from "@/client"; import { setupTestClient } from "../utils"; describe("Protocols", () => { diff --git a/src/test/endpoints/studies.test.ts b/src/test/endpoints/studies.test.ts index b90b88e..d868178 100644 --- a/src/test/endpoints/studies.test.ts +++ b/src/test/endpoints/studies.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { setupTestClient } from "@/test/utils"; -import { CarpTestClient } from "@/client"; import { StudyStatus } from "@/shared"; import { StudyOverview } from "@/shared/models"; +import CarpTestClient from "../client/carpTestClient"; describe("Studies service", () => { let testClient: CarpTestClient; diff --git a/src/test/endpoints/study/collections.test.ts b/src/test/endpoints/study/collections.test.ts new file mode 100644 index 0000000..53a481a --- /dev/null +++ b/src/test/endpoints/study/collections.test.ts @@ -0,0 +1,174 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + CarpDocument, + DefaultSerializer, + getSerializer, + ResourceData, + StudyProtocolSnapshot, + StudyStatus, +} from "@/shared"; +import CarpTestClient from "../../client/carpTestClient"; +import { STUDY_PROTOCOL } from "@/test/consts"; +import { setupTestClient } from "@/test/utils"; + +describe("Collections", () => { + let testClient: CarpTestClient; + let study: StudyStatus; + let researcherAccountId: string; + let document: CarpDocument; + + const data = { + title: "Test Document", + description: "Test test", + someNumber: 42, + }; + + beforeAll(async () => { + const { client, accountId } = await setupTestClient(); + testClient = client; + researcherAccountId = accountId; + + // create a study + study = await testClient.studies.create({ + name: "Test study", + description: "This is a test study", + ownerId: researcherAccountId, + }); + + // set invitation + await testClient.study.setInvitation({ + studyId: study.studyId.stringRepresentation, + title: "Test invitation", + description: "This is a test invitation", + }); + + // set protocol + const json = DefaultSerializer; + const serializer = getSerializer(StudyProtocolSnapshot); + const protocol = json.decodeFromString( + serializer, + JSON.stringify(STUDY_PROTOCOL), + ) as StudyProtocolSnapshot; + + await testClient.study.setProtocol({ + protocol, + studyId: study.studyId.stringRepresentation, + }); + + // set study as live + await testClient.study.goLive({ + studyId: study.studyId.stringRepresentation, + }); + + document = await testClient.study.collections.createDocument({ + studyId: study.studyId.stringRepresentation, + collectionName: "resources", + document: data, + fileName: "Test Document", + }); + + await testClient.authentication.refresh(); + }, 25000); + + it("should be able to create a document", async () => { + expect(document).toBeDefined(); + expect(document.data as ResourceData).toEqual(data); + }); + + it("should be able to query collection", async () => { + const resources = await testClient.study.collections.getByName({ + studyId: study.studyId.stringRepresentation, + collectionName: "resources", + }); + + expect(resources).toBeDefined(); + expect(resources.documents.length).toBeGreaterThan(0); + expect(resources.documents[0].data as ResourceData).toEqual(data); + }); + + it("should be able to get a document by id", async () => { + const retrievedDocument = + await testClient.study.collections.getDocumentById({ + studyId: study.studyId.stringRepresentation, + documentId: document.id, + }); + + expect(retrievedDocument).toBeDefined(); + expect(retrievedDocument.data).toEqual(data); + }); + + it("should be able to get document by name", async () => { + const retrievedDocument = + await testClient.study.collections.getDocumentByFileName({ + studyId: study.studyId.stringRepresentation, + collectionName: "resources", + fileName: document.name, + }); + + expect(retrievedDocument).toBeDefined(); + expect(retrievedDocument.data).toEqual(data); + }); + + it("should be able to update a document", async () => { + const newData = { + title: "Updated test document", + description: "Updated description", + someNumber: 84, + }; + + const updatedDocument = + await testClient.study.collections.updateDocumentById({ + studyId: study.studyId.stringRepresentation, + documentId: document.id, + document: newData, + fileName: "Updated test file", + }); + + expect(updatedDocument).toBeDefined(); + expect(updatedDocument.data as ResourceData).toEqual(newData); + + await testClient.study.collections.updateDocumentById({ + studyId: study.studyId.stringRepresentation, + documentId: document.id, + document: data, + fileName: "Test file", + }); + }); + + it("should be able to upload an image", async () => { + const imageData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const file = new File([imageData], "test.png", { type: "image/png" }); + const imageDocument = await testClient.study.collections.uploadImage({ + studyId: study.studyId.stringRepresentation, + image: file, + }); + + expect(imageDocument).toBeDefined(); + expect(() => new URL(imageDocument)).not.throws(); + }); + + it("should be able to delete a document", async () => { + const newDocument = await testClient.study.collections.createDocument({ + studyId: study.studyId.stringRepresentation, + collectionName: "resources", + document: data, + fileName: document.name, + }); + + expect(newDocument).toBeDefined(); + expect(newDocument.data as ResourceData).toEqual(data); + + await testClient.study.collections.deleteDocumentById({ + studyId: study.studyId.stringRepresentation, + documentId: newDocument.id, + }); + + const resources = await testClient.study.collections.getByName({ + studyId: study.studyId.stringRepresentation, + collectionName: "resources", + }); + + const found = resources.documents.find((doc) => doc.id === newDocument.id); + expect(found).toBeUndefined(); + }); +}); diff --git a/src/test/endpoints/study/deployments.test.ts b/src/test/endpoints/study/deployments.test.ts index 4e51bb8..e923b1f 100644 --- a/src/test/endpoints/study/deployments.test.ts +++ b/src/test/endpoints/study/deployments.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { setupTestClient } from "@/test/utils"; import { STUDY_PROTOCOL } from "@/test/consts"; -import { CarpTestClient } from "@/client"; +import CarpTestClient from "../../client/carpTestClient"; import { DefaultSerializer, ParticipantGroupStatus, @@ -78,7 +78,7 @@ describe("Deployments", () => { }); await testClient.authentication.refresh(); - }, 25000); + }, 50000); it("should be able to register and deploy device", async () => { const deploymentStatus = await testClient.study.deployments.registerDevice({ diff --git a/src/test/endpoints/study/exports.test.ts b/src/test/endpoints/study/exports.test.ts index 4a623ab..9ba496f 100644 --- a/src/test/endpoints/study/exports.test.ts +++ b/src/test/endpoints/study/exports.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { CarpTestClient } from "@/client"; +import CarpTestClient from "../../client/carpTestClient"; import { setupTestClient } from "@/test/utils"; import { StudyStatus, @@ -76,7 +76,7 @@ describe("Exports", () => { }); await testClient.authentication.refresh(); - }, 25000); + }, 40000); test("should be able to get empty list of exports", async () => { await expect( @@ -131,6 +131,55 @@ describe("Exports", () => { expect(downloadedExport).toBeDefined(); }); + test("anonymous participant without subdomain can be generated", async () => { + const response = + await testClient.study.recruitment.generateAnonymousAccounts({ + studyId: study.studyId.stringRepresentation, + amountOfAccounts: 1, + expirationSeconds: 3600, + clientId: "studies-app", + redirectUri: "carp-studies:/callback", + participantRoleName: "Participant", + }); + + expect(response).toBeDefined(); + await new Promise((resolve) => { + setTimeout(resolve, 3000); + }); + + const exports = await testClient.study.exports.getAll({ + studyId: study.studyId.stringRepresentation, + }); + expect( + exports.find((e) => e.type === "ANONYMOUS_PARTICIPANTS").status, + ).toBe("AVAILABLE"); + }); + + test("anonymous participant with subdomain can be generated", async () => { + const response = + await testClient.study.recruitment.generateAnonymousAccounts({ + studyId: study.studyId.stringRepresentation, + amountOfAccounts: 1, + expirationSeconds: 3600, + clientId: "studies-app", + redirectUri: "https://csa.dev.carp.dk/anonyomous", + subdomain: null, + participantRoleName: "Participant", + }); + + expect(response).toBeDefined(); + await new Promise((resolve) => { + setTimeout(resolve, 3000); + }); + + const exports = await testClient.study.exports.getAll({ + studyId: study.studyId.stringRepresentation, + }); + expect( + exports.find((e) => e.type === "ANONYMOUS_PARTICIPANTS").status, + ).toBe("AVAILABLE"); + }); + afterAll(async () => { if (study) { await testClient.study.delete({ diff --git a/src/test/endpoints/study/files.test.ts b/src/test/endpoints/study/files.test.ts index 5224eb8..3ec666d 100644 --- a/src/test/endpoints/study/files.test.ts +++ b/src/test/endpoints/study/files.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { CarpTestClient } from "@/client"; +import CarpTestClient from "../../client/carpTestClient"; import { setupTestClient } from "@/test/utils"; import { StudyStatus, diff --git a/src/test/endpoints/study/participants.test.ts b/src/test/endpoints/study/participants.test.ts index b446acc..4bbdb2c 100644 --- a/src/test/endpoints/study/participants.test.ts +++ b/src/test/endpoints/study/participants.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import CarpTestClient from "../../client/carpTestClient"; import { generateRandomEmail, setupTestClient } from "@/test/utils"; import { STUDY_PROTOCOL } from "../../consts"; import { @@ -9,7 +10,6 @@ import { StudyStatus, getSerializer, } from "@/shared"; -import { CarpTestClient } from "@/client"; describe("Study participant endpoints", () => { let testClient: CarpTestClient; @@ -52,7 +52,7 @@ describe("Study participant endpoints", () => { await testClient.study.goLive({ studyId: study.studyId.stringRepresentation, }); - }); + }, 30000); it("should be able to add participant by email to a study", async () => { // generate a random email diff --git a/src/test/endpoints/study/recruitment.test.ts b/src/test/endpoints/study/recruitment.test.ts index 22fcf3b..5c49ab8 100644 --- a/src/test/endpoints/study/recruitment.test.ts +++ b/src/test/endpoints/study/recruitment.test.ts @@ -1,4 +1,5 @@ import { describe, beforeAll, expect, it, afterAll } from "vitest"; +import CarpTestClient from "../../client/carpTestClient"; import { STUDY_PROTOCOL } from "../../consts"; import { DefaultSerializer, @@ -10,7 +11,6 @@ import { PaginatedParticipantAccounts, } from "@/shared"; import { generateRandomEmail, setupTestClient } from "@/test/utils"; -import { CarpTestClient } from "@/client"; describe("Recruitment", () => { let participants: Participant[]; @@ -68,7 +68,7 @@ describe("Recruitment", () => { participants = await testClient.study.recruitment.getParticipants({ studyId: study.studyId.stringRepresentation, }); - }, 25000); + }, 40000); it("should be able to invite new participant group", async () => { participantGroupStatus = @@ -148,6 +148,7 @@ describe("Recruitment", () => { offset: 0, search: null, response_as_dto: true, + is_descending: false, }); expect(accountInfo).toBeDefined(); diff --git a/src/test/endpoints/study/study.test.ts b/src/test/endpoints/study/study.test.ts index 6ccbaba..ed5f868 100644 --- a/src/test/endpoints/study/study.test.ts +++ b/src/test/endpoints/study/study.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import CarpTestClient from "../../client/carpTestClient"; import { STUDY_PROTOCOL, STUDY_PROTOCOL_ID } from "@/test/consts"; import { setupTestClient } from "@/test/utils"; import { @@ -10,7 +11,6 @@ import { getSerializer, StudyProtocolSnapshot, } from "@/shared"; -import { CarpTestClient } from "@/client"; describe("Study", () => { let testClient: CarpTestClient; @@ -197,6 +197,7 @@ describe("Study", () => { testClient.study.researchers.addResearcherToStudy({ studyId: study.studyId.stringRepresentation, email: "researcher@cachet.dk", + role: "RESEARCHER", }), ).resolves.not.toThrow(); const researchers = await testClient.study.researchers.getStudyResearchers({ @@ -210,6 +211,26 @@ describe("Study", () => { expect(researcher).not.toBe(undefined); }); + it("should be able to add a research assistant to a study", async () => { + await expect( + testClient.study.researchers.addResearcherToStudy({ + studyId: study.studyId.stringRepresentation, + email: "research_assistant@cachet.dk", + role: "RESEARCH_ASSISTANT", + }), + ).resolves.not.toThrow(); + const researchAssistants = + await testClient.study.researchers.getStudyResearchAssistants({ + studyId: study.studyId.stringRepresentation, + }); + + expect(researchAssistants).toBeInstanceOf(Array); + const researcher = researchAssistants.find( + (r) => r.email === "research_assistant@cachet.dk", + ); + expect(researcher).not.toBe(undefined); + }); + it("should be able to remove a researcher from a study", async () => { await expect( testClient.study.researchers.removeResearcherFromStudy({ @@ -229,6 +250,26 @@ describe("Study", () => { expect(researcher).toBe(undefined); }); + it("should be able to remove a research assistant from a study", async () => { + await expect( + testClient.study.researchers.removeResearcherFromStudy({ + studyId: study.studyId.stringRepresentation, + email: "research_assistant@cachet.dk", + }), + ).resolves.not.toThrow(); + + const researchAssistants = + await testClient.study.researchers.getStudyResearchAssistants({ + studyId: study.studyId.stringRepresentation, + }); + + expect(researchAssistants).toBeInstanceOf(Array); + const researchAssistant = researchAssistants.find( + (r) => r.email === "research_assistant@cachet.dk", + ); + expect(researchAssistant).toBe(undefined); + }); + it("study should be able to go live", async () => { const studyBefore = await testClient.study.getStatus({ studyId: study.studyId.stringRepresentation, diff --git a/src/test/utils.ts b/src/test/utils.ts index bd6e9aa..b5e56ff 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,4 +1,4 @@ -import { CarpTestClient } from "@/client"; +import CarpTestClient from "./client/carpTestClient"; export const setupTestClient = async () => { const client = new CarpTestClient({ diff --git a/vitest.config.ts b/vitest.config.ts index 91aa170..70c89e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,7 @@ export default ({ mode }) => { reporters: [new CustomReporter(shouldLogOnSuccess)], fileParallelism: false, maxConcurrency: 1, - testTimeout: 20000, + testTimeout: 50000, retry: 1, isolate: false, coverage: {