diff --git a/.github/workflows/deploy_alpha.yml b/.github/workflows/deploy_alpha.yml index b3eda993..227722bd 100644 --- a/.github/workflows/deploy_alpha.yml +++ b/.github/workflows/deploy_alpha.yml @@ -9,10 +9,10 @@ jobs: test-unit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -23,10 +23,10 @@ jobs: test-integration: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -35,12 +35,13 @@ jobs: run: npm run test:integration test-functional: + needs: [test-integration] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -54,10 +55,10 @@ jobs: permissions: packages: write steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 registry-url: https://npm.pkg.github.com/ - name: Install npm dependencies diff --git a/.github/workflows/deploy_pr.yml b/.github/workflows/deploy_pr.yml index 8635e0f3..692f9d58 100644 --- a/.github/workflows/deploy_pr.yml +++ b/.github/workflows/deploy_pr.yml @@ -6,10 +6,10 @@ jobs: test-unit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -20,10 +20,10 @@ jobs: test-integration: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -32,12 +32,13 @@ jobs: run: npm run test:integration test-functional: + needs: [test-integration] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci @@ -52,10 +53,10 @@ jobs: packages: write contents: read steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 registry-url: https://npm.pkg.github.com/ - name: Install npm dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3eeeeb66..9c0e8093 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,10 +6,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 19 + node-version: 22 - name: Install npm dependencies run: npm ci diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ba987145 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# Agent guide: dataverse-client-javascript + +This file is the canonical reference for any AI agent or tool working in this repository: Claude Code, GitHub Copilot, or otherwise. Tools that support `AGENTS.md` may load it automatically; point other tools here before they make changes. + +`@iqss/dataverse-client-javascript` is a use-case-driven TypeScript SDK for the [Dataverse API](https://guides.dataverse.org/en/latest/api/native-api.html). It's part of the Dataverse Frontend ecosystem: the `dataverse-frontend` SPA and other consumers import use cases from this package instead of calling the REST API directly. Every public capability is exposed as a `use case` object with an `.execute(...)` method, documented one by one in [docs/useCases.md](docs/useCases.md). + +## Layering (Clean Architecture / DDD) + +Each top-level domain lives in its own folder under `src/` (`datasets`, `files`, `collections`, `access`, `guestbooks`, `users`, and so on). Inside a domain folder: + +``` +src// + domain/ + models/ plain TS interfaces, the shapes use cases return + dtos/ input shapes for write operations + repositories/ IRepository, the interface use cases depend on + useCases/ one class per use case, implements UseCase, has execute() + infra/ + repositories/ + Repository.ts implements IRepository, extends ApiRepository + transformers/ raw API JSON to domain model mapping functions + index.ts wires everything together (see below) +``` + +A use case class never talks to axios directly. It depends on the `IRepository` interface, constructor-injected, and the concrete `Repository` is the only thing that knows the actual REST endpoint shape. That split keeps the unit tests clean: use-case tests stub the repository interface, repository tests mock `axios` and assert the exact URL, params, and headers sent. + +Each domain's `index.ts` is the composition root: + +```typescript +const datasetsRepository = new DatasetsRepository() +const getDataset = new GetDataset(datasetsRepository) +// one instantiation per use case, all sharing the one repository instance + +export { getDataset /* , all other use case instances */ } +export { Dataset, PreviewUrl /* , domain model types consumers need */ } from './domain/models/...' +``` + +Consumers `import { getDataset } from '@iqss/dataverse-client-javascript'` and call `getDataset.execute(...)`. They get an already-wired singleton, never a class they need to instantiate themselves. + +That import works because the exports chain one level further than a single domain's `index.ts`: the package root `src/index.ts` does `export * from './datasets'`, `export * from './files'`, and so on for every domain, and `package.json`'s `main` and `types` fields point at `dist/index.js` and `dist/index.d.ts`, built from that root file by `npm run build` (`tsc`). Adding a new use case to a domain's `index.ts` is enough; nothing else needs updating for it to reach consumers. + +Grouping related use cases in a subfolder: when several use cases form one cohesive feature, put them in `domain/useCases//` rather than flat in `useCases/`. There's precedent for this beyond a single example: `domain/useCases/validators/` groups the dataset metadata field validators (`MetadataFieldValidator` and friends) that `CreateDataset`/`UpdateDataset` run before submitting, and that throw `ResourceValidationError` when a field value is invalid. Subfolders aren't the default; most use cases sit flat. + +## The HTTP layer + +- `ApiRepository`, in `src/core/infra/repositories/`, is the base class every `Repository` extends. It exposes `doGet`, `doPost`, `doPut`, `doDelete`, thin wrappers around axios. +- `ApiConfig` is a global singleton holding the configured `dataverseApiUrl` and auth mechanism (`API_KEY`, `SESSION_COOKIE`, or `BEARER_TOKEN`), set once via `ApiConfig.init(...)`. +- `doGet` accepts an `authRequired: boolean` argument, defaulted to `false`. `doPost`, `doPut`, and `doDelete` always call `buildRequestConfig` with `authRequired: true` in the current implementation. When auth is required, `buildRequestConfig` (in `apiConfigBuilders.ts`) attaches whichever credential `ApiConfig` currently holds: an `X-Dataverse-Key` header for `API_KEY`, a cookie for `SESSION_COOKIE`, or an `Authorization: Bearer` header for `BEARER_TOKEN`. When `doGet` passes `false`, no ambient credential is attached at all, regardless of what's configured. +- Dataverse itself accepts an API key, or any equivalent token, via the `X-Dataverse-Key` header or a `?key=` query parameter; either works, for any authenticated endpoint. That equivalence is why token-based access (see Preview URLs below) can be threaded through as a query param without touching the auth-mechanism plumbing at all. + +### Gotcha: ambient credentials silently beat an explicit token + +If a method hardcodes `authRequired: true` and also accepts a token to put in `?key=`, both go out on the same request: the configured ambient credential in the header, and the token in the query param. Empirically, Dataverse's permission check resolves the ambient-credential identity first; the query-param token is only consulted when the request is otherwise anonymous. So if a caller-supplied token needs to be authoritative, for example a Preview URL token letting an unauthenticated reviewer in, pass `authRequired: someToken === undefined` instead of a hardcoded `true`. Otherwise a caller who happens to have their own unrelated, permissionless credentials configured gets denied even with a perfectly valid token. + +### The error model: `ReadError` versus `WriteError` + +Both extend `RepositoryError` (`src/core/domain/repositories/RepositoryError.ts`), and the split follows the HTTP method, not the domain: `doGet` failures throw `ReadError`, `doPost`/`doPut`/`doDelete` failures throw `WriteError`. A test asserting `.rejects.toBeInstanceOf(ReadError)` against a failed create or delete call fails for the wrong reason, since that call actually throws `WriteError`. When in doubt, check which `do*` method the repository method under test calls. + +## Finding the API spec for a new use case + +Before writing any code, pin down the exact endpoint, HTTP method, and payload shape: + +1. **Check the official docs first**: [native-api.html](https://guides.dataverse.org/en/latest/api/native-api.html) is the source of truth (also see [dataaccess.html](https://guides.dataverse.org/en/latest/api/dataaccess.html) for file-access endpoints). If the endpoint is documented there, that's the spec. +2. **If it's not documented yet**, because the backend feature landed on `develop` but isn't in a docs release, or is still in review, search open PRs at [github.com/IQSS/dataverse/pulls](https://github.com/IQSS/dataverse/pulls). The PR's description and diff are the spec until the guide catches up. +3. **If you can't find or confidently identify the right PR, ask the user** for the source (a PR link, issue, or discussion thread) rather than guessing at an endpoint's shape from first principles. +4. **To integration-test against a not-yet-merged PR's backend changes**, check the PR's comments for a `github-actions[bot]` message starting "Pushed preview images as": every open Dataverse PR gets one, publishing a temporary Docker image built from that exact branch, for example: + ``` + ghcr.io/gdcc/dataverse:12535-download-without-guestbook-response-for-preview-user2 + ghcr.io/gdcc/configbaker:12535-download-without-guestbook-response-for-preview-user2 + ``` + `test/environment/docker-compose.yml` already reads its images from `${DATAVERSE_IMAGE_REGISTRY}/gdcc/dataverse:${DATAVERSE_IMAGE_TAG}`, and the equivalent for `configbaker`, defaulted in `test/environment/.env` to `DATAVERSE_IMAGE_REGISTRY=docker.io` and `DATAVERSE_IMAGE_TAG=unstable`. To point a local environment at the PR's temp build instead, override both in `test/environment/.env`: + ``` + DATAVERSE_IMAGE_REGISTRY=ghcr.io + DATAVERSE_IMAGE_TAG= + ``` + This lets integration tests against the new endpoint pass before the backend PR merges. Revert `.env` back to the `docker.io`/`unstable` defaults once done: CI and everyone else's local runs assume the default image, and a committed PR-specific tag breaks the suite for anyone else once that PR's preview image is cleaned up (GHCR preview images are generally scoped to the PR's lifetime). + +## Checking the backend source for complex logic + +The API guide states what an endpoint does. It doesn't state precedence rules, edge cases, or exactly how a permission check resolves. For that, read the Java implementation directly at [github.com/IQSS/dataverse](https://github.com/IQSS/dataverse). + +Start in `src/main/java/edu/harvard/iq/dataverse/api/`, one class per domain area (`Datasets.java`, `Access.java`, `Files.java`, `Users.java`, and so on). JAX-RS `@Path` annotations map directly onto the REST paths from the guide, so grepping a class for the path segment finds the resource method quickly. Follow that method into whatever `Command` class it executes: Dataverse runs its business logic through a Command pattern, and that's where permission checks and edge-case behavior actually live, not in the resource class itself. + +Verified example: `Datasets.java` defines `/datasets/{id}/previewUrl` and the deprecated `/datasets/{id}/privateUrl` as separate `@Path`-annotated methods that delegate to the same implementation (`createPrivateUrl(...)` calls `createPreviewUrl(...)`), which executes a `CreatePrivateUrlCommand`. The token-consuming side, `/datasets/previewUrlDatasetVersion/{token}`, resolves the token through `PrivateUrlServiceBean.getPrivateUrlUserFromToken(...)` into a `PrivateUrlUser` principal. None of that is in the API guide; it only became clear from reading the class directly. + +## Adding or modifying a use case: the standard recipe + +Follow this sequence: + +1. **Model**: add or extend a type in `domain/models/` if the shape doesn't exist yet. +2. **Repository interface**: add the method signature to `IRepository`. +3. **Repository implementation**: implement it in `Repository`, calling `doGet`/`doPost`/`doPut`/`doDelete` against the real endpoint. Add a transformer function in `infra/repositories/transformers/` if the raw JSON shape differs from the domain model. +4. **Use case class**: a small class implementing `UseCase` with one `execute(...)` method that delegates to the repository. Keep it thin; all real logic belongs in the repository or transformer. +5. **Wire it up** in the domain's `index.ts`: instantiate and export. +6. **Unit tests**: one test file per use case (stub the repository interface, assert delegation) plus coverage of the new repository method in `Repository.test.ts` (mock `axios`, assert the exact URL, `params`, and `headers` sent). Both live in `test/unit//`. +7. **Integration tests**: not optional, even when the unit tests already pass. Real assertions against a live Dataverse instance, in `test/integration//`. See "What integration tests must cover" below for what "done" means here. Prefer exercising the SDK's own use cases for test setup and fixtures over raw `axios` test helpers when a suitable use case already exists; dogfooding catches real response-shape bugs early. +8. **Functional tests**: add or update a test in `test/functional//` when the change affects a public use case's end-to-end behavior. Functional tests exercise the exported SDK against a live Dataverse instance; cover the user-visible workflow rather than implementation details. +9. **Docs**: add a section to [docs/useCases.md](docs/useCases.md) matching an existing entry's format, plus a line in its table of contents. +10. **Changelog**: a line under `## [Unreleased] > ### Added` (or `Changed`/`Fixed`) in [CHANGELOG.md](CHANGELOG.md). + +This recipe assumes a single request-response call. File upload doesn't fit it: it's a multi-step flow through `DirectUploadClient` (`src/files/infra/clients/`), not one repository method. Look there first if the new use case is upload-shaped rather than a plain CRUD call. + +### What integration tests must cover + +Unit tests only prove the SDK builds the request it intended to build; they can't catch how the real backend actually responds. Cover at least: + +- **Both id formats**: numeric id and persistent identifier (`doi:...`). `buildApiEndpoint` (`ApiRepository.ts`) builds a different URL for each. +- **Authentication variations**: sufficient permission, a different real user with insufficient permission (via `createBuiltInUser`, not just a missing API key), and unauthenticated where the endpoint allows it. These are different backend code paths and fail for different reasons. +- **Every lifecycle state the endpoint touches**: `DatasetVersionState` (`DRAFT`, `RELEASED`, `ARCHIVED`, `DEACCESSIONED`) and, for files, `FileAccessStatus` (`PUBLIC`, `RESTRICTED`, `EMBARGOED`, `EMBARGOED_RESTRICTED`). A deaccessioned dataset needs `includeDeaccessioned: true` just to be found. +- **The specific error, not any error**: `.rejects.toBeInstanceOf(ReadError)` passes whether the dataset doesn't exist or a token was rejected. Assert something specific enough to rule out the wrong failure reason. + +## Testing: three suites, three configs + +| Command | Config | Docker? | +| ------------------------------------- | ---------------------------- | -------------------------------------------- | +| `npm run test:unit` | `jest.config.unit.ts` | No: `globalSetup` is deleted for this config | +| `npm run test:integration` | `jest.config.integration.ts` | Yes, full stack | +| `npm run test:functional` | `jest.config.functional.ts` | Yes | +| `npm test` / `jest -c jest.config.ts` | base config | Yes, runs everything including unit | + +Gotcha: running unit tests via the base config (`jest -c jest.config.ts test/unit/...`) still pays the full Docker spin-up and teardown cost, because `globalSetup` is only stripped out in `jest.config.unit.ts`. Use `npm run test:unit` (or `jest -c jest.config.unit.ts`) for fast unit-only iteration. + +Integration and functional runs spin up Postgres, Solr, LocalStack, and a Dataverse container via `testcontainers` reading `test/environment/docker-compose.yml`, wait for the bootstrap container to log "Done, your instance has been configured for development. Have a nice day!", then call `setupApiKey()` (`test/environment/setup.ts`) to fetch a superuser API key into `process.env.TEST_API_KEY`. This teardown and setup cycle runs on every invocation ("Cleaning up old container volumes..."), so it's slow, and repeated back-to-back runs can strain Docker. + +- Use `-t ""` (Jest's `testNamePattern`) to run a subset fast. Jest skips `beforeAll`/`beforeEach` hooks for `describe` blocks with zero matching tests, so a filtered run of a handful of tests finishes in a few seconds instead of minutes. Hooks on any ancestor of a matching test still run, though. +- `test/environment/docker-compose.yml`'s published port (Dataverse on `8080`) and `test/testHelpers/TestConstants.ts`'s `TEST_API_URL` have to agree. If they drift, `setupApiKey()` fails to connect and the whole suite dies with an axios `AggregateError` right after "Test containers up and running," easy to misdiagnose as a code bug when it's really a config-pair mismatch. If a non-default port is needed locally, for example because another Dataverse dev environment on the same machine already holds 8080, change both files together, and revert both together too: plain `8080:8080` is what CI expects, since CI runners don't have a competing local Dataverse instance. + +## Preventing flaky tests and race conditions + +- **Reuse running containers for iterative work instead of recreating them.** `setupContainers()` (`test/environment/setup.ts`) skips setup entirely when `SKIP_CONTAINERS=true`, and the `:no-teardown` npm scripts set `TESTCONTAINERS_RYUK_DISABLED=true` so containers survive after Jest exits. Start once with `npm run test:integration:no-teardown`, then iterate with `SKIP_CONTAINERS=true npm run test:integration`. Recreating the full stack on every run is itself a source of flakiness: repeated teardown and startup cycles compete for the same host ports and can time out the bootstrap wait strategy under load. +- **Don't shrink a retry budget below its default without a documented reason.** `waitForNoLocks` (`test/testHelpers/datasets/datasetHelper.ts`) defaults to 20 retries at 1 second each; a test passing a shorter override saves a few seconds on a passing run but leaves far less margin on a slow or loaded machine, and the failure mode is a timeout that reproduces inconsistently rather than a clear error. +- **Give lifecycle tests (create, publish, delete) their own dataset** rather than reusing one a sibling `describe` already set up for something else. Deleting a resource in one test must never be able to break an unrelated test elsewhere in the file just because it happened to run later against the same fixture. +- **Assign derived values inside `beforeEach`, not at `describe`-body scope.** A `const` read directly in a `describe(...)` callback resolves before any `beforeAll` runs, so it captures a stale or `undefined` value from an outer `let` an ancestor hook sets later (see the Jest pitfalls below). This is a race dressed up as a typo: it produces a consistently wrong value rather than a visibly broken test, which makes it easy to miss in review. +- **Reset shared ambient state explicitly.** Wrap any test that changes `ApiConfig` (or similar global state) in its own `beforeEach`/`afterEach` reset rather than leaving the reset implicit or assuming test order. Inserting one new test between two others is enough to expose an implicit dependency on leftover state. + +## Jest pitfalls worth knowing up front + +- **Describe-registration-time capture**: a bare `const x = someOuterLetVariable` written directly in a `describe(...)` callback body runs at registration time, before any `beforeAll` has executed, so it captures whatever the outer variable holds at that instant, often `undefined`, not its value at test-run time. To use the current value of something an ancestor `beforeAll` sets, assign it inside a `beforeEach`/`beforeAll`, not as a describe-body `const`. +- **`toHaveBeenCalledWith` and trailing `undefined`**: adding a new trailing optional parameter to a method changes the argument count callers pass, even when the new argument is `undefined`. Existing exact-match assertions (`toHaveBeenCalledWith(a, b, c)`) fail against a call that now includes a fourth `undefined` argument; jest's mock-call equality isn't forgiving about trailing `undefined` items the way `toEqual` is for object properties. Update every existing assertion to include the new trailing argument explicitly. + +## Git hygiene + +The `.husky/pre-commit` hook runs `npm run format`, `npm run typecheck`, `npm run lint:fix`, and then `git add .` as separate commands. That final `git add .` stages every modified or untracked file in the working tree, not just what got `git add`ed deliberately. Unrelated in-progress edits sitting in the working tree at commit time get swept in regardless of intent. Set aside anything that shouldn't be bundled before committing, for example with `git stash push -- `, or by copying it elsewhere and restoring it after. + +## Creating a pull request + +1. **Branch from `develop`**, the default branch, not `main`. Name the branch after the issue it addresses, `-`, matching the convention already in use (`149-configbaker`, `168-get-all-metadatablocks`). +2. **Work through the [CONTRIBUTING.md](CONTRIBUTING.md) checklist** before opening the PR: the project builds, `npm run lint` and `npm run format` pass, unit and integration tests pass, new tests cover the new functionality, [docs/useCases.md](docs/useCases.md) has an entry for any use case added or modified, and [CHANGELOG.md](CHANGELOG.md) has a line under `[Unreleased]`. +3. **Push the branch and open the PR against `develop`**: + ``` + gh pr create --base develop --title "" + ``` +4. **Fill in every section of the PR template** ([.github/PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md)): what the PR does and why, which issue it closes, any related `IQSS/dataverse` backend PR it depends on, notes for the reviewer, how to test it, and whether a changelog entry is included. +5. **Link the backend PR** in the "Related Dataverse PRs" field when this SDK change depends on one, the same PR the "Finding the API spec" section above points to when an endpoint isn't documented yet. +6. **Match the existing commit-message convention**: recent history uses a `type: description` prefix (`feat: ...`, `fix: ...`, `chore(deps): ...`), even though it isn't formally enforced. + +`deploy_pr.yml` runs three test jobs on every PR: `test-unit`, `test-integration`, and `test-functional` (the last only after integration passes), then `publish-gpr` builds and publishes the PR package after all test jobs pass. This is the actual enforcement behind step 7 of the use-case recipe: skipping integration tests locally still means CI runs them before merge. + +## Where the rest of the documentation lives + +- [README.md](README.md): one-line pitch, install, minimal usage example +- [docs/useCases.md](docs/useCases.md): the exhaustive per-use-case reference, linked from every use case's JSDoc +- [CHANGELOG.md](CHANGELOG.md): Keep a Changelog format, `[Unreleased]` section at the top +- [CONTRIBUTING.md](CONTRIBUTING.md): PR checklist covering build, lint and format, unit and integration tests for new functionality, docs/useCases.md updates, and changelog updates +- [docs/localDevelopment.md](docs/localDevelopment.md): local dev environment setup +- [docs/making-releases.md](docs/making-releases.md): the release process, versioning, and publishing to npm diff --git a/CHANGELOG.md b/CHANGELOG.md index d042fa65..c1d822c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Removed +## [v2.3.0] -- 2026-10-08 + +### Added + +- Datasets: Added optional `templateId` support to `createDataset` requests and `getDataset` responses. +- Guestbooks: Added `editGuestbook` use case. +- Guestbooks: Added `getGuestbookResponsesByGuestbookId` use case and repository support for retrieving paginated guestbook responses with total count as structured JSON. +- Guestbooks: Added `downloadGuestbookResponsesByCollectionId` and `downloadGuestbookResponsesOfAGuestbook` use cases and repository support for exporting guestbook responses as raw CSV content. +- Guestbooks: Added optional `includeStats` support to `getGuestbooksByCollectionId`, returning `usageCount` and `responseCount` when requested. +- Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats. +- Datasets: Added `getDatasetReviews` use case and repository method to support Dataverse endpoint `GET /datasets/{identifier}/reviews`, for retrieving review datasets associated with a dataset by persistent id or numeric id. +- Datasets: Added `exportDatasetMetadata` use case, repository method, and `ExportedDatasetMetadata` response type to support exporting dataset metadata by numeric id or persistent id through Dataverse endpoint `GET /datasets/export`. +- Datasets: Added `getDatasetVersions` use case. +- Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. +- Collections: Added theme information when retrieving a collection using `getCollection`. +- Collections: Added `setDefaultContributorRole` use case. + +### Changed + +- Files: Added an optional dataset `version` parameter to `getFileCitationByFormat` for version-specific file citation exports. + ## [v2.2.0] -- 2026-04-24 ### Added @@ -25,6 +46,9 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - New Use Case: [Create a Template](./docs/useCases.md#create-a-template) under Templates. - New Use Case: [Get a Template](./docs/useCases.md#get-a-template) under Templates. - New Use Case: [Delete a Template](./docs/useCases.md#delete-a-template) under Templates. +- New Use Case: [Update Template Metadata](./docs/useCases.md#update-template-metadata) under Templates. +- New Use Case: [Update Template License Terms](./docs/useCases.md#update-template-license-terms) under Templates. +- New Use Case: [Update Template Terms Of Access](./docs/useCases.md#update-template-terms-of-access) under Templates. - Templates: Added `setTemplateAsDefault` use case and repository method to support Dataverse endpoint `POST /dataverses/{id}/template/default/{templateId}`. - Templates: Added `unsetTemplateAsDefault` use case and repository method to support Dataverse endpoint `DELETE /dataverses/{id}/template/default`. - New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access). @@ -42,6 +66,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Templates: Rename `CreateDatasetTemplateDTO` to `CreateTemplateDTO`. - Templates: Rename `createDatasetTemplate` repository method to `createTemplate`. - Templates: Rename `getDatasetTemplates` repository method to `getTemplatesByCollectionId`. +- Collections: `updateCollection` now supports partial updates by accepting `Partial<CollectionDTO>`. Only explicitly provided fields are sent in update requests, aligning with Dataverse API semantics. Metadata blocks handling was adjusted to respect inheritance flags and avoid invalid field combinations. ### Fixed diff --git a/docs/useCases.md b/docs/useCases.md index 4fb7a6f1..64a2c8d6 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -11,6 +11,8 @@ The different use cases currently available in the package are classified below, - [Collections](#Collections) - [Collections read use cases](#collections-read-use-cases) - [Get a Collection](#get-a-collection) + - [Get Collection Storage Driver](#get-collection-storage-driver) + - [Get Allowed Collection Storage Drivers](#get-allowed-collection-storage-drivers) - [Get Collection Facets](#get-collection-facets) - [Get User Permissions on a Collection](#get-user-permissions-on-a-collection) - [List All Collection Items](#list-all-collection-items) @@ -19,12 +21,15 @@ The different use cases currently available in the package are classified below, - [Get Collections for Linking](#get-collections-for-linking) - [Collections write use cases](#collections-write-use-cases) - [Create a Collection](#create-a-collection) + - [Set Collection Storage Driver](#set-collection-storage-driver) + - [Delete Collection Storage Driver](#delete-collection-storage-driver) - [Update a Collection](#update-a-collection) - [Publish a Collection](#publish-a-collection) - [Delete a Collection](#delete-a-collection) - [Update Collection Featured Items](#update-collection-featured-items) - [Delete Collection Featured Items](#delete-collection-featured-items) - [Delete a Collection Featured Item](#delete-a-collection-featured-item) + - [Set Default Contributor Role](#set-default-contributor-role) - [Templates](#Templates) - [Templates read use cases](#templates-read-use-cases) - [Get a Template](#get-a-template) @@ -32,6 +37,9 @@ The different use cases currently available in the package are classified below, - [Templates write use cases](#templates-write-use-cases) - [Create a Template](#create-a-template) - [Delete a Template](#delete-a-template) + - [Update Template Metadata](#update-template-metadata) + - [Update Template License Terms](#update-template-license-terms) + - [Update Template Terms Of Access](#update-template-terms-of-access) - [Set Template As Default](#set-template-as-default) - [Unset Template As Default](#unset-template-as-default) - [Datasets](#Datasets) @@ -39,12 +47,15 @@ The different use cases currently available in the package are classified below, - [Get a Dataset](#get-a-dataset) - [Get Dataset By Private URL Token](#get-dataset-by-private-url-token) - [Get Dataset Citation Text](#get-dataset-citation-text) + - [Get Dataset Citation In Other Formats](#get-dataset-citation-in-other-formats) + - [Export Dataset Metadata](#export-dataset-metadata) - [Get Dataset Citation Text By Private URL Token](#get-dataset-citation-text-by-private-url-token) - [Get Dataset Locks](#get-dataset-locks) - [Get Dataset Summary Field Names](#get-dataset-summary-field-names) - [Get User Permissions on a Dataset](#get-user-permissions-on-a-dataset) - [Get Differences between Two Dataset Versions](#get-differences-between-two-dataset-versions) - [List All Datasets](#list-all-datasets) + - [Get Dataset Versions](#get-dataset-versions) - [Get Dataset Versions Summaries](#get-dataset-versions-summaries) - [Get Dataset Linked Collections](#get-dataset-linked-collections) - [Get Dataset Available Categories](#get-dataset-available-categories) @@ -71,6 +82,7 @@ The different use cases currently available in the package are classified below, - [Get a File](#get-a-file) - [Get a File and its Dataset](#get-a-file-and-its-dataset) - [Get File Citation Text](#get-file-citation-text) + - [Get File Citation By Format](#get-file-citation-by-format) - [Get File Counts in a Dataset](#get-file-counts-in-a-dataset) - [Get File Data Tables](#get-file-data-tables) - [Get File Download Count](#get-file-download-count) @@ -130,8 +142,12 @@ The different use cases currently available in the package are classified below, - [Guestbooks read use cases](#guestbooks-read-use-cases) - [Get a Guestbook](#get-a-guestbook) - [Get Guestbooks By Collection Id](#get-guestbooks-by-collection-id) + - [Get Guestbook Responses By Guestbook Id](#get-guestbook-responses-by-guestbook-id) + - [Download Guestbook Responses By Collection Id](#download-guestbook-responses-by-collection-id) + - [Download Guestbook Responses Of A Guestbook](#download-guestbook-responses-of-a-guestbook) - [Guestbooks write use cases](#guestbooks-write-use-cases) - [Create a Guestbook](#create-a-guestbook) + - [Edit a Guestbook](#edit-a-guestbook) - [Set Guestbook Enabled](#set-guestbook-enabled) - [Assign Dataset Guestbook](#assign-dataset-guestbook) - [Remove Dataset Guestbook](#remove-dataset-guestbook) @@ -190,6 +206,79 @@ The `collectionIdOrAlias` is a generic collection identifier, which can be eithe If no collection identifier is specified, the default collection identifier; `:root` will be used. If you want to search for a different collection, you must add the collection identifier as a parameter in the use case call. +##### Collection Allowed Dataset Types + +Collections can optionally restrict which [DatasetType](../src/datasets/domain/models/DatasetType.ts) objects can be created within them. The `allowedDatasetTypes` field contains an array of dataset types allowed on the collection when configured. If not configured on the collection, this field will be `undefined`. + +```typescript +getCollection.execute('myCollection').then((collection: Collection) => { + if (collection.allowedDatasetTypes) { + collection.allowedDatasetTypes.forEach((datasetType) => { + console.log(`Allowed type: ${datasetType.displayName}`) + }) + } +}) +``` + +#### Get Collection Storage Driver + +Returns a [StorageDriver](../src/core/domain/models/StorageDriver.ts) instance describing the collection's assigned storage driver. + +##### Example call: + +```typescript +import { getCollectionStorageDriver } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 'classicLiterature' + +getCollectionStorageDriver.execute(collectionIdOrAlias).then((storageDriver: StorageDriver) => { + /* ... */ +}) + +// Pass true to resolve the effective driver after inheritance/default fallback +getCollectionStorageDriver + .execute(collectionIdOrAlias, true) + .then((storageDriver: StorageDriver) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/GetCollectionStorageDriver.ts) implementation_. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + +The optional `getEffective` parameter defaults to `false`. Set it to `true` to retrieve the effective storage driver after inheritance/default resolution. + +#### Get Allowed Collection Storage Drivers + +Returns an [AllowedStorageDrivers](../src/collections/domain/models/AllowedStorageDrivers.ts) object whose keys are driver labels and whose values are storage driver ids. + +##### Example call: + +```typescript +import { getAllowedCollectionStorageDrivers } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 'classicLiterature' + +getAllowedCollectionStorageDrivers + .execute(collectionIdOrAlias) + .then((storageDrivers: AllowedStorageDrivers) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/GetAllowedCollectionStorageDrivers.ts) implementation_. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + #### Get Collection Facets Returns a [CollectionFacet](../src/collections/domain/models/CollectionFacet.ts) array containing the facets of the requested collection, given the collection identifier or alias. @@ -460,6 +549,57 @@ The above example creates the new collection in the root collection since no col The use case returns a number, which is the identifier of the created collection. +#### Set Collection Storage Driver + +Assigns a storage driver to a collection by driver label and returns the backend success message. + +##### Example call: + +```typescript +import { setCollectionStorageDriver } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 'classicLiterature' +const driverLabel = 'Local Storage' + +setCollectionStorageDriver.execute(collectionIdOrAlias, driverLabel).then((message: string) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/SetCollectionStorageDriver.ts) implementation_. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + +The `driverLabel` parameter must match the storage driver's label, not its id. + +#### Delete Collection Storage Driver + +Clears the directly assigned storage driver from a collection so it falls back to inherited/default storage, and returns the backend success message. + +##### Example call: + +```typescript +import { deleteCollectionStorageDriver } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 'classicLiterature' + +deleteCollectionStorageDriver.execute(collectionIdOrAlias).then((message: string) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/DeleteCollectionStorageDriver.ts) implementation_. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + #### Update a Collection Updates an existing collection, given a collection identifier and a [CollectionDTO](../src/collections/domain/dtos/CollectionDTO.ts) including the updated collection data. @@ -594,6 +734,27 @@ deleteCollectionFeaturedItem.execute(featuredItemId) _See [use case](../src/collections/domain/useCases/DeleteCollectionFeaturedItem.ts)_ definition. +#### Set Default Contributor Role + +Sets the default contributor role of a collection, given a collection identifier and a role alias. + +##### Example call: + +```typescript +import { setDefaultContributorRole } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 12345 +const roleAlias = 'curator' + +setDefaultContributorRole.execute(collectionIdOrAlias, roleAlias) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/SetDefaultContributorRole.ts)_ definition. + ## Templates ### Templates Read Use Cases @@ -724,6 +885,84 @@ await unsetTemplateAsDefault.execute(collectionIdOrAlias) _See [use case](../src/templates/domain/useCases/UnsetTemplateAsDefault.ts)_ definition. +#### Update Template Metadata + +Updates template metadata fields and instructions for a template id. + +##### Example call: + +```typescript +import { updateTemplateMetadata } from '@iqss/dataverse-client-javascript' +import { UpdateTemplateMetadataDTO } from '@iqss/dataverse-client-javascript' + +const templateId = 12345 +const replace = true + +const payload: UpdateTemplateMetadataDTO = { + name: 'Dataverse template updated', + fields: [ + { + typeName: 'author', + typeClass: 'compound', + multiple: true, + value: [ + { + authorName: { typeName: 'authorName', value: 'Belicheck, Bill' }, + authorAffiliation: { typeName: 'authorIdentifierScheme', value: 'ORCID' } + } + ] + } + ], + instructions: [{ instructionField: 'author', instructionText: 'Updated instructions' }] +} + +await updateTemplateMetadata.execute(templateId, payload, replace) +``` + +_See [use case](../src/templates/domain/useCases/UpdateTemplateMetadata.ts) definition_. + +#### Update Template License Terms + +Updates either the license name or custom terms of use for a template id. + +##### Example call: + +```typescript +import { updateTemplateLicenseTerms } from '@iqss/dataverse-client-javascript' +import { UpdateTemplateLicenseTermsDTO } from '@iqss/dataverse-client-javascript' + +const templateId = 12345 + +const payload: UpdateTemplateLicenseTermsDTO = { + customTerms: { + termsOfUse: 'Updated template terms of use' + } +} + +await updateTemplateLicenseTerms.execute(templateId, payload) +``` + +_See [use case](../src/templates/domain/useCases/UpdateTemplateLicenseTerms.ts) definitition_. + +#### Update Template Terms Of Access + +Updates terms of access for a template id. + +##### Example call: + +```typescript +import { updateTemplateTermsOfAccess } from '@iqss/dataverse-client-javascript' + +const templateId = 12345 + +await updateTemplateTermsOfAccess.execute(templateId, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Restricted access only' +}) +``` + +_See [use case](../src/templates/domain/useCases/UpdateTemplateTermsOfAccess.ts) definition_. + ## Datasets ### Datasets Read Use Cases @@ -759,6 +998,8 @@ There is an optional third parameter called `includeDeaccessioned`, which indica There is an optional fourth parameter called `keepRawFields`, which indicates whether or not to keep the metadata fields as they are and avoid the transformation to Markdown. The default value is `false`. +When the dataset is associated with a template, the returned `Dataset` includes the optional `templateId` field containing that template's numeric identifier. + #### Get Dataset By Private URL Token Returns a [Dataset](../src/datasets/domain/models/Dataset.ts) instance, given an associated Private URL Token. @@ -839,6 +1080,37 @@ The `datasetId` parameter can be a string, for persistent identifiers, or a numb There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the dataset search. If not set, the default value is `false`. +#### Export Dataset Metadata + +Exports dataset metadata in a specified metadata export format. + +##### Example call: + +```typescript +import { + exportDatasetMetadata, + DatasetNotNumberedVersion, + ExportedDatasetMetadata +} from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +const exporter = 'ddi' + +exportDatasetMetadata + .execute(datasetId, exporter, DatasetNotNumberedVersion.DRAFT) + .then((metadata: ExportedDatasetMetadata) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/ExportDatasetMetadata.ts) implementation_. + +The `datasetId` parameter can be a string for persistent identifiers or a number for numeric identifiers. The optional `version` parameter accepts `DatasetNotNumberedVersion.LATEST_PUBLISHED` or `DatasetNotNumberedVersion.DRAFT`. If not set, Dataverse defaults to `DatasetNotNumberedVersion.LATEST_PUBLISHED`. Draft exports require configured authentication with access to the draft. + #### Get Dataset Citation Text By Private URL Token Returns the Dataset citation text, given an associated Private URL Token. @@ -885,6 +1157,30 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetLocks.ts) implementati The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. +#### Get Dataset Reviews + +Returns a [DatasetReview](../src/datasets/domain/models/DatasetReview.ts) array with the local review datasets that review a dataset. Review datasets are matched by their `itemReviewedUrl` metadata field pointing at the URL form of the dataset persistent identifier. + +##### Example call: + +```typescript +import { getDatasetReviews } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.5072/FK2/ABCDEF' + +getDatasetReviews.execute(datasetId).then((datasetReviews: DatasetReview[]) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/GetDatasetReviews.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. An API token is optional when the review dataset has been published, but unpublished targets require permission to view the unpublished dataset. + #### Get Dataset Summary Field Names Returns the names of the dataset summary fields configured in the installation. @@ -991,6 +1287,34 @@ Note that `collectionId` is an optional parameter to filter datasets by collecti The `DatasetPreviewSubset`returned instance contains a property called `totalDatasetCount` which is necessary for pagination. +#### Get Dataset Versions + +Returns an array of [Dataset](../src/datasets/domain/models/Dataset.ts) that contains information about every specific version. + +##### Example call: + +```typescript +import { getDatasetVersions } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' + +getDatasetVersions.execute(datasetId).then((datasetVersions: DatasetVersionSubset) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/GetDatasetVersions.ts) implementation_. + +- The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. +- **limit**: (number) Limit for pagination. +- **offset**: (number) Offset for pagination. +- **excludeMetadataBlocks**: (boolean) Exclude metadata blocks (default: false). +- Note that the **excludeFiles** parameter, which is available on Dataverse's "List Versions of a Dataset" API, is not available here. The list of files can be retrieved using the separate use case [List Files in a Dataset](#list-files-in-a-dataset). + #### Get Dataset Versions Summaries Returns the total count of versions and an array of [DatasetVersionSummaryInfo](../src/datasets/domain/models/DatasetVersionSummaryInfo.ts) that contains information about what changed in every specific version. @@ -1156,7 +1480,7 @@ createDataset.execute(datasetDTO).then((newDatasetIds: CreatedDatasetIdentifiers _See [use case](../src/datasets/domain/useCases/CreateDataset.ts) implementation_. -The above example creates the new dataset in the root collection since no collection identifier is specified. If you want to create the dataset in a different collection, you must add the collection identifier as a second parameter in the use case call. If you want the dataset type to be anything other than dataset, first [check available dataset types](#get-dataset-available-dataset-types) and then add the name of the dataset type as the third parameter. +The above example creates the new dataset in the root collection since no collection identifier is specified. If you want to create the dataset in a different collection, you must add the collection identifier as a second parameter in the use case call. If you want the dataset type to be anything other than dataset, first [check available dataset types](#get-dataset-available-dataset-types) and then add the name of the dataset type as the third parameter. To create from an applicable metadata template, set its numeric identifier as `datasetDTO.templateId`; the SDK sends it as the top-level `templateId` field in the request body. The use case returns a [CreatedDatasetIdentifiers](../src/datasets/domain/models/CreatedDatasetIdentifiers.ts) object, which includes the persistent and numeric identifiers of the created dataset. @@ -1491,7 +1815,7 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetTemplates.ts)_ definit #### Get Dataset Storage Driver -Returns a [StorageDriver](../src/datasets/domain/models/StorageDriver.ts) instance with storage driver configuration for a dataset, including properties like name, type, label, and upload/download capabilities. +Returns a [StorageDriver](../src/core/domain/models/StorageDriver.ts) instance with storage driver configuration for a dataset, including properties like name, type, label, and upload/download capabilities. ##### Example call: @@ -1710,6 +2034,36 @@ The `fileId` parameter can be a string, for persistent identifiers, or a number, There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the file search. If not set, the default value is `false`. +#### Get File Citation By Format + +Returns the File citation in the requested citation export format. + +##### Example call: + +```typescript +import { FileCitationFormat, getFileCitationByFormat } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId = 3 + +getFileCitationByFormat + .execute(fileId, FileCitationFormat.BIBTEX, '1.0') + .then((citationText: string) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/files/domain/useCases/GetFileCitationByFormat.ts) implementation_. + +The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +The optional third parameter, `version`, selects the **dataset version** whose file metadata is used in the citation. It accepts a numbered version such as `1.0`, or `DatasetNotNumberedVersion.DRAFT`, `LATEST`, or `LATEST_PUBLISHED` (`:draft`, `:latest`, `:latest-published`). When omitted, no version query parameter is sent, it will return the `:latest`. + +The `format` parameter must be one of the available [FileCitationFormat](../src/files/domain/models/FileCitationFormat.ts) enum values: `FileCitationFormat.ENDNOTE`, `FileCitationFormat.RIS`, `FileCitationFormat.BIBTEX`, `FileCitationFormat.CSL`, or `FileCitationFormat.INTERNAL`. + #### Get File Counts in a Dataset Returns an instance of [FileCounts](../src/files/domain/models/FileCounts.ts), containing the requested Dataset total file count, as well as file counts for the following file properties: @@ -2932,6 +3286,8 @@ _See [use case](../src/guestbooks/domain/useCases/GetGuestbook.ts) implementatio #### Get Guestbooks By Collection Id Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries available for a collection. +Set `includeStats` to `true` to include `usageCount` and `responseCount` for each guestbook. +Set `includeInherited` to `true` to include the collection's guestbooks and guestbooks from the collection's hierarchical owners. ##### Example call: @@ -2939,14 +3295,81 @@ Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries av import { getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' const collectionIdOrAlias = 'root' +const includeStats = true +const includeInherited = true -getGuestbooksByCollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => { - /* ... */ -}) +getGuestbooksByCollectionId + .execute(collectionIdOrAlias, includeStats, includeInherited) + .then((guestbooks: Guestbook[]) => { + /* ... */ + }) ``` _See [use case](../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts) implementation_. +#### Get Guestbook Responses By Guestbook Id + +Returns a [GuestbookResponseSubset](../src/guestbooks/domain/models/GuestbookResponse.ts) containing paginated guestbook response entries and the total response count for a guestbook. + +##### Example call: + +```typescript +import { getGuestbookResponsesByGuestbookId } from '@iqss/dataverse-client-javascript' + +const guestbookId = 123 +const limit = 10 +const offset = 0 + +getGuestbookResponsesByGuestbookId + .execute(guestbookId, limit, offset) + .then((guestbookResponseSubset: GuestbookResponseSubset) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts) implementation_. + +#### Download Guestbook Responses By Collection Id + +Downloads all guestbook responses for a collection and returns the raw response body, typically CSV content. + +##### Example call: + +```typescript +import { downloadGuestbookResponsesByCollectionId } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' + +downloadGuestbookResponsesByCollectionId + .execute(collectionIdOrAlias) + .then((csvResponse: string) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts) implementation_. + +#### Download Guestbook Responses Of A Guestbook + +Downloads guestbook responses for one guestbook in a collection and returns the raw response body, typically CSV content. + +##### Example call: + +```typescript +import { downloadGuestbookResponsesOfAGuestbook } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' +const guestbookId = 123 + +downloadGuestbookResponsesOfAGuestbook + .execute(collectionIdOrAlias, guestbookId) + .then((csvResponse: string) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts) implementation_. + ### Guestbooks Write Use Cases #### Create a Guestbook @@ -2984,6 +3407,34 @@ createGuestbook.execute(guestbook, collectionIdOrAlias).then(() => { _See [use case](../src/guestbooks/domain/useCases/CreateGuestbook.ts) implementation_. +#### Edit a Guestbook + +Edits an existing guestbook using [EditGuestbookDTO](../src/guestbooks/domain/dtos/EditGuestbookDTO.ts). + +##### Example call: + +```typescript +import { editGuestbook } from '@iqss/dataverse-client-javascript' + +const guestbookId = 123 +const guestbook: EditGuestbookDTO = { + name: 'new name', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + createTime: '2026-06-12T00:00:00Z', + customQuestions: [] +} + +editGuestbook.execute(guestbookId, guestbook).then(() => { + /* ... */ +}) +``` + +_See [use case](../src/guestbooks/domain/useCases/EditGuestbook.ts) implementation_. + #### Set Guestbook Enabled Enables or disables a guestbook in a collection. diff --git a/package-lock.json b/package-lock.json index 91410ed6..74986f4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "@iqss/dataverse-client-javascript", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@iqss/dataverse-client-javascript", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "dependencies": { - "@iqss/dataverse-client-javascript": "^2.1.0", "@types/node": "^18.15.11", "@types/turndown": "^5.0.1", "axios": "^1.12.2", @@ -18,6 +17,7 @@ }, "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsdom": "^20.0.1", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", @@ -31,6 +31,7 @@ "husky": "9.1.7", "jest": "^29.4.3", "jest-environment-jsdom": "29.7.0", + "jsdom": "^20.0.3", "prettier": "2.8.4", "testcontainers": "^10.11.0", "ts-jest": "^29.0.5", @@ -716,19 +717,6 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, - "node_modules/@iqss/dataverse-client-javascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@iqss/dataverse-client-javascript/-/dataverse-client-javascript-2.1.0.tgz", - "integrity": "sha512-5UVqAtRb7KZQi0c9W64f2G3vKFJrMLAIOuRwHR6hB0Z0ZVjEgCBjgP37N52T6sPh6bFbGv67Egy0+ZtmvgGMDg==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.15.11", - "@types/turndown": "^5.0.1", - "axios": "^1.12.2", - "turndown": "^7.1.2", - "typescript": "^4.9.5" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1548,6 +1536,7 @@ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -2596,13 +2585,13 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4472,15 +4461,16 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -4528,16 +4518,16 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4897,9 +4887,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6123,6 +6113,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, + "license": "MIT", "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -8537,10 +8528,11 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 06a0f896..61b28f3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iqss/dataverse-client-javascript", - "version": "2.2.0", + "version": "2.3.0", "description": "Dataverse API wrapper package for JavaScript/TypeScript-based applications", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -10,11 +10,16 @@ "scripts": { "build": "tsc", "test": "jest -c jest.config.ts", + "test:no-teardown": "TESTCONTAINERS_RYUK_DISABLED=true jest -c jest.config.ts", "test:unit": "jest -c jest.config.unit.ts", "test:integration": "jest -c jest.config.integration.ts", + "test:integration:no-teardown": "TESTCONTAINERS_RYUK_DISABLED=true jest -c jest.config.integration.ts", "test:functional": "jest -c jest.config.functional.ts", + "test:functional:no-teardown": "TESTCONTAINERS_RYUK_DISABLED=true jest -c jest.config.functional.ts", "test:coverage": "jest --coverage -c jest.config.ts", + "test:coverage:no-teardown": "TESTCONTAINERS_RYUK_DISABLED=true jest --coverage -c jest.config.ts", "test:coverage:check": "jest --coverage --ci --config jest.config.ts", + "test:coverage:check:no-teardown": "TESTCONTAINERS_RYUK_DISABLED=true jest --coverage --ci --config jest.config.ts", "lint": "npm run lint:eslint && npm run lint:prettier", "lint:fix": "eslint --fix --ext .ts ./src --ignore-path .gitignore .", "lint:eslint": "eslint --ignore-path .gitignore .", @@ -38,6 +43,7 @@ "homepage": "https://github.com/IQSS/dataverse-client-javascript#readme", "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsdom": "^20.0.1", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", @@ -51,6 +57,7 @@ "husky": "9.1.7", "jest": "^29.4.3", "jest-environment-jsdom": "29.7.0", + "jsdom": "^20.0.3", "prettier": "2.8.4", "testcontainers": "^10.11.0", "ts-jest": "^29.0.5", diff --git a/src/access/infra/repositories/AccessRepository.ts b/src/access/infra/repositories/AccessRepository.ts index a5f52a2a..db2bb4a2 100644 --- a/src/access/infra/repositories/AccessRepository.ts +++ b/src/access/infra/repositories/AccessRepository.ts @@ -142,7 +142,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository return fetchHeaders } - private async parseResponseBody(response: Response): Promise<any> { + private async parseResponseBody(response: Response): Promise<FetchResponseBody | string> { const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { @@ -158,7 +158,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository } } - private buildFetchErrorMessage(status: number, responseData: any): string { + private buildFetchErrorMessage(status: number, responseData: FetchResponseBody | string): string { const message = typeof responseData === 'string' ? responseData @@ -167,8 +167,8 @@ export class AccessRepository extends ApiRepository implements IAccessRepository return `[${status}] ${message}` } - private getSignedUrlOrThrow(responseData: any): string { - const signedUrl = responseData?.data?.signedUrl + private getSignedUrlOrThrow(responseData: FetchResponseBody | string): string { + const signedUrl = typeof responseData === 'string' ? undefined : responseData?.data?.signedUrl if (typeof signedUrl !== 'string' || signedUrl.length === 0) { throw new WriteError('Missing signedUrl in access download response.') @@ -177,3 +177,11 @@ export class AccessRepository extends ApiRepository implements IAccessRepository return signedUrl } } + +interface FetchResponseBody { + message?: string + data?: { + message?: string + signedUrl?: unknown + } +} diff --git a/src/collections/domain/models/AllowedStorageDrivers.ts b/src/collections/domain/models/AllowedStorageDrivers.ts new file mode 100644 index 00000000..8d0dc7ea --- /dev/null +++ b/src/collections/domain/models/AllowedStorageDrivers.ts @@ -0,0 +1 @@ +export type AllowedStorageDrivers = Record<string, string> diff --git a/src/collections/domain/models/Collection.ts b/src/collections/domain/models/Collection.ts index cbcf01ca..28b99403 100644 --- a/src/collections/domain/models/Collection.ts +++ b/src/collections/domain/models/Collection.ts @@ -1,6 +1,7 @@ import { DvObjectOwnerNode } from '../../../core' import { CollectionContact } from './CollectionContact' import { CollectionType } from './CollectionType' +import { DatasetType } from '../../../datasets' export interface Collection { id: number @@ -13,9 +14,22 @@ export interface Collection { inputLevels?: CollectionInputLevel[] type: CollectionType contacts?: CollectionContact[] + allowedDatasetTypes?: DatasetType[] isMetadataBlockRoot: boolean isFacetRoot: boolean childCount: number + theme?: CollectionTheme +} + +export interface CollectionTheme { + id: number + logo?: string + tagline?: string + linkUrl?: string + linkColor?: string + textColor?: string + backgroundColor?: string + logoBackgroundColor?: string } export interface CollectionInputLevel { diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index bc8960c8..cf1fad5b 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -11,10 +11,25 @@ import { PublicationStatus } from '../../../core/domain/models/PublicationStatus import { CollectionItemType } from '../../../collections/domain/models/CollectionItemType' import { CollectionLinks } from '../models/CollectionLinks' import { CollectionSummary } from '../models/CollectionSummary' +import { AllowedStorageDrivers } from '../models/AllowedStorageDrivers' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { LinkingObjectType } from '../useCases/GetCollectionsForLinking' +import { RoleAlias } from '../../../roles/domain/models/RoleAlias' export interface ICollectionsRepository { getCollection(collectionIdOrAlias: number | string): Promise<Collection> + getCollectionStorageDriver( + collectionIdOrAlias: number | string, + getEffective?: boolean + ): Promise<StorageDriver> + setCollectionStorageDriver( + collectionIdOrAlias: number | string, + driverLabel: string + ): Promise<string> + deleteCollectionStorageDriver(collectionIdOrAlias: number | string): Promise<string> + getAllowedCollectionStorageDrivers( + collectionIdOrAlias: number | string + ): Promise<AllowedStorageDrivers> createCollection( collectionDTO: CollectionDTO, parentCollectionId: number | string @@ -25,6 +40,10 @@ export interface ICollectionsRepository { getCollectionUserPermissions( collectionIdOrAlias: number | string ): Promise<CollectionUserPermissions> + setDefaultContributorRole( + collectionIdOrAlias: number | string, + roleAlias: RoleAlias | string + ): Promise<void> getCollectionItems( collectionId?: string, limit?: number, @@ -44,7 +63,7 @@ export interface ICollectionsRepository { ): Promise<MyDataCollectionItemSubset> updateCollection( collectionIdOrAlias: number | string, - updatedCollection: CollectionDTO + updatedCollection: Partial<CollectionDTO> ): Promise<void> getCollectionFeaturedItems(collectionIdOrAlias: number | string): Promise<FeaturedItem[]> updateCollectionFeaturedItems( diff --git a/src/collections/domain/useCases/DeleteCollectionStorageDriver.ts b/src/collections/domain/useCases/DeleteCollectionStorageDriver.ts new file mode 100644 index 00000000..2f3d510c --- /dev/null +++ b/src/collections/domain/useCases/DeleteCollectionStorageDriver.ts @@ -0,0 +1,14 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class DeleteCollectionStorageDriver implements UseCase<string> { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + async execute(collectionIdOrAlias: number | string): Promise<string> { + return this.collectionsRepository.deleteCollectionStorageDriver(collectionIdOrAlias) + } +} diff --git a/src/collections/domain/useCases/GetAllowedCollectionStorageDrivers.ts b/src/collections/domain/useCases/GetAllowedCollectionStorageDrivers.ts new file mode 100644 index 00000000..75af0568 --- /dev/null +++ b/src/collections/domain/useCases/GetAllowedCollectionStorageDrivers.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' +import { AllowedStorageDrivers } from '../models/AllowedStorageDrivers' + +export class GetAllowedCollectionStorageDrivers implements UseCase<AllowedStorageDrivers> { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + async execute(collectionIdOrAlias: number | string): Promise<AllowedStorageDrivers> { + return this.collectionsRepository.getAllowedCollectionStorageDrivers(collectionIdOrAlias) + } +} diff --git a/src/collections/domain/useCases/GetCollectionStorageDriver.ts b/src/collections/domain/useCases/GetCollectionStorageDriver.ts new file mode 100644 index 00000000..80fc5240 --- /dev/null +++ b/src/collections/domain/useCases/GetCollectionStorageDriver.ts @@ -0,0 +1,18 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class GetCollectionStorageDriver implements UseCase<StorageDriver> { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + async execute( + collectionIdOrAlias: number | string, + getEffective = false + ): Promise<StorageDriver> { + return this.collectionsRepository.getCollectionStorageDriver(collectionIdOrAlias, getEffective) + } +} diff --git a/src/collections/domain/useCases/SetCollectionStorageDriver.ts b/src/collections/domain/useCases/SetCollectionStorageDriver.ts new file mode 100644 index 00000000..ee915da3 --- /dev/null +++ b/src/collections/domain/useCases/SetCollectionStorageDriver.ts @@ -0,0 +1,14 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class SetCollectionStorageDriver implements UseCase<string> { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + async execute(collectionIdOrAlias: number | string, driverLabel: string): Promise<string> { + return this.collectionsRepository.setCollectionStorageDriver(collectionIdOrAlias, driverLabel) + } +} diff --git a/src/collections/domain/useCases/SetDefaultContributorRole.ts b/src/collections/domain/useCases/SetDefaultContributorRole.ts new file mode 100644 index 00000000..e2aa9beb --- /dev/null +++ b/src/collections/domain/useCases/SetDefaultContributorRole.ts @@ -0,0 +1,30 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' +import { ROOT_COLLECTION_ID } from '../models/Collection' +import { RoleAlias } from '../../../roles/domain/models/RoleAlias' + +export class SetDefaultContributorRole implements UseCase<void> { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Sets the default Role that is assigned to contributors in the given collection. + * + * @param {number | string} [collectionIdOrAlias = ':root'] - A generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId) + * If this parameter is not set, the default value is: ':root' + * @param {RoleAlias | string} [roleAlias] - The alias of the role to be assigned + * @returns {Promise<void>} + */ + async execute( + collectionIdOrAlias: number | string = ROOT_COLLECTION_ID, + roleAlias: RoleAlias | string + ): Promise<void> { + return await this.collectionsRepository.setDefaultContributorRole( + collectionIdOrAlias, + roleAlias + ) + } +} diff --git a/src/collections/domain/useCases/UpdateCollection.ts b/src/collections/domain/useCases/UpdateCollection.ts index f1068086..b32a6e05 100644 --- a/src/collections/domain/useCases/UpdateCollection.ts +++ b/src/collections/domain/useCases/UpdateCollection.ts @@ -19,7 +19,7 @@ export class UpdateCollection implements UseCase<void> { */ async execute( collectionIdOrAlias: number | string, - updatedCollection: CollectionDTO + updatedCollection: Partial<CollectionDTO> ): Promise<void> { return await this.collectionsRepository.updateCollection(collectionIdOrAlias, updatedCollection) } diff --git a/src/collections/index.ts b/src/collections/index.ts index 59e2e50b..efee7d13 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -3,6 +3,7 @@ import { GetCollection } from './domain/useCases/GetCollection' import { GetCollectionFacets } from './domain/useCases/GetCollectionFacets' import { GetCollectionUserPermissions } from './domain/useCases/GetCollectionUserPermissions' import { GetCollectionItems } from './domain/useCases/GetCollectionItems' +import { GetCollectionStorageDriver } from './domain/useCases/GetCollectionStorageDriver' import { PublishCollection } from './domain/useCases/PublishCollection' import { UpdateCollection } from './domain/useCases/UpdateCollection' import { GetCollectionFeaturedItems } from './domain/useCases/GetCollectionFeaturedItems' @@ -16,10 +17,15 @@ import { LinkCollection } from './domain/useCases/LinkCollection' import { UnlinkCollection } from './domain/useCases/UnlinkCollection' import { GetCollectionLinks } from './domain/useCases/GetCollectionLinks' import { GetCollectionsForLinking } from './domain/useCases/GetCollectionsForLinking' +import { SetCollectionStorageDriver } from './domain/useCases/SetCollectionStorageDriver' +import { DeleteCollectionStorageDriver } from './domain/useCases/DeleteCollectionStorageDriver' +import { GetAllowedCollectionStorageDrivers } from './domain/useCases/GetAllowedCollectionStorageDrivers' +import { SetDefaultContributorRole } from './domain/useCases/SetDefaultContributorRole' const collectionsRepository = new CollectionsRepository() const getCollection = new GetCollection(collectionsRepository) +const getCollectionStorageDriver = new GetCollectionStorageDriver(collectionsRepository) const createCollection = new CreateCollection(collectionsRepository) const getCollectionFacets = new GetCollectionFacets(collectionsRepository) const getCollectionUserPermissions = new GetCollectionUserPermissions(collectionsRepository) @@ -36,9 +42,16 @@ const linkCollection = new LinkCollection(collectionsRepository) const unlinkCollection = new UnlinkCollection(collectionsRepository) const getCollectionLinks = new GetCollectionLinks(collectionsRepository) const getCollectionsForLinking = new GetCollectionsForLinking(collectionsRepository) +const setCollectionStorageDriver = new SetCollectionStorageDriver(collectionsRepository) +const deleteCollectionStorageDriver = new DeleteCollectionStorageDriver(collectionsRepository) +const getAllowedCollectionStorageDrivers = new GetAllowedCollectionStorageDrivers( + collectionsRepository +) +const setDefaultContributorRole = new SetDefaultContributorRole(collectionsRepository) export { getCollection, + getCollectionStorageDriver, createCollection, getCollectionFacets, getCollectionUserPermissions, @@ -54,9 +67,13 @@ export { linkCollection, unlinkCollection, getCollectionLinks, - getCollectionsForLinking + getCollectionsForLinking, + setCollectionStorageDriver, + deleteCollectionStorageDriver, + getAllowedCollectionStorageDrivers, + setDefaultContributorRole } -export { Collection, CollectionInputLevel } from './domain/models/Collection' +export { Collection, CollectionInputLevel, CollectionTheme } from './domain/models/Collection' export { CollectionFacet } from './domain/models/CollectionFacet' export { CollectionUserPermissions } from './domain/models/CollectionUserPermissions' export { CollectionDTO, CollectionInputLevelDTO } from './domain/dtos/CollectionDTO' @@ -66,3 +83,4 @@ export { CollectionSearchCriteria } from './domain/models/CollectionSearchCriter export { FeaturedItem } from './domain/models/FeaturedItem' export { FeaturedItemsDTO } from './domain/dtos/FeaturedItemsDTO' export { CollectionSummary } from './domain/models/CollectionSummary' +export { AllowedStorageDrivers } from './domain/models/AllowedStorageDrivers' diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index e0e459b0..99dddaf3 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -39,7 +39,10 @@ import { PublicationStatus } from '../../../core/domain/models/PublicationStatus import { ReadError } from '../../../core/domain/repositories/ReadError' import { CollectionLinks } from '../../domain/models/CollectionLinks' import { CollectionSummary } from '../../domain/models/CollectionSummary' +import { AllowedStorageDrivers } from '../../domain/models/AllowedStorageDrivers' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { LinkingObjectType } from '../../domain/useCases/GetCollectionsForLinking' +import { RoleAlias } from '../../../roles/domain/models/RoleAlias' export interface NewCollectionRequestPayload { alias: string @@ -108,6 +111,62 @@ export class CollectionsRepository extends ApiRepository implements ICollections }) } + public async getCollectionStorageDriver( + collectionIdOrAlias: number | string, + getEffective = true + ): Promise<StorageDriver> { + return this.doGet( + `/${this.collectionsResourceName}/${collectionIdOrAlias}/storageDriver`, + true, + { + getEffective + } + ) + .then((response) => response.data.data as StorageDriver) + .catch((error) => { + throw error + }) + } + + public async setCollectionStorageDriver( + collectionIdOrAlias: number | string, + driverLabel: string + ): Promise<string> { + return this.doPut( + `/${this.collectionsResourceName}/${collectionIdOrAlias}/storageDriver`, + driverLabel, + undefined, + ApiConstants.CONTENT_TYPE_TEXT_PLAIN + ) + .then((response) => response.data.data.message) + .catch((error) => { + throw error + }) + } + + public async deleteCollectionStorageDriver( + collectionIdOrAlias: number | string + ): Promise<string> { + return this.doDelete(`/${this.collectionsResourceName}/${collectionIdOrAlias}/storageDriver`) + .then((response) => response.data.data.message) + .catch((error) => { + throw error + }) + } + + public async getAllowedCollectionStorageDrivers( + collectionIdOrAlias: number | string + ): Promise<AllowedStorageDrivers> { + return this.doGet( + `/${this.collectionsResourceName}/${collectionIdOrAlias}/allowedStorageDrivers`, + true + ) + .then((response) => response.data.data as AllowedStorageDrivers) + .catch((error) => { + throw error + }) + } + public async createCollection( collectionDTO: CollectionDTO, parentCollectionId: number | string = ROOT_COLLECTION_ID @@ -167,6 +226,20 @@ export class CollectionsRepository extends ApiRepository implements ICollections }) } + public async setDefaultContributorRole( + collectionIdOrAlias: number | string, + roleAlias: RoleAlias | string + ): Promise<void> { + return this.doPut( + `/${this.collectionsResourceName}/${collectionIdOrAlias}/defaultContributorRole/${roleAlias}`, + {} + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + public async getCollectionItems( collectionId?: string, limit?: number, @@ -215,9 +288,9 @@ export class CollectionsRepository extends ApiRepository implements ICollections public async updateCollection( collectionIdOrAlias: string | number, - updatedCollection: CollectionDTO + updatedCollection: Partial<CollectionDTO> ): Promise<void> { - const requestBody = this.createCreateOrUpdateRequestBody(updatedCollection) + const requestBody = this.createUpdateRequestBody(updatedCollection) return this.doPut(`/${this.collectionsResourceName}/${collectionIdOrAlias}`, requestBody) .then(() => undefined) @@ -332,6 +405,86 @@ export class CollectionsRepository extends ApiRepository implements ICollections } } + private createUpdateRequestBody( + collectionDTO: Partial<CollectionDTO> + ): Partial<NewCollectionRequestPayload> { + const dataverseContacts: NewCollectionContactRequestPayload[] | undefined = + collectionDTO.contacts?.map((contact) => ({ + contactEmail: contact + })) + const inputLevelsRequestBody: NewCollectionInputLevelRequestPayload[] | undefined = + collectionDTO.inputLevels?.map((inputLevel) => ({ + datasetFieldTypeName: inputLevel.datasetFieldName, + include: inputLevel.include, + required: inputLevel.required + })) + let metadataBlocksRequestBody: Partial<NewCollectionMetadataBlocksRequestPayload> | undefined + + const hasMetadataBlocksData = + collectionDTO.metadataBlockNames !== undefined || + collectionDTO.facetIds !== undefined || + collectionDTO.inputLevels !== undefined || + collectionDTO.inheritMetadataBlocksFromParent !== undefined || + collectionDTO.inheritFacetsFromParent !== undefined + + if (hasMetadataBlocksData) { + metadataBlocksRequestBody = {} + if (collectionDTO.inheritMetadataBlocksFromParent !== true) { + if (collectionDTO.metadataBlockNames !== undefined) { + metadataBlocksRequestBody.metadataBlockNames = collectionDTO.metadataBlockNames + } + if (inputLevelsRequestBody !== undefined) { + metadataBlocksRequestBody.inputLevels = inputLevelsRequestBody + } + } + if (collectionDTO.inheritFacetsFromParent !== true) { + if (collectionDTO.facetIds !== undefined) { + metadataBlocksRequestBody.facetIds = collectionDTO.facetIds + } + } + if (collectionDTO.inheritMetadataBlocksFromParent !== undefined) { + metadataBlocksRequestBody.inheritMetadataBlocksFromParent = + collectionDTO.inheritMetadataBlocksFromParent + } + if (collectionDTO.inheritFacetsFromParent !== undefined) { + metadataBlocksRequestBody.inheritFacetsFromParent = collectionDTO.inheritFacetsFromParent + } + } + + // Build the final request body, only including defined fields + const requestBody: Partial<NewCollectionRequestPayload> = {} + + if (collectionDTO.alias !== undefined) { + requestBody.alias = collectionDTO.alias + } + + if (collectionDTO.name !== undefined) { + requestBody.name = collectionDTO.name + } + + if (dataverseContacts !== undefined) { + requestBody.dataverseContacts = dataverseContacts + } + + if (collectionDTO.type !== undefined) { + requestBody.dataverseType = collectionDTO.type + } + + if (collectionDTO.description !== undefined) { + requestBody.description = collectionDTO.description + } + + if (collectionDTO.affiliation !== undefined) { + requestBody.affiliation = collectionDTO.affiliation + } + + if (metadataBlocksRequestBody !== undefined) { + requestBody.metadataBlocks = metadataBlocksRequestBody + } + + return requestBody + } + private applyCollectionSearchCriteriaToQueryParams( queryParams: URLSearchParams, collectionSearchCriteria: CollectionSearchCriteria diff --git a/src/collections/infra/repositories/transformers/CollectionPayload.ts b/src/collections/infra/repositories/transformers/CollectionPayload.ts index f478ca13..a52e8021 100644 --- a/src/collections/infra/repositories/transformers/CollectionPayload.ts +++ b/src/collections/infra/repositories/transformers/CollectionPayload.ts @@ -10,10 +10,23 @@ export interface CollectionPayload { isPartOf: OwnerNodePayload inputLevels?: CollectionInputLevelPayload[] dataverseContacts?: CollectionContactPayload[] + allowedDatasetTypes?: AllowedDatasetTypePayload[] dataverseType: string isMetadataBlockRoot: boolean isFacetRoot: boolean childCount: number + theme?: CollectionThemePayload +} + +export interface CollectionThemePayload { + id: number + logo?: string + tagline?: string + linkUrl?: string + linkColor?: string + textColor?: string + backgroundColor?: string + logoBackgroundColor?: string } export interface CollectionInputLevelPayload { @@ -26,3 +39,10 @@ export interface CollectionContactPayload { contactEmail: string displayOrder: number } + +export interface AllowedDatasetTypePayload { + id: number + name: string + displayName: string + description?: string +} diff --git a/src/collections/infra/repositories/transformers/collectionTransformers.ts b/src/collections/infra/repositories/transformers/collectionTransformers.ts index fa23b8ed..4aa71d0a 100644 --- a/src/collections/infra/repositories/transformers/collectionTransformers.ts +++ b/src/collections/infra/repositories/transformers/collectionTransformers.ts @@ -3,7 +3,8 @@ import { AxiosResponse } from 'axios' import { CollectionContactPayload, CollectionInputLevelPayload, - CollectionPayload + CollectionPayload, + AllowedDatasetTypePayload } from './CollectionPayload' import { transformPayloadToOwnerNode } from '../../../../core/infra/repositories/transformers/dvObjectOwnerNodeTransformer' import { CollectionFacet } from '../../../domain/models/CollectionFacet' @@ -13,7 +14,7 @@ import { CollectionItemSubset, CountPerObjectType } from '../../../domain/models/CollectionItemSubset' -import { DatasetPreview } from '../../../../datasets' +import { DatasetPreview, DatasetType } from '../../../../datasets' import { FilePreview } from '../../../../files' import { DatasetPreviewPayload } from '../../../../datasets/infra/repositories/transformers/DatasetPreviewPayload' import { FilePreviewPayload } from '../../../../files/infra/repositories/transformers/FilePreviewPayload' @@ -74,6 +75,9 @@ const transformPayloadToCollection = (collectionPayload: CollectionPayload): Col isFacetRoot: collectionPayload.isFacetRoot, description: collectionPayload.description, childCount: collectionPayload.childCount, + ...(collectionPayload.theme && { + theme: collectionPayload.theme + }), ...(collectionPayload.isPartOf && { isPartOf: transformPayloadToOwnerNode(collectionPayload.isPartOf) }), @@ -82,6 +86,11 @@ const transformPayloadToCollection = (collectionPayload: CollectionPayload): Col }), ...(collectionPayload.dataverseContacts && { contacts: transformContactsPayloadToContacts(collectionPayload.dataverseContacts) + }), + ...(collectionPayload.allowedDatasetTypes && { + allowedDatasetTypes: transformAllowedDatasetTypesPayloadToAllowedDatasetTypes( + collectionPayload.allowedDatasetTypes + ) }) } return collectionModel @@ -252,3 +261,14 @@ const transformContactsPayloadToContacts = ( displayOrder: contactPayload.displayOrder })) } + +const transformAllowedDatasetTypesPayloadToAllowedDatasetTypes = ( + allowedDatasetTypesPayload: AllowedDatasetTypePayload[] +): DatasetType[] => { + return allowedDatasetTypesPayload.map((allowedDatasetType) => ({ + id: allowedDatasetType.id, + name: allowedDatasetType.name, + displayName: allowedDatasetType.displayName, + description: allowedDatasetType.description + })) +} diff --git a/src/datasets/domain/models/StorageDriver.ts b/src/core/domain/models/StorageDriver.ts similarity index 79% rename from src/datasets/domain/models/StorageDriver.ts rename to src/core/domain/models/StorageDriver.ts index 9c04b400..308ba635 100644 --- a/src/datasets/domain/models/StorageDriver.ts +++ b/src/core/domain/models/StorageDriver.ts @@ -1,7 +1,7 @@ export interface StorageDriver { name: string - type: string - label: string + type?: string + label?: string directUpload: boolean directDownload: boolean uploadOutOfBand: boolean diff --git a/src/core/index.ts b/src/core/index.ts index e7cb65f8..ddd54a8a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,3 +3,4 @@ export { WriteError } from './domain/repositories/WriteError' export { ApiConfig } from './infra/repositories/ApiConfig' export { DvObjectOwnerNode, DvObjectType } from './domain/models/DvObjectOwnerNode' export { PublicationStatus } from './domain/models/PublicationStatus' +export { StorageDriver } from './domain/models/StorageDriver' diff --git a/src/core/infra/repositories/ApiConfig.ts b/src/core/infra/repositories/ApiConfig.ts index dd0144ca..24a24d6e 100644 --- a/src/core/infra/repositories/ApiConfig.ts +++ b/src/core/infra/repositories/ApiConfig.ts @@ -3,17 +3,20 @@ export class ApiConfig { static dataverseApiAuthMechanism: DataverseApiAuthMechanism static dataverseApiKey?: string static bearerTokenLocalStorageKey?: string + static bearerTokenGetFunction?: () => string | null static init( dataverseApiUrl: string, dataverseApiAuthMechanism: DataverseApiAuthMechanism, dataverseApiKey?: string, - bearerTokenLocalStorageKey?: string + bearerTokenLocalStorageKey?: string, + bearerTokenGetFunction?: () => string | null ) { this.dataverseApiUrl = dataverseApiUrl this.dataverseApiAuthMechanism = dataverseApiAuthMechanism this.dataverseApiKey = dataverseApiKey this.bearerTokenLocalStorageKey = bearerTokenLocalStorageKey + this.bearerTokenGetFunction = bearerTokenGetFunction } } diff --git a/src/core/infra/repositories/ApiConstants.ts b/src/core/infra/repositories/ApiConstants.ts index 966841d4..e6509613 100644 --- a/src/core/infra/repositories/ApiConstants.ts +++ b/src/core/infra/repositories/ApiConstants.ts @@ -1,4 +1,5 @@ export class ApiConstants { static readonly CONTENT_TYPE_APPLICATION_JSON = 'application/json' static readonly CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data' + static readonly CONTENT_TYPE_TEXT_PLAIN = 'text/plain' } diff --git a/src/core/infra/repositories/apiConfigBuilders.ts b/src/core/infra/repositories/apiConfigBuilders.ts index c245c58e..5030b0b1 100644 --- a/src/core/infra/repositories/apiConfigBuilders.ts +++ b/src/core/infra/repositories/apiConfigBuilders.ts @@ -41,13 +41,19 @@ export const buildRequestConfig = ( break case DataverseApiAuthMechanism.BEARER_TOKEN: { - if (!ApiConfig.bearerTokenLocalStorageKey) { + if (!(ApiConfig.bearerTokenLocalStorageKey || ApiConfig.bearerTokenGetFunction)) { throw new Error( - 'Bearer token local storage key is not set in the ApiConfig, when using bearer token auth mechanism you must set the bearerTokenLocalStorageKey' + 'Bearer token local storage key or get function is not set in the ApiConfig, when using bearer token auth mechanism you must set the bearerTokenLocalStorageKey or bearerTokenGetFunction' ) } - const token = getLocalStorageItem<string>(ApiConfig.bearerTokenLocalStorageKey) + let token + + if (ApiConfig.bearerTokenLocalStorageKey) { + token = getLocalStorageItem<string>(ApiConfig.bearerTokenLocalStorageKey) + } else if (ApiConfig.bearerTokenGetFunction) { + token = ApiConfig.bearerTokenGetFunction() + } if (token) { requestConfig.headers.Authorization = `Bearer ${token}` diff --git a/src/datasets/domain/dtos/DatasetDTO.ts b/src/datasets/domain/dtos/DatasetDTO.ts index 23d668c9..bb77a6d1 100644 --- a/src/datasets/domain/dtos/DatasetDTO.ts +++ b/src/datasets/domain/dtos/DatasetDTO.ts @@ -2,6 +2,7 @@ import { DatasetLicense } from '../models/Dataset' export interface DatasetDTO { license?: DatasetLicense + templateId?: number metadataBlockValues: DatasetMetadataBlockValuesDTO[] } diff --git a/src/datasets/domain/models/Dataset.ts b/src/datasets/domain/models/Dataset.ts index ebd302f1..22527aaa 100644 --- a/src/datasets/domain/models/Dataset.ts +++ b/src/datasets/domain/models/Dataset.ts @@ -13,6 +13,7 @@ export interface Dataset { publicationDate?: string citationDate?: string guestbookId?: number + templateId?: number metadataBlocks: DatasetMetadataBlocks isPartOf: DvObjectOwnerNode datasetType?: string diff --git a/src/datasets/domain/models/DatasetReview.ts b/src/datasets/domain/models/DatasetReview.ts new file mode 100644 index 00000000..2ffbf7e5 --- /dev/null +++ b/src/datasets/domain/models/DatasetReview.ts @@ -0,0 +1,23 @@ +export interface DatasetReview { + title: string + authors: string[] + persistentId: string + persistentIdUrl: string + id: number + citation: string + citationHtml: string + datePublished: string + description: string + rubricMetadataBlocks: DatasetReviewRubricMetadataBlock[] +} + +export interface DatasetReviewRubricMetadataBlock { + name: string + displayName: string + fields: DatasetReviewRubricMetadataField[] +} + +export interface DatasetReviewRubricMetadataField { + typeName: string + value: string +} diff --git a/src/datasets/domain/models/DatasetVersionSubset.ts b/src/datasets/domain/models/DatasetVersionSubset.ts new file mode 100644 index 00000000..07801577 --- /dev/null +++ b/src/datasets/domain/models/DatasetVersionSubset.ts @@ -0,0 +1,5 @@ +import { Dataset, DatasetMetadataBlocks } from './Dataset' + +export interface DatasetVersionSubset { + versions: Array<Omit<Dataset, 'metadataBlocks'> & { metadataBlocks?: DatasetMetadataBlocks }> +} diff --git a/src/datasets/domain/models/ExportedDatasetMetadata.ts b/src/datasets/domain/models/ExportedDatasetMetadata.ts new file mode 100644 index 00000000..e6d259e7 --- /dev/null +++ b/src/datasets/domain/models/ExportedDatasetMetadata.ts @@ -0,0 +1,4 @@ +export type ExportedDatasetMetadata = { + content: string + contentType: string +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 02c0d2c3..0a7119af 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -9,6 +9,7 @@ import { MetadataBlock } from '../../../metadataBlocks' import { DatasetVersionDiff } from '../models/DatasetVersionDiff' import { DatasetDownloadCount } from '../models/DatasetDownloadCount' import { DatasetVersionSummarySubset } from '../models/DatasetVersionSummaryInfo' +import { DatasetVersionSubset } from '../models/DatasetVersionSubset' import { DatasetLinkedCollection } from '../models/DatasetLinkedCollection' import { CitationFormat } from '../models/CitationFormat' import { FormattedCitation } from '../models/FormattedCitation' @@ -16,8 +17,11 @@ import { DatasetType } from '../models/DatasetType' import { TermsOfAccess } from '../models/Dataset' import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest' import { DatasetTypeDTO } from '../dtos/DatasetTypeDTO' -import { StorageDriver } from '../models/StorageDriver' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../models/DatasetUploadLimits' +import { DatasetReview } from '../models/DatasetReview' +import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' +import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' export interface IDatasetsRepository { getDataset( @@ -74,6 +78,13 @@ export interface IDatasetsRepository { limit?: number, offset?: number ): Promise<DatasetVersionSummarySubset> + getDatasetVersions( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise<DatasetVersionSubset> deleteDatasetDraft(datasetId: number | string): Promise<void> linkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise<void> unlinkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise<void> @@ -85,6 +96,11 @@ export interface IDatasetsRepository { format: CitationFormat, includeDeaccessioned?: boolean ): Promise<FormattedCitation> + exportDatasetMetadata( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise<ExportedDatasetMetadata> getDatasetAvailableDatasetTypes(): Promise<DatasetType[]> getDatasetAvailableDatasetType(datasetTypeId: number | string): Promise<DatasetType> addDatasetType(datasetType: DatasetTypeDTO): Promise<DatasetType> @@ -104,4 +120,5 @@ export interface IDatasetsRepository { ): Promise<void> getDatasetStorageDriver(datasetId: number | string): Promise<StorageDriver> getDatasetUploadLimits(datasetId: number | string): Promise<DatasetUploadLimits> + getDatasetReviews(datasetId: number | string): Promise<DatasetReview[]> } diff --git a/src/datasets/domain/useCases/CreateDataset.ts b/src/datasets/domain/useCases/CreateDataset.ts index 090c0721..a7912694 100644 --- a/src/datasets/domain/useCases/CreateDataset.ts +++ b/src/datasets/domain/useCases/CreateDataset.ts @@ -18,7 +18,7 @@ export class CreateDataset extends DatasetWriteUseCase<CreatedDatasetIdentifiers /** * Creates a new Dataset in a collection, given a DatasetDTO object and an optional collection identifier, which defaults to :root. * - * @param {DatasetDTO} [newDataset] - DatasetDTO object including the new dataset metadata field values for each metadata block. + * @param {DatasetDTO} [newDataset] - DatasetDTO object including the new dataset metadata field values for each metadata block and, optionally, the template ID to apply. * @param {string} [collectionId] - Specifies the collection identifier where the new dataset should be created (optional, defaults to :root). * @param {string} [datasetType] - Specifies the dataset type (optional, when omitted, defaults to "dataset"). * @returns {Promise<CreatedDatasetIdentifiers>} diff --git a/src/datasets/domain/useCases/ExportDatasetMetadata.ts b/src/datasets/domain/useCases/ExportDatasetMetadata.ts new file mode 100644 index 00000000..15fd8b02 --- /dev/null +++ b/src/datasets/domain/useCases/ExportDatasetMetadata.ts @@ -0,0 +1,28 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' +import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class ExportDatasetMetadata implements UseCase<ExportedDatasetMetadata> { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Exports dataset metadata in the specified metadata export format. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string for persistent identifiers or a number for numeric identifiers. + * @param {string} exporter - The metadata exporter format name. + * @param {DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT} [version] - The dataset version to export. If omitted, Dataverse defaults to :latest-published. + * @returns {Promise<ExportedDatasetMetadata>} The exported metadata content and content type. + */ + async execute( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise<ExportedDatasetMetadata> { + return await this.datasetsRepository.exportDatasetMetadata(datasetId, exporter, version) + } +} diff --git a/src/datasets/domain/useCases/GetDatasetReviews.ts b/src/datasets/domain/useCases/GetDatasetReviews.ts new file mode 100644 index 00000000..33ce43ae --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetReviews.ts @@ -0,0 +1,21 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetReview } from '../models/DatasetReview' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class GetDatasetReviews implements UseCase<DatasetReview[]> { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns the local review datasets that review the given dataset. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise<DatasetReview[]>} + */ + async execute(datasetId: number | string): Promise<DatasetReview[]> { + return this.datasetsRepository.getDatasetReviews(datasetId) + } +} diff --git a/src/datasets/domain/useCases/GetDatasetStorageDriver.ts b/src/datasets/domain/useCases/GetDatasetStorageDriver.ts index 361d1db9..3de13864 100644 --- a/src/datasets/domain/useCases/GetDatasetStorageDriver.ts +++ b/src/datasets/domain/useCases/GetDatasetStorageDriver.ts @@ -1,6 +1,6 @@ import { UseCase } from '../../../core/domain/useCases/UseCase' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { IDatasetsRepository } from '../repositories/IDatasetsRepository' -import { StorageDriver } from '../models/StorageDriver' export class GetDatasetStorageDriver implements UseCase<StorageDriver> { private datasetsRepository: IDatasetsRepository diff --git a/src/datasets/domain/useCases/GetDatasetVersions.ts b/src/datasets/domain/useCases/GetDatasetVersions.ts new file mode 100644 index 00000000..03ae71fd --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetVersions.ts @@ -0,0 +1,38 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetVersionSubset } from '../models/DatasetVersionSubset' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class GetDatasetVersions implements UseCase<DatasetVersionSubset> { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns a list of versions for a given dataset including (optionally) metadata blocks and files. + * Draft versions will only be available to users who have permission to view unpublished drafts. + * + * @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {number} [limit] - Limit for pagination (optional). + * @param {number} [offset] - Offset for pagination (optional). + * @param {boolean} [excludeMetadataBlocks] - Exclude metadata blocks (optional, default: false). + * @param {boolean} [keepRawFields=false] - Indicates whether or not the use case should keep the metadata fields as they are and avoid the transformation to markdown. The default value is false. + * @returns {Promise<DatasetVersionSubset>} - A DatasetVersionSubset containing the versions and total count. + */ + async execute( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise<DatasetVersionSubset> { + return await this.datasetsRepository.getDatasetVersions( + datasetId, + limit, + offset, + excludeMetadataBlocks, + keepRawFields + ) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index a129467f..afa2252e 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -19,6 +19,7 @@ import { GetDatasetVersionDiff } from './domain/useCases/GetDatasetVersionDiff' import { DeaccessionDataset } from './domain/useCases/DeaccessionDataset' import { GetDatasetDownloadCount } from './domain/useCases/GetDatasetDownloadCount' import { GetDatasetVersionsSummaries } from './domain/useCases/GetDatasetVersionsSummaries' +import { GetDatasetVersions } from './domain/useCases/GetDatasetVersions' import { DeleteDatasetDraft } from './domain/useCases/DeleteDatasetDraft' import { LinkDataset } from './domain/useCases/LinkDataset' import { UnlinkDataset } from './domain/useCases/UnlinkDataset' @@ -35,6 +36,8 @@ import { UpdateTermsOfAccess } from './domain/useCases/UpdateTermsOfAccess' import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense' import { GetDatasetStorageDriver } from './domain/useCases/GetDatasetStorageDriver' import { GetDatasetUploadLimits } from './domain/useCases/GetDatasetUploadLimits' +import { GetDatasetReviews } from './domain/useCases/GetDatasetReviews' +import { ExportDatasetMetadata } from './domain/useCases/ExportDatasetMetadata' const datasetsRepository = new DatasetsRepository() @@ -68,6 +71,7 @@ const updateDataset = new UpdateDataset( const deaccessionDataset = new DeaccessionDataset(datasetsRepository) const getDatasetDownloadCount = new GetDatasetDownloadCount(datasetsRepository) const getDatasetVersionsSummaries = new GetDatasetVersionsSummaries(datasetsRepository) +const getDatasetVersions = new GetDatasetVersions(datasetsRepository) const deleteDatasetDraft = new DeleteDatasetDraft(datasetsRepository) const linkDataset = new LinkDataset(datasetsRepository) const unlinkDataset = new UnlinkDataset(datasetsRepository) @@ -86,6 +90,8 @@ const updateTermsOfAccess = new UpdateTermsOfAccess(datasetsRepository) const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository) const getDatasetStorageDriver = new GetDatasetStorageDriver(datasetsRepository) const getDatasetUploadLimits = new GetDatasetUploadLimits(datasetsRepository) +const getDatasetReviews = new GetDatasetReviews(datasetsRepository) +const exportDatasetMetadata = new ExportDatasetMetadata(datasetsRepository) export { getDataset, @@ -103,6 +109,7 @@ export { deaccessionDataset, getDatasetDownloadCount, getDatasetVersionsSummaries, + getDatasetVersions, deleteDatasetDraft, linkDataset, unlinkDataset, @@ -118,9 +125,12 @@ export { deleteDatasetType, updateDatasetLicense, getDatasetStorageDriver, - getDatasetUploadLimits + getDatasetUploadLimits, + getDatasetReviews, + exportDatasetMetadata } export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion' +export { ExportedDatasetMetadata } from './domain/models/ExportedDatasetMetadata' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' export { DatasetLock, DatasetLockType } from './domain/models/DatasetLock' export { @@ -136,6 +146,7 @@ export { TermsOfUse } from './domain/models/Dataset' export { DatasetPreview } from './domain/models/DatasetPreview' +export { DatasetVersionSubset } from './domain/models/DatasetVersionSubset' export { DatasetVersionDiff } from './domain/models/DatasetVersionDiff' export { DatasetPreviewSubset } from './domain/models/DatasetPreviewSubset' export { @@ -157,5 +168,9 @@ export { export { DatasetLinkedCollection } from './domain/models/DatasetLinkedCollection' export { DatasetType } from './domain/models/DatasetType' export { DatasetTypeDTO } from './domain/dtos/DatasetTypeDTO' -export { StorageDriver } from './domain/models/StorageDriver' export { DatasetUploadLimits } from './domain/models/DatasetUploadLimits' +export { + DatasetReview, + DatasetReviewRubricMetadataBlock, + DatasetReviewRubricMetadataField +} from './domain/models/DatasetReview' diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 5bac498d..a0e80a29 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -3,7 +3,8 @@ import { IDatasetsRepository } from '../../domain/repositories/IDatasetsReposito import { Dataset, VersionUpdateType } from '../../domain/models/Dataset' import { transformVersionResponseToDataset, - transformDatasetModelToUpdateDatasetRequestPayload + transformDatasetModelToUpdateDatasetRequestPayload, + transformVersionPayloadToDataset } from './transformers/datasetTransformers' import { DatasetUserPermissions } from '../../domain/models/DatasetUserPermissions' import { transformDatasetUserPermissionsResponseToDatasetUserPermissions } from './transformers/datasetUserPermissionsTransformers' @@ -20,17 +21,23 @@ import { DatasetVersionDiff } from '../../domain/models/DatasetVersionDiff' import { transformDatasetVersionDiffResponseToDatasetVersionDiff } from './transformers/datasetVersionDiffTransformers' import { DatasetDownloadCount } from '../../domain/models/DatasetDownloadCount' import { DatasetVersionSummarySubset } from '../../domain/models/DatasetVersionSummaryInfo' +import { DatasetVersionSubset } from '../../domain/models/DatasetVersionSubset' import { DatasetLinkedCollection } from '../../domain/models/DatasetLinkedCollection' import { CitationFormat } from '../../domain/models/CitationFormat' import { transformDatasetLinkedCollectionsResponseToDatasetLinkedCollection } from './transformers/datasetLinkedCollectionsTransformers' import { FormattedCitation } from '../../domain/models/FormattedCitation' import { DatasetType } from '../../domain/models/DatasetType' import { TermsOfAccess } from '../../domain/models/Dataset' +import { DatasetNotNumberedVersion } from '../../domain/models/DatasetNotNumberedVersion' import { transformTermsOfAccessToUpdatePayload } from './transformers/termsOfAccessTransformers' import { DatasetLicenseUpdateRequest } from '../../domain/dtos/DatasetLicenseUpdateRequest' import { DatasetTypeDTO } from '../../domain/dtos/DatasetTypeDTO' -import { StorageDriver } from '../../domain/models/StorageDriver' +import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../../domain/models/DatasetUploadLimits' +import { DatasetReview } from '../../domain/models/DatasetReview' +import { transformDatasetReviewsResponseToDatasetReviews } from './transformers/datasetReviewTransformers' +import { ExportedDatasetMetadata } from '../../domain/models/ExportedDatasetMetadata' +import { DatasetPayload } from './transformers/DatasetPayload' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -131,6 +138,39 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi } } + public async exportDatasetMetadata( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise<ExportedDatasetMetadata> { + const persistentId = + typeof datasetId === 'number' + ? ( + await this.getDataset( + datasetId, + version ?? DatasetNotNumberedVersion.LATEST_PUBLISHED, + false, + false + ) + ).persistentId + : datasetId + + const response = await this.doGet('/datasets/export', true, { + exporter, + persistentId, + ...(version && { version }) + }) + + const contentType = String(response.headers['content-type'] ?? '') + const content = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + + return { + content, + contentType + } + } + public async getPrivateUrlDatasetCitation(token: string): Promise<string> { return this.doGet( this.buildApiEndpoint(this.datasetsResourceName, `privateUrlDatasetVersion/${token}/citation`) @@ -335,6 +375,42 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async getDatasetVersions( + datasetId: string | number, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise<DatasetVersionSubset> { + const queryParams = new URLSearchParams() + + if (limit) { + queryParams.set('limit', limit.toString()) + } + + if (offset) { + queryParams.set('offset', offset.toString()) + } + + if (excludeMetadataBlocks !== undefined) { + queryParams.set('excludeMetadataBlocks', excludeMetadataBlocks.toString()) + } + + return this.doGet( + this.buildApiEndpoint(this.datasetsResourceName, 'versions', datasetId), + true, + queryParams + ) + .then((response) => ({ + versions: response.data.data.map((x: DatasetPayload) => + transformVersionPayloadToDataset(x, keepRawFields ?? false) + ) + })) + .catch((error) => { + throw error + }) + } + public async deleteDatasetDraft(datasetId: string | number): Promise<void> { return this.doDelete( this.buildApiEndpoint(this.datasetsResourceName, 'versions/:draft', datasetId) @@ -523,4 +599,12 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi throw error }) } + + public async getDatasetReviews(datasetId: number | string): Promise<DatasetReview[]> { + return this.doGet(this.buildApiEndpoint(this.datasetsResourceName, 'reviews', datasetId), true) + .then((response) => transformDatasetReviewsResponseToDatasetReviews(response)) + .catch((error) => { + throw error + }) + } } diff --git a/src/datasets/infra/repositories/transformers/DatasetPayload.ts b/src/datasets/infra/repositories/transformers/DatasetPayload.ts index 347bc5d1..5b2df9ff 100644 --- a/src/datasets/infra/repositories/transformers/DatasetPayload.ts +++ b/src/datasets/infra/repositories/transformers/DatasetPayload.ts @@ -18,6 +18,7 @@ export interface DatasetPayload { publicationDate?: string citationDate?: string guestbookId?: number + templateId?: number fileAccessRequest: boolean termsOfAccess?: string dataAccessPlace?: string diff --git a/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts new file mode 100644 index 00000000..174a437b --- /dev/null +++ b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts @@ -0,0 +1,23 @@ +export interface DatasetReviewPayload { + title: string + authors?: string[] + persistentId: string + persistentIdUrl: string + id: number + citation: string + citationHtml: string + datePublished?: string + description?: string + rubricMetadataBlocks?: DatasetReviewRubricMetadataBlockPayload[] +} + +export interface DatasetReviewRubricMetadataBlockPayload { + name: string + displayName: string + fields?: DatasetReviewRubricMetadataFieldPayload[] +} + +export interface DatasetReviewRubricMetadataFieldPayload { + typeName: string + value: string +} diff --git a/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts b/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts new file mode 100644 index 00000000..e62fb3ac --- /dev/null +++ b/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts @@ -0,0 +1,28 @@ +import { AxiosResponse } from 'axios' + +import { DatasetReview } from '../../../domain/models/DatasetReview' +import { DatasetReviewPayload } from './DatasetReviewPayload' + +export const transformDatasetReviewsResponseToDatasetReviews = ( + response: AxiosResponse +): DatasetReview[] => { + const reviews = (response.data.data?.reviews ?? []) as DatasetReviewPayload[] + + return reviews.map((review) => ({ + title: review.title, + authors: review.authors ?? [], + persistentId: review.persistentId, + persistentIdUrl: review.persistentIdUrl, + id: review.id, + citation: review.citation, + citationHtml: review.citationHtml, + datePublished: review.datePublished ?? '', + description: review.description ?? '', + rubricMetadataBlocks: + review.rubricMetadataBlocks?.map((metadataBlock) => ({ + name: metadataBlock.name, + displayName: metadataBlock.displayName, + fields: metadataBlock.fields ?? [] + })) ?? [] + })) +} diff --git a/src/datasets/infra/repositories/transformers/datasetTransformers.ts b/src/datasets/infra/repositories/transformers/datasetTransformers.ts index 1c1e31d8..17075a01 100644 --- a/src/datasets/infra/repositories/transformers/datasetTransformers.ts +++ b/src/datasets/infra/repositories/transformers/datasetTransformers.ts @@ -32,6 +32,7 @@ const turndownService = new TurndownService() export interface NewDatasetRequestPayload { datasetType?: string + templateId?: number datasetVersion: { license?: DatasetLicense metadataBlocks: Record<string, MetadataBlockRequestPayload> @@ -102,6 +103,7 @@ export const transformDatasetModelToNewDatasetRequestPayload = ( ): NewDatasetRequestPayload => { return { datasetType, + ...(dataset.templateId !== undefined && { templateId: dataset.templateId }), datasetVersion: { ...(dataset.license && { license: dataset.license }), metadataBlocks: transformMetadataBlockModelsToRequestPayload( @@ -254,10 +256,12 @@ export const transformVersionPayloadToDataset = ( studyCompletion: transformPayloadText(keepRawFields, versionPayload.studyCompletion) } }, - metadataBlocks: transformPayloadToDatasetMetadataBlocks( - versionPayload.metadataBlocks, - keepRawFields - ), + ...(versionPayload.metadataBlocks && { + metadataBlocks: transformPayloadToDatasetMetadataBlocks( + versionPayload.metadataBlocks, + keepRawFields + ) + }), ...(versionPayload.isPartOf && { isPartOf: transformPayloadToOwnerNode(versionPayload.isPartOf) }) @@ -299,6 +303,9 @@ export const transformVersionPayloadToDataset = ( if ('guestbookId' in versionPayload) { datasetModel.guestbookId = versionPayload.guestbookId } + if ('templateId' in versionPayload) { + datasetModel.templateId = versionPayload.templateId + } if ('datasetType' in versionPayload) { datasetModel.datasetType = versionPayload.datasetType } diff --git a/src/files/domain/models/FileCitationFormat.ts b/src/files/domain/models/FileCitationFormat.ts new file mode 100644 index 00000000..ffe657ee --- /dev/null +++ b/src/files/domain/models/FileCitationFormat.ts @@ -0,0 +1,7 @@ +export enum FileCitationFormat { + ENDNOTE = 'EndNote', + RIS = 'RIS', + BIBTEX = 'BibTeX', + CSL = 'CSL', + INTERNAL = 'Internal' +} diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 4890a38b..9656f5e0 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -11,6 +11,7 @@ import { UploadedFileDTO } from '../dtos/UploadedFileDTO' import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO' import { RestrictFileDTO } from '../dtos/RestrictFileDTO' import { FileVersionSummarySubset } from '../models/FileVersionSummaryInfo' +import { FileCitationFormat } from '../models/FileCitationFormat' export interface IFilesRepository { getDatasetFiles( @@ -57,6 +58,12 @@ export interface IFilesRepository { includeDeaccessioned: boolean ): Promise<string> + getFileCitationByFormat( + fileId: number | string, + format: FileCitationFormat, + version?: string + ): Promise<string> + getFileUploadDestination(datasetId: number | string, file: File): Promise<FileUploadDestination> addUploadedFilesToDataset( diff --git a/src/files/domain/useCases/GetFileCitationByFormat.ts b/src/files/domain/useCases/GetFileCitationByFormat.ts new file mode 100644 index 00000000..51bd749d --- /dev/null +++ b/src/files/domain/useCases/GetFileCitationByFormat.ts @@ -0,0 +1,27 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IFilesRepository } from '../repositories/IFilesRepository' +import { FileCitationFormat } from '../models/FileCitationFormat' + +export class GetFileCitationByFormat implements UseCase<string> { + private filesRepository: IFilesRepository + + constructor(filesRepository: IFilesRepository) { + this.filesRepository = filesRepository + } + + /** + * Returns the File citation in the requested format (EndNote XML, RIS, BibTeX, CSL JSON, or Internal HTML). + * + * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {FileCitationFormat} [format] - The citation format to return. + * @param {string} [version] - Dataset version: a number such as 1.0, :draft, :latest, or :latest-published. Omit to use the server default. Drafts require access to the dataset. + * @returns {Promise<string>} + */ + async execute( + fileId: number | string, + format: FileCitationFormat, + version?: string + ): Promise<string> { + return await this.filesRepository.getFileCitationByFormat(fileId, format, version) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index a9d38386..f49a0ea3 100644 --- a/src/files/index.ts +++ b/src/files/index.ts @@ -7,6 +7,7 @@ import { GetFileDataTables } from './domain/useCases/GetFileDataTables' import { GetDatasetFilesTotalDownloadSize } from './domain/useCases/GetDatasetFilesTotalDownloadSize' import { GetFile } from './domain/useCases/GetFile' import { GetFileCitation } from './domain/useCases/GetFileCitation' +import { GetFileCitationByFormat } from './domain/useCases/GetFileCitationByFormat' import { GetFileAndDataset } from './domain/useCases/GetFileAndDataset' import { UploadFile } from './domain/useCases/UploadFile' import { DirectUploadClient } from './infra/clients/DirectUploadClient' @@ -32,6 +33,7 @@ const getDatasetFilesTotalDownloadSize = new GetDatasetFilesTotalDownloadSize(fi const getFile = new GetFile(filesRepository) const getFileAndDataset = new GetFileAndDataset(filesRepository) const getFileCitation = new GetFileCitation(filesRepository) +const getFileCitationByFormat = new GetFileCitationByFormat(filesRepository) const uploadFile = new UploadFile(directUploadClient) const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository) const deleteFile = new DeleteFile(filesRepository) @@ -53,6 +55,7 @@ export { getFile, getFileAndDataset, getFileCitation, + getFileCitationByFormat, uploadFile, addUploadedFilesToDataset, deleteFile, @@ -89,6 +92,7 @@ export { FileDataVariableFormatType } from './domain/models/FileDataTable' export { FileDownloadSizeMode } from './domain/models/FileDownloadSizeMode' +export { FileCitationFormat } from './domain/models/FileCitationFormat' export { FilesSubset } from './domain/models/FilesSubset' export { FilePreview, FilePreviewChecksum } from './domain/models/FilePreview' export { UploadedFileDTO } from './domain/dtos/UploadedFileDTO' diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index 00b70ba8..2276428f 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -24,6 +24,7 @@ import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' import { RestrictFileDTO } from '../../domain/dtos/RestrictFileDTO' import { FileVersionSummarySubset } from '../../domain/models/FileVersionSummaryInfo' import { transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo } from './transformers/fileVersionSummaryInfoTransformers' +import { FileCitationFormat } from '../../domain/models/FileCitationFormat' export interface GetFilesQueryParams { includeDeaccessioned: boolean @@ -234,6 +235,24 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { }) } + public async getFileCitationByFormat( + fileId: number | string, + format: FileCitationFormat, + version?: string + ): Promise<string> { + const endpoint = this.buildApiEndpoint(this.accessResourceName, `citation/${format}`, fileId) + const request = + version === undefined ? this.doGet(endpoint, true) : this.doGet(endpoint, true, { version }) + + return request + .then((response) => + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + ) + .catch((error) => { + throw error + }) + } + public async getFileUploadDestination( datasetId: number | string, file: File diff --git a/src/guestbooks/domain/dtos/EditGuestbookDTO.ts b/src/guestbooks/domain/dtos/EditGuestbookDTO.ts new file mode 100644 index 00000000..c20ad0b8 --- /dev/null +++ b/src/guestbooks/domain/dtos/EditGuestbookDTO.ts @@ -0,0 +1,28 @@ +export type EditGuestbookQuestionTypeDTO = 'text' | 'textarea' | 'options' + +export interface EditGuestbookOptionDTO { + id?: number + value: string + displayOrder: number +} + +export interface EditGuestbookCustomQuestionDTO { + id?: number + question: string + required: boolean + displayOrder: number + type: EditGuestbookQuestionTypeDTO + hidden: boolean + optionValues?: EditGuestbookOptionDTO[] +} + +export interface EditGuestbookDTO { + name: string + enabled: boolean + emailRequired: boolean + nameRequired: boolean + institutionRequired: boolean + positionRequired: boolean + createTime: string + customQuestions?: EditGuestbookCustomQuestionDTO[] +} diff --git a/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts b/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts new file mode 100644 index 00000000..7a08bdd0 --- /dev/null +++ b/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts @@ -0,0 +1,13 @@ +import { Guestbook } from '../models/Guestbook' +import { GuestbookResponse } from '../models/GuestbookResponse' + +export interface GuestbookResponsesDTO { + guestbook: Guestbook + responses: GuestbookResponse[] + pagination?: GuestbookResponsesPaginationDTO +} + +export interface GuestbookResponsesPaginationDTO { + next?: string + totalResponses: number +} diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts index 2a2f3c5b..899ef793 100644 --- a/src/guestbooks/domain/models/Guestbook.ts +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -1,11 +1,13 @@ export type GuestbookQuestionType = 'text' | 'textarea' | 'options' export interface GuestbookOption { + id?: number value: string displayOrder: number } export interface GuestbookCustomQuestion { + id?: number question: string required: boolean displayOrder: number @@ -25,4 +27,6 @@ export interface Guestbook { customQuestions: GuestbookCustomQuestion[] createTime: string dataverseId: number + usageCount?: number + responseCount?: number } diff --git a/src/guestbooks/domain/models/GuestbookResponse.ts b/src/guestbooks/domain/models/GuestbookResponse.ts new file mode 100644 index 00000000..3cf8d59a --- /dev/null +++ b/src/guestbooks/domain/models/GuestbookResponse.ts @@ -0,0 +1,32 @@ +export interface GuestbookResponse { + id: number + dataset: string + datasetPid: string + date: string + type: EventType + fileName?: string + fileId?: number + filePid?: string + name: string + email?: string + institution?: string + position?: string + customQuestions?: GuestbookResponseCustomQuestion[] +} + +export interface GuestbookResponseCustomQuestion { + question: string + response: string +} + +export interface GuestbookResponseSubset { + guestbookResponses: GuestbookResponse[] + totalGuestbookResponseCount: number +} + +export enum EventType { + ACCESS_REQUEST = 'AccessRequest', + DOWNLOAD = 'Download', + SUBSET = 'Subset', + EXPLORE = 'Explore' +} diff --git a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts index 87ce91ab..38fbb590 100644 --- a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts +++ b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts @@ -1,13 +1,29 @@ import { CreateGuestbookDTO } from '../dtos/CreateGuestbookDTO' +import { EditGuestbookDTO } from '../dtos/EditGuestbookDTO' import { Guestbook } from '../models/Guestbook' +import { GuestbookResponseSubset } from '../models/GuestbookResponse' export interface IGuestbooksRepository { createGuestbook( collectionIdOrAlias: number | string, guestbook: CreateGuestbookDTO ): Promise<number> + editGuestbook(guestbookId: number, guestbook: EditGuestbookDTO): Promise<void> getGuestbook(guestbookId: number): Promise<Guestbook> - getGuestbooksByCollectionId(collectionIdOrAlias: number | string): Promise<Guestbook[]> + getGuestbooksByCollectionId( + collectionIdOrAlias: number | string, + includeStats?: boolean, + includeInherited?: boolean + ): Promise<Guestbook[]> + getGuestbookResponsesByGuestbookId( + guestbookId: number, + limit?: number, + offset?: number + ): Promise<GuestbookResponseSubset> + downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias: number | string, + guestbookId?: number + ): Promise<string> setGuestbookEnabled( collectionIdOrAlias: number | string, guestbookId: number, diff --git a/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts new file mode 100644 index 00000000..d70b26d4 --- /dev/null +++ b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts @@ -0,0 +1,18 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class DownloadGuestbookResponsesByCollectionId implements UseCase<string> { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Downloads all guestbook responses for a collection. + * + * @param {number | string} collectionIdOrAlias - Collection alias/identifier or numeric database id. + * @returns {Promise<string>} Raw response body returned by the Dataverse API. + */ + async execute(collectionIdOrAlias: number | string): Promise<string> { + return await this.guestbooksRepository.downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias + ) + } +} diff --git a/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts new file mode 100644 index 00000000..64d047cc --- /dev/null +++ b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class DownloadGuestbookResponsesOfAGuestbook implements UseCase<string> { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Downloads guestbook responses for one guestbook in a collection. + * + * @param {number | string} collectionIdOrAlias - Collection alias/identifier or numeric database id. + * @param {number} guestbookId - Guestbook identifier to restrict the export. + * @returns {Promise<string>} Raw response body returned by the Dataverse API. + */ + async execute(collectionIdOrAlias: number | string, guestbookId: number): Promise<string> { + return await this.guestbooksRepository.downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias, + guestbookId + ) + } +} diff --git a/src/guestbooks/domain/useCases/EditGuestbook.ts b/src/guestbooks/domain/useCases/EditGuestbook.ts new file mode 100644 index 00000000..a1b267f5 --- /dev/null +++ b/src/guestbooks/domain/useCases/EditGuestbook.ts @@ -0,0 +1,17 @@ +import { EditGuestbookDTO } from '../dtos/EditGuestbookDTO' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class EditGuestbook { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Edits an existing guestbook. + * + * @param {number} guestbookId - Guestbook identifier. + * @param {EditGuestbookDTO} guestbook - Guestbook edit payload. + * @returns {Promise<void>} + */ + async execute(guestbookId: number, guestbook: EditGuestbookDTO): Promise<void> { + return await this.guestbooksRepository.editGuestbook(guestbookId, guestbook) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts b/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts new file mode 100644 index 00000000..0dfc5dd2 --- /dev/null +++ b/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts @@ -0,0 +1,23 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseSubset } from '../models/GuestbookResponse' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class GetGuestbookResponsesByGuestbookId implements UseCase<GuestbookResponseSubset> { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Returns paginated guestbook responses for one guestbook. + * + * @param {number} guestbookId - Guestbook identifier. + * @param {number} limit - Maximum number of responses to return. + * @param {number} offset - Number of responses to skip. + * @returns {Promise<GuestbookResponseSubset>} + */ + async execute(guestbookId: number, limit = 10, offset = 0): Promise<GuestbookResponseSubset> { + return await this.guestbooksRepository.getGuestbookResponsesByGuestbookId( + guestbookId, + limit, + offset + ) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts index 003bdb07..9748456e 100644 --- a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts +++ b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts @@ -9,9 +9,23 @@ export class GetGuestbooksByCollectionId implements UseCase<Guestbook[]> { * Returns all guestbooks available for a given collection. * * @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias). + * @param {boolean} [includeStats=false] - Include usage and response counts for each guestbook. + * @param {boolean} [includeInherited=false] - Include guestbooks inherited from hierarchical owners. * @returns {Promise<Guestbook[]>} */ - async execute(collectionIdOrAlias: number | string): Promise<Guestbook[]> { - return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias) + async execute( + collectionIdOrAlias: number | string, + includeStats = false, + includeInherited = false + ): Promise<Guestbook[]> { + if (!includeStats && !includeInherited) { + return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias) + } + + return await this.guestbooksRepository.getGuestbooksByCollectionId( + collectionIdOrAlias, + includeStats, + includeInherited + ) } } diff --git a/src/guestbooks/index.ts b/src/guestbooks/index.ts index 29d22988..cd1fbe6f 100644 --- a/src/guestbooks/index.ts +++ b/src/guestbooks/index.ts @@ -1,6 +1,10 @@ import { GuestbooksRepository } from './infra/repositories/GuestbooksRepository' import { CreateGuestbook } from './domain/useCases/CreateGuestbook' +import { EditGuestbook } from './domain/useCases/EditGuestbook' +import { DownloadGuestbookResponsesByCollectionId } from './domain/useCases/DownloadGuestbookResponsesByCollectionId' +import { DownloadGuestbookResponsesOfAGuestbook } from './domain/useCases/DownloadGuestbookResponsesOfAGuestbook' import { GetGuestbook } from './domain/useCases/GetGuestbook' +import { GetGuestbookResponsesByGuestbookId } from './domain/useCases/GetGuestbookResponsesByGuestbookId' import { GetGuestbooksByCollectionId } from './domain/useCases/GetGuestbooksByCollectionId' import { SetGuestbookEnabled } from './domain/useCases/SetGuestbookEnabled' import { AssignDatasetGuestbook } from './domain/useCases/AssignDatasetGuestbook' @@ -9,7 +13,17 @@ import { RemoveDatasetGuestbook } from './domain/useCases/RemoveDatasetGuestbook const guestbooksRepository = new GuestbooksRepository() const createGuestbook = new CreateGuestbook(guestbooksRepository) +const editGuestbook = new EditGuestbook(guestbooksRepository) +const downloadGuestbookResponsesByCollectionId = new DownloadGuestbookResponsesByCollectionId( + guestbooksRepository +) +const downloadGuestbookResponsesOfAGuestbook = new DownloadGuestbookResponsesOfAGuestbook( + guestbooksRepository +) const getGuestbook = new GetGuestbook(guestbooksRepository) +const getGuestbookResponsesByGuestbookId = new GetGuestbookResponsesByGuestbookId( + guestbooksRepository +) const getGuestbooksByCollectionId = new GetGuestbooksByCollectionId(guestbooksRepository) const setGuestbookEnabled = new SetGuestbookEnabled(guestbooksRepository) const assignDatasetGuestbook = new AssignDatasetGuestbook(guestbooksRepository) @@ -17,7 +31,11 @@ const removeDatasetGuestbook = new RemoveDatasetGuestbook(guestbooksRepository) export { createGuestbook, + editGuestbook, + downloadGuestbookResponsesByCollectionId, + downloadGuestbookResponsesOfAGuestbook, getGuestbook, + getGuestbookResponsesByGuestbookId, getGuestbooksByCollectionId, setGuestbookEnabled, assignDatasetGuestbook, @@ -29,4 +47,19 @@ export { CreateGuestbookCustomQuestionDTO, CreateGuestbookOptionDTO } from './domain/dtos/CreateGuestbookDTO' +export { + EditGuestbookDTO, + EditGuestbookCustomQuestionDTO, + EditGuestbookOptionDTO +} from './domain/dtos/EditGuestbookDTO' +export { + GuestbookResponsesDTO, + GuestbookResponsesPaginationDTO +} from './domain/dtos/GuestbookResponsesDTO' export { Guestbook, GuestbookCustomQuestion, GuestbookOption } from './domain/models/Guestbook' +export { + EventType, + GuestbookResponse, + GuestbookResponseCustomQuestion, + GuestbookResponseSubset +} from './domain/models/GuestbookResponse' diff --git a/src/guestbooks/infra/repositories/GuestbooksRepository.ts b/src/guestbooks/infra/repositories/GuestbooksRepository.ts index 6f9812e6..77de66bf 100644 --- a/src/guestbooks/infra/repositories/GuestbooksRepository.ts +++ b/src/guestbooks/infra/repositories/GuestbooksRepository.ts @@ -1,11 +1,15 @@ import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' import { CreateGuestbookDTO } from '../../domain/dtos/CreateGuestbookDTO' +import { EditGuestbookDTO } from '../../domain/dtos/EditGuestbookDTO' +import { GuestbookResponsesDTO } from '../../domain/dtos/GuestbookResponsesDTO' import { Guestbook } from '../../domain/models/Guestbook' +import { GuestbookResponseSubset } from '../../domain/models/GuestbookResponse' import { IGuestbooksRepository } from '../../domain/repositories/IGuestbooksRepository' export class GuestbooksRepository extends ApiRepository implements IGuestbooksRepository { private readonly guestbooksResourceName: string = 'guestbooks' private readonly datasetsResourceName: string = 'datasets' + private readonly dataversesResourceName: string = 'dataverses' public async createGuestbook( collectionIdOrAlias: number | string, @@ -21,6 +25,13 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe }) } + public async editGuestbook(guestbookId: number, guestbook: EditGuestbookDTO): Promise<void> { + await this.doPut( + this.buildApiEndpoint(this.guestbooksResourceName, undefined, guestbookId), + guestbook + ) + } + public async getGuestbook(guestbookId: number): Promise<Guestbook> { return this.doGet( this.buildApiEndpoint(this.guestbooksResourceName, undefined, guestbookId), @@ -33,11 +44,19 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe } public async getGuestbooksByCollectionId( - collectionIdOrAlias: number | string + collectionIdOrAlias: number | string, + includeStats = false, + includeInherited = false ): Promise<Guestbook[]> { + const queryParams = { + ...(includeStats ? { includeStats } : {}), + ...(includeInherited ? { includeInherited } : {}) + } + return this.doGet( this.buildApiEndpoint(this.guestbooksResourceName, `${collectionIdOrAlias}/list`), - true + true, + queryParams ) .then((response) => response.data.data as Guestbook[]) .catch((error) => { @@ -45,6 +64,45 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe }) } + public async getGuestbookResponsesByGuestbookId( + guestbookId: number, + limit = 10, + offset = 0 + ): Promise<GuestbookResponseSubset> { + return this.doGet( + this.buildApiEndpoint(this.guestbooksResourceName, 'responses', guestbookId), + true, + { limit, offset } + ) + .then((response) => { + const responseData = response.data.data as GuestbookResponsesDTO + + return { + guestbookResponses: responseData.responses, + totalGuestbookResponseCount: responseData.pagination?.totalResponses ?? 0 + } + }) + .catch((error) => { + throw error + }) + } + + public async downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias: number | string, + guestbookId?: number + ): Promise<string> { + const endpoint = this.buildApiEndpoint( + this.dataversesResourceName, + `${collectionIdOrAlias}/guestbookResponses` + ) + + return this.doGet(endpoint, true, guestbookId ? { guestbookId } : {}) + .then((response) => response.data as string) + .catch((error) => { + throw error + }) + } + public async setGuestbookEnabled( collectionIdOrAlias: number | string, guestbookId: number, diff --git a/src/roles/domain/models/RoleAlias.ts b/src/roles/domain/models/RoleAlias.ts new file mode 100644 index 00000000..23e35fff --- /dev/null +++ b/src/roles/domain/models/RoleAlias.ts @@ -0,0 +1,12 @@ +// Aliases of built-in roles +export enum RoleAlias { + ADMIN = 'admin', + FILE_DOWNLOADER = 'fileDownloader', + FULL_CONTRIBUTOR = 'fullContributor', + DV_CONTRIBUTOR = 'dvContributor', + DS_CONTRIBUTOR = 'dsContributor', + EDITOR = 'contributor', + MANAGER = 'manager', + CURATOR = 'curator', + MEMBER = 'member' +} diff --git a/src/roles/index.ts b/src/roles/index.ts index 8bd9e276..51617c3d 100644 --- a/src/roles/index.ts +++ b/src/roles/index.ts @@ -8,3 +8,4 @@ const getUserSelectableRoles = new GetUserSelectableRoles(rolesRepository) export { getUserSelectableRoles } export { Role } from './domain/models/Role' +export { RoleAlias } from './domain/models/RoleAlias' diff --git a/src/templates/domain/dtos/UpdateTemplateLicenseTermsDTO.ts b/src/templates/domain/dtos/UpdateTemplateLicenseTermsDTO.ts new file mode 100644 index 00000000..b476a07d --- /dev/null +++ b/src/templates/domain/dtos/UpdateTemplateLicenseTermsDTO.ts @@ -0,0 +1,6 @@ +import { CustomTerms } from '../../../datasets/domain/models/Dataset' + +export interface UpdateTemplateLicenseTermsDTO { + name?: string + customTerms?: CustomTerms +} diff --git a/src/templates/domain/dtos/UpdateTemplateMetadataDTO.ts b/src/templates/domain/dtos/UpdateTemplateMetadataDTO.ts new file mode 100644 index 00000000..3504fcea --- /dev/null +++ b/src/templates/domain/dtos/UpdateTemplateMetadataDTO.ts @@ -0,0 +1,7 @@ +import { TemplateFieldDTO, TemplateInstructionDTO } from './CreateTemplateDTO' + +export interface UpdateTemplateMetadataDTO { + name?: string + fields?: TemplateFieldDTO[] + instructions?: TemplateInstructionDTO[] +} diff --git a/src/templates/domain/repositories/ITemplatesRepository.ts b/src/templates/domain/repositories/ITemplatesRepository.ts index 7b043f0c..2adb7f57 100644 --- a/src/templates/domain/repositories/ITemplatesRepository.ts +++ b/src/templates/domain/repositories/ITemplatesRepository.ts @@ -1,8 +1,21 @@ import { CreateTemplateDTO } from '../dtos/CreateTemplateDTO' +import { UpdateTemplateLicenseTermsDTO } from '../dtos/UpdateTemplateLicenseTermsDTO' +import { UpdateTemplateMetadataDTO } from '../dtos/UpdateTemplateMetadataDTO' import { Template } from '../models/Template' +import { TermsOfAccess } from '../../../datasets/domain/models/Dataset' export interface ITemplatesRepository { createTemplate(collectionIdOrAlias: number | string, template: CreateTemplateDTO): Promise<void> + updateTemplateMetadata( + templateId: number, + payload: UpdateTemplateMetadataDTO, + replace?: boolean + ): Promise<void> + updateTemplateLicenseTerms( + templateId: number, + payload: UpdateTemplateLicenseTermsDTO + ): Promise<void> + updateTemplateTermsOfAccess(templateId: number, termsOfAccess: TermsOfAccess): Promise<void> getTemplate(templateId: number): Promise<Template> getTemplatesByCollectionId(collectionIdOrAlias: number | string): Promise<Template[]> deleteTemplate(templateId: number): Promise<void> diff --git a/src/templates/domain/useCases/UpdateTemplateLicenseTerms.ts b/src/templates/domain/useCases/UpdateTemplateLicenseTerms.ts new file mode 100644 index 00000000..f5956e5a --- /dev/null +++ b/src/templates/domain/useCases/UpdateTemplateLicenseTerms.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { UpdateTemplateLicenseTermsDTO } from '../dtos/UpdateTemplateLicenseTermsDTO' +import { ITemplatesRepository } from '../repositories/ITemplatesRepository' + +export class UpdateTemplateLicenseTerms implements UseCase<void> { + private templatesRepository: ITemplatesRepository + + constructor(templatesRepository: ITemplatesRepository) { + this.templatesRepository = templatesRepository + } + + /** + * Updates the license terms for a template with the given identifier. + * + * @param {number} templateId - The unique identifier of the template to update. + * @param {UpdateTemplateLicenseTermsDTO} payload - The license terms data to apply to the template. + * @returns {Promise<void>} A promise that resolves when the license terms have been updated. + */ + async execute(templateId: number, payload: UpdateTemplateLicenseTermsDTO): Promise<void> { + return await this.templatesRepository.updateTemplateLicenseTerms(templateId, payload) + } +} diff --git a/src/templates/domain/useCases/UpdateTemplateMetadata.ts b/src/templates/domain/useCases/UpdateTemplateMetadata.ts new file mode 100644 index 00000000..24998455 --- /dev/null +++ b/src/templates/domain/useCases/UpdateTemplateMetadata.ts @@ -0,0 +1,25 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { UpdateTemplateMetadataDTO } from '../dtos/UpdateTemplateMetadataDTO' +import { ITemplatesRepository } from '../repositories/ITemplatesRepository' + +export class UpdateTemplateMetadata implements UseCase<void> { + private templatesRepository: ITemplatesRepository + + constructor(templatesRepository: ITemplatesRepository) { + this.templatesRepository = templatesRepository + } + /** + * Updates the metadata for a template with the given identifier. + * + * @param {number} templateId - The unique identifier of the template to update. + * @param {UpdateTemplateMetadataDTO} payload - The metadata to apply to the template. + * @returns {Promise<void>} A promise that resolves when the metadata has been updated. + */ + async execute( + templateId: number, + payload: UpdateTemplateMetadataDTO, + replace = false + ): Promise<void> { + return await this.templatesRepository.updateTemplateMetadata(templateId, payload, replace) + } +} diff --git a/src/templates/domain/useCases/UpdateTemplateTermsOfAccess.ts b/src/templates/domain/useCases/UpdateTemplateTermsOfAccess.ts new file mode 100644 index 00000000..e4336f45 --- /dev/null +++ b/src/templates/domain/useCases/UpdateTemplateTermsOfAccess.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { TermsOfAccess } from '../../../datasets/domain/models/Dataset' +import { ITemplatesRepository } from '../repositories/ITemplatesRepository' + +export class UpdateTemplateTermsOfAccess implements UseCase<void> { + private templatesRepository: ITemplatesRepository + + constructor(templatesRepository: ITemplatesRepository) { + this.templatesRepository = templatesRepository + } + + /** + * Updates the terms of access for a template with the given identifier. + * + * @param {number} templateId - The unique identifier of the template to update. + * @param {TermsOfAccess} termsOfAccess - The new terms of access to apply to the template. + * @returns {Promise<void>} A promise that resolves when the terms of access have been updated. + */ + async execute(templateId: number, termsOfAccess: TermsOfAccess): Promise<void> { + return await this.templatesRepository.updateTemplateTermsOfAccess(templateId, termsOfAccess) + } +} diff --git a/src/templates/index.ts b/src/templates/index.ts index 27bb1c85..6e8b3b81 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -5,6 +5,9 @@ import { GetTemplatesByCollectionId } from './domain/useCases/GetTemplatesByColl import { GetTemplate } from './domain/useCases/GetTemplate' import { SetTemplateAsDefault } from './domain/useCases/SetTemplateAsDefault' import { UnsetTemplateAsDefault } from './domain/useCases/UnsetTemplateAsDefault' +import { UpdateTemplateMetadata } from './domain/useCases/UpdateTemplateMetadata' +import { UpdateTemplateLicenseTerms } from './domain/useCases/UpdateTemplateLicenseTerms' +import { UpdateTemplateTermsOfAccess } from './domain/useCases/UpdateTemplateTermsOfAccess' const templatesRepository = new TemplatesRepository() @@ -12,6 +15,9 @@ const createTemplate = new CreateTemplate(templatesRepository) const deleteTemplate = new DeleteTemplate(templatesRepository) const getTemplatesByCollectionId = new GetTemplatesByCollectionId(templatesRepository) const getTemplate = new GetTemplate(templatesRepository) +const updateTemplateMetadata = new UpdateTemplateMetadata(templatesRepository) +const updateTemplateLicenseTerms = new UpdateTemplateLicenseTerms(templatesRepository) +const updateTemplateTermsOfAccess = new UpdateTemplateTermsOfAccess(templatesRepository) const setTemplateAsDefault = new SetTemplateAsDefault(templatesRepository) const unsetTemplateAsDefault = new UnsetTemplateAsDefault(templatesRepository) @@ -21,8 +27,12 @@ export { getTemplatesByCollectionId, getTemplate, setTemplateAsDefault, - unsetTemplateAsDefault + unsetTemplateAsDefault, + updateTemplateMetadata, + updateTemplateLicenseTerms, + updateTemplateTermsOfAccess } + export { CreateTemplateDTO, TemplateFieldDTO, @@ -32,4 +42,6 @@ export { TemplateFieldValueControlledVocabularyDTO, TemplateInstructionDTO } from './domain/dtos/CreateTemplateDTO' +export { UpdateTemplateMetadataDTO } from './domain/dtos/UpdateTemplateMetadataDTO' +export { UpdateTemplateLicenseTermsDTO } from './domain/dtos/UpdateTemplateLicenseTermsDTO' export { Template, TemplateInstruction } from './domain/models/Template' diff --git a/src/templates/infra/repositories/TemplatesRepository.ts b/src/templates/infra/repositories/TemplatesRepository.ts index c07885f7..2d7ad259 100644 --- a/src/templates/infra/repositories/TemplatesRepository.ts +++ b/src/templates/infra/repositories/TemplatesRepository.ts @@ -1,9 +1,13 @@ import { AxiosResponse } from 'axios' import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { TermsOfAccess } from '../../../datasets/domain/models/Dataset' import { CreateTemplateDTO } from '../../domain/dtos/CreateTemplateDTO' +import { UpdateTemplateLicenseTermsDTO } from '../../domain/dtos/UpdateTemplateLicenseTermsDTO' +import { UpdateTemplateMetadataDTO } from '../../domain/dtos/UpdateTemplateMetadataDTO' import { Template } from '../../domain/models/Template' import { ITemplatesRepository } from '../../domain/repositories/ITemplatesRepository' import { TemplatePayload } from './transformers/TemplatePayload' +import { transformTemplateTermsOfAccessToUpdatePayload } from './transformers/templateTermsOfAccessTransformers' import { transformTemplatePayloadToTemplate, transformTemplatePayloadsToTemplates @@ -26,6 +30,32 @@ export class TemplatesRepository extends ApiRepository implements ITemplatesRepo }) } + public async updateTemplateMetadata( + templateId: number, + payload: UpdateTemplateMetadataDTO, + replace = false + ): Promise<void> { + return this.updateTemplate(templateId, 'metadata', payload, { replace }) + } + + public async updateTemplateLicenseTerms( + templateId: number, + payload: UpdateTemplateLicenseTermsDTO + ): Promise<void> { + return this.updateTemplate(templateId, 'licenseTerms', payload) + } + + public async updateTemplateTermsOfAccess( + templateId: number, + termsOfAccess: TermsOfAccess + ): Promise<void> { + return this.updateTemplate( + templateId, + 'access', + transformTemplateTermsOfAccessToUpdatePayload(termsOfAccess) + ) + } + public async getTemplate(templateId: number): Promise<Template> { return this.doGet(`/dataverses/${templateId}/template`, true) .then((response: AxiosResponse<{ data: TemplatePayload }>) => @@ -56,6 +86,19 @@ export class TemplatesRepository extends ApiRepository implements ITemplatesRepo }) } + private async updateTemplate( + templateId: number, + operation: 'metadata' | 'licenseTerms' | 'access', + payload: object, + queryParams: object = {} + ): Promise<void> { + return this.doPut(`/dataverses/${templateId}/${operation}`, payload, queryParams) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + public async setTemplateAsDefault( collectionIdOrAlias: number | string, templateId: number diff --git a/src/templates/infra/repositories/transformers/templateTermsOfAccessTransformers.ts b/src/templates/infra/repositories/transformers/templateTermsOfAccessTransformers.ts new file mode 100644 index 00000000..fa3e6d84 --- /dev/null +++ b/src/templates/infra/repositories/transformers/templateTermsOfAccessTransformers.ts @@ -0,0 +1,30 @@ +import { TermsOfAccess } from '../../../../datasets/domain/models/Dataset' + +export const transformTemplateTermsOfAccessToUpdatePayload = ( + terms: TermsOfAccess & { termsOfAccess?: string } +) => { + const { + fileAccessRequest, + dataAccessPlace, + originalArchive, + availabilityStatus, + contactForAccess, + sizeOfCollection, + studyCompletion + } = terms + + const termsOfAccess = terms.termsOfAccess ?? terms.termsOfAccessForRestrictedFiles + + return { + customTermsOfAccess: { + fileAccessRequest, + termsOfAccess, + dataAccessPlace, + originalArchive, + availabilityStatus, + contactForAccess, + sizeOfCollection, + studyCompletion + } + } +} diff --git a/test/environment/setup.ts b/test/environment/setup.ts index 3126f8b1..bc46cc42 100644 --- a/test/environment/setup.ts +++ b/test/environment/setup.ts @@ -9,17 +9,20 @@ const CONTAINER_DATAVERSE_BOOTSTRAP_NAME = 'test_dataverse_bootstrap' const CONTAINER_DATAVERSE_BOOTSTRAP_END_MESSAGE = 'Done, your instance has been configured for development. Have a nice day!' const CONTAINERS_STARTUP_TIMEOUT = 300000 - +const SKIP_CONTAINERS = process.env.SKIP_CONTAINERS === 'true' // Set this environment variable to true to skip container setup and run tests against an already running instance const API_ALLOW_TOKEN_LOOKUP_ENDPOINT = '/admin/settings/:AllowApiTokenLookupViaApi' const API_KEY_USER_ENDPOINT = '/builtin-users/dataverseAdmin/api-token' const API_KEY_USER_PASSWORD = 'admin1' export default async function setupTestEnvironment(): Promise<void> { - await setupContainers() + await setupContainers(SKIP_CONTAINERS) //Set skipContainers to true to skip container setup and run tests against an already running instance await setupApiKey() } -async function setupContainers(): Promise<void> { +async function setupContainers(skipContainers?: boolean): Promise<void> { + if (skipContainers) { + return + } console.log('Cleaning up old container volumes...') fs.rmSync(`${__dirname}/docker-dev-volumes`, { recursive: true, force: true }) console.log('Running test containers...') diff --git a/test/functional/collections/GetMyDataCollectionItems.test.ts b/test/functional/collections/GetMyDataCollectionItems.test.ts index 5a9c43ec..94e2d672 100644 --- a/test/functional/collections/GetMyDataCollectionItems.test.ts +++ b/test/functional/collections/GetMyDataCollectionItems.test.ts @@ -3,8 +3,7 @@ import { createDataset, CreatedDatasetIdentifiers, CollectionPreview, - getMyDataCollectionItems, - ReadError + getMyDataCollectionItems } from '../../../src' import { TestConstants } from '../../testHelpers/TestConstants' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -66,136 +65,213 @@ describe('execute', () => { throw new Error('Tests afterAll(): Error while deleting test collection') } }) - test('should return error message when repository returns empty item subset', async () => { - expect.assertions(2) - let readError: ReadError | undefined = undefined - try { - await getMyDataCollectionItems.execute( - testRoleIds, - testCollectionItemTypes, - [PublicationStatus.Deaccessioned], - undefined, - undefined, - undefined - ) - throw new Error('Use case should throw an error') - } catch (error) { - readError = error as ReadError - } finally { - expect(readError).toBeInstanceOf(ReadError) - expect(readError?.message).toEqual( - 'There was an error when reading the resource. Reason was: Sorry, no results were found.' - ) - } - }), - test('should return items when valid roles,collection types, and publishingStatuses are provided', async () => { - // Give enough time to Solr for indexing - await new Promise((resolve) => setTimeout(resolve, 5000)) + test('should return an empty item subset when repository returns no results', async () => { + const actual = await getMyDataCollectionItems.execute( + testRoleIds, + testCollectionItemTypes, + [PublicationStatus.Deaccessioned], + undefined, + undefined, + 'no-results-for-get-my-data-collection-items' + ) - try { - const actual = await getMyDataCollectionItems.execute( - testRoleIds, - testCollectionItemTypes, - testPublishingStatuses, - undefined, - undefined, - testCollectionAlias - ) + expect(actual.items).toEqual([]) + expect(actual.totalItemCount).toBe(0) + expect(actual.publicationStatusCounts).toEqual([ + { + publicationStatus: 'Published', + count: 0 + }, + { + publicationStatus: 'Unpublished', + count: 0 + }, + { + publicationStatus: 'Draft', + count: 0 + }, + { + publicationStatus: 'In Review', + count: 0 + }, + { + publicationStatus: 'Deaccessioned', + count: 0 + } + ]) + expect(actual.countPerObjectType).toEqual({ + collections: 0, + datasets: 0, + files: 0 + }) + }) - const actualCollectionPreview = actual.items[0] as CollectionPreview - expect(actualCollectionPreview.alias).toBe(testCollectionAlias) + test('should return items when valid roles,collection types, and publishingStatuses are provided', async () => { + // Give enough time to Solr for indexing + await new Promise((resolve) => setTimeout(resolve, 5000)) + + const actual = await getMyDataCollectionItems.execute( + testRoleIds, + testCollectionItemTypes, + testPublishingStatuses, + undefined, + undefined, + testCollectionAlias + ) - expect(actual.totalItemCount).toBe(1) - expect(actual.publicationStatusCounts).toEqual([ - { - publicationStatus: 'Published', - count: 0 - }, - { - publicationStatus: 'Unpublished', - count: 1 - }, - { - publicationStatus: 'Draft', - count: 0 - }, - { - publicationStatus: 'In Review', - count: 0 - }, - { - publicationStatus: 'Deaccessioned', - count: 0 - } - ]) - expect(actual.countPerObjectType).toEqual({ - collections: 1, - datasets: 0, - files: 0 - }) - } catch (error) { - console.log(error) - throw new Error('Item subset should be retrieved') + const actualCollectionPreview = actual.items[0] as CollectionPreview + expect(actualCollectionPreview.alias).toBe(testCollectionAlias) + + expect(actual.totalItemCount).toBe(1) + expect(actual.publicationStatusCounts).toEqual([ + { + publicationStatus: 'Published', + count: 0 + }, + { + publicationStatus: 'Unpublished', + count: 1 + }, + { + publicationStatus: 'Draft', + count: 0 + }, + { + publicationStatus: 'In Review', + count: 0 + }, + { + publicationStatus: 'Deaccessioned', + count: 0 } + ]) + expect(actual.countPerObjectType).toEqual({ + collections: 1, + datasets: 0, + files: 0 }) + }) - test('should return an error message when no role is specified', async () => { - expect.assertions(2) - let readError: ReadError | undefined = undefined - try { - await getMyDataCollectionItems.execute([], [], [], undefined, undefined, undefined) - throw new Error('Use case should throw an error') - } catch (error) { - readError = error as ReadError - } finally { - expect(readError).toBeInstanceOf(ReadError) - expect(readError?.message).toEqual( - `There was an error when reading the resource. Reason was: No results. Please select at least one Role.` - ) - } + test('should return an empty item subset when no role is specified', async () => { + const actual = await getMyDataCollectionItems.execute( + [], + [], + [], + undefined, + undefined, + undefined + ) + + expect(actual.items).toEqual([]) + expect(actual.totalItemCount).toBe(0) + expect(actual.publicationStatusCounts).toEqual([ + { + publicationStatus: 'Published', + count: 0 + }, + { + publicationStatus: 'Unpublished', + count: 0 + }, + { + publicationStatus: 'Draft', + count: 0 + }, + { + publicationStatus: 'In Review', + count: 0 + }, + { + publicationStatus: 'Deaccessioned', + count: 0 + } + ]) + expect(actual.countPerObjectType).toEqual({ + collections: 0, + datasets: 0, + files: 0 + }) }) - test('should return an error message when no publication status is specified', async () => { - expect.assertions(2) - let readError: ReadError | undefined = undefined - try { - await getMyDataCollectionItems.execute( - testRoleIds, - testCollectionItemTypes, - [], - undefined, - undefined, - undefined - ) - throw new Error('Use case should throw an error') - } catch (error) { - readError = error as ReadError - } finally { - expect(readError).toBeInstanceOf(ReadError) - expect(readError?.message).toEqual( - `There was an error when reading the resource. Reason was: No user found for: "Published, Unpublished, Draft, In Review, Deaccessioned"` - ) - } + + test('should return an empty item subset when no publication status is specified', async () => { + const actual = await getMyDataCollectionItems.execute( + testRoleIds, + testCollectionItemTypes, + [], + undefined, + undefined, + undefined + ) + + expect(actual.items).toEqual([]) + expect(actual.totalItemCount).toBe(0) + expect(actual.publicationStatusCounts).toEqual([ + { + publicationStatus: 'Published', + count: 0 + }, + { + publicationStatus: 'Unpublished', + count: 0 + }, + { + publicationStatus: 'Draft', + count: 0 + }, + { + publicationStatus: 'In Review', + count: 0 + }, + { + publicationStatus: 'Deaccessioned', + count: 0 + } + ]) + expect(actual.countPerObjectType).toEqual({ + collections: 0, + datasets: 0, + files: 0 + }) }) - test('should an error message when no collection type is specified', async () => { - expect.assertions(2) - let readError: ReadError | undefined = undefined - try { - await getMyDataCollectionItems.execute( - testRoleIds, - testCollectionItemTypes, - [], - undefined, - undefined, - undefined - ) - throw new Error('Use case should throw an error') - } catch (error) { - readError = error as ReadError - } finally { - expect(readError).toBeInstanceOf(ReadError) - expect(readError?.message).toEqual( - `There was an error when reading the resource. Reason was: No user found for: "Published, Unpublished, Draft, In Review, Deaccessioned"` - ) - } + + test('should return an empty item subset when no collection type is specified', async () => { + const actual = await getMyDataCollectionItems.execute( + testRoleIds, + [], + testPublishingStatuses, + undefined, + undefined, + undefined + ) + + expect(actual.items).toEqual([]) + expect(actual.totalItemCount).toBe(0) + expect(actual.publicationStatusCounts).toEqual([ + { + publicationStatus: 'Published', + count: 0 + }, + { + publicationStatus: 'Unpublished', + count: 0 + }, + { + publicationStatus: 'Draft', + count: 0 + }, + { + publicationStatus: 'In Review', + count: 0 + }, + { + publicationStatus: 'Deaccessioned', + count: 0 + } + ]) + expect(actual.countPerObjectType).toEqual({ + collections: 0, + datasets: 0, + files: 0 + }) }) }) diff --git a/test/functional/collections/UnlinkCollection.test.ts b/test/functional/collections/UnlinkCollection.test.ts index 0b20b455..badf54ce 100644 --- a/test/functional/collections/UnlinkCollection.test.ts +++ b/test/functional/collections/UnlinkCollection.test.ts @@ -5,6 +5,7 @@ import { linkCollection, deleteCollection, getCollectionItems, + getCollectionLinks, unlinkCollection } from '../../../src' import { TestConstants } from '../../testHelpers/TestConstants' @@ -43,12 +44,14 @@ describe('execute', () => { // Verify that the collections are linked const collectionItemSubset = await getCollectionItems.execute(firstCollectionAlias) expect(collectionItemSubset.items.length).toBe(1) + const collectionLinksBefore = await getCollectionLinks.execute(firstCollectionId) + expect(collectionLinksBefore.linkedCollections.map((collection) => collection.alias)).toContain( + secondCollectionAlias + ) await unlinkCollection.execute(secondCollectionAlias, firstCollectionAlias) - // Wait for the unlinking to be processed by Solr - await new Promise((resolve) => setTimeout(resolve, 5000)) - const collectionItemSubset2 = await getCollectionItems.execute(firstCollectionAlias) - expect(collectionItemSubset2.items.length).toBe(0) + const collectionLinksAfter = await getCollectionLinks.execute(firstCollectionId) + expect(collectionLinksAfter.linkedCollections).toStrictEqual([]) }) test('should throw an error when unlinking a non-existent collection', async () => { diff --git a/test/functional/collections/UpdateCollection.test.ts b/test/functional/collections/UpdateCollection.test.ts index ac5f47e9..56ed98d1 100644 --- a/test/functional/collections/UpdateCollection.test.ts +++ b/test/functional/collections/UpdateCollection.test.ts @@ -3,7 +3,8 @@ import { WriteError, createCollection, getCollection, - updateCollection + updateCollection, + CollectionDTO } from '../../../src' import { TestConstants } from '../../testHelpers/TestConstants' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -35,6 +36,28 @@ describe('execute', () => { } }) + test('should successfully update a collection with partial data (name only)', async () => { + const testNewCollectionAlias = 'updateCollection-partial-test' + const testNewCollection = createCollectionDTO(testNewCollectionAlias) + await createCollection.execute(testNewCollection) + + const partialUpdate: Partial<CollectionDTO> = { + name: 'Partially Updated Name' + } + + expect.assertions(3) + try { + await updateCollection.execute(testNewCollectionAlias, partialUpdate) + } catch (error) { + throw new Error('Collection should be updated with partial data') + } finally { + const updatedCollection = await getCollection.execute(testNewCollectionAlias) + expect(updatedCollection.name).toBe('Partially Updated Name') + expect(updatedCollection.alias).toBe(testNewCollectionAlias) + expect(updatedCollection.type).toBe(testNewCollection.type) + } + }) + test('should throw an error when the parent collection does not exist', async () => { const testNewCollection = createCollectionDTO() expect.assertions(2) diff --git a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts index 87d3ebec..b993e27e 100644 --- a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts +++ b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts @@ -18,7 +18,6 @@ import { CONTENT_FIELD_WITH_ALL_TAGS, createCollectionViaApi, deleteCollectionViaApi, - EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS, publishCollectionViaApi } from '../../testHelpers/collections/collectionHelper' import { @@ -27,6 +26,7 @@ import { FeaturedItemType } from '../../../src/collections/domain/models/FeaturedItem' import { uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer' import { deletePublishedDatasetViaApi, publishDatasetViaApi, @@ -165,7 +165,9 @@ describe('execute', () => { expect(secondItemResponse.imageFileUrl).toBeUndefined() expect(secondItemResponse.imageFileName).toBeUndefined() - expect(thirdItemResponse.content).toEqual(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS) + expect(normalizeHtml(thirdItemResponse.content)).toEqual( + normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS) + ) expect(thirdItemResponse.displayOrder).toBe(newFeaturedItems[2].displayOrder) expect(thirdItemResponse.imageFileName).toEqual('featured-item-test-image-3.png') expect(thirdItemResponse.imageFileUrl).toContain( diff --git a/test/functional/datasets/GetDataset.test.ts b/test/functional/datasets/GetDataset.test.ts index 771e432d..527b4286 100644 --- a/test/functional/datasets/GetDataset.test.ts +++ b/test/functional/datasets/GetDataset.test.ts @@ -93,6 +93,14 @@ describe('execute', () => { expect(dataset.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe('Terms of access') expect(dataset.termsOfUse.customTerms?.termsOfUse).toBe('Terms of use') }) + test('should return the template id when present', () => { + const versionPayload = createDatasetVersionPayload() + versionPayload.templateId = 3 + + const dataset = transformVersionPayloadToDataset(versionPayload, false) + + expect(dataset.templateId).toBe(3) + }) test('should return metadata fields in markdown format when keepRawFields is false', async () => { const createdDatasetIdentifiers = await createDataset.execute(testNewDataset) diff --git a/test/functional/datasets/GetDatasetAvailableDatasetTypes.test.ts b/test/functional/datasets/GetDatasetAvailableDatasetTypes.test.ts index 6f03c714..dbdc0819 100644 --- a/test/functional/datasets/GetDatasetAvailableDatasetTypes.test.ts +++ b/test/functional/datasets/GetDatasetAvailableDatasetTypes.test.ts @@ -14,18 +14,17 @@ describe('getDatasetAvailableDatasetTypes', () => { test('should return available dataset types', async () => { const actualDatasetTypes: DatasetType[] = await getDatasetAvailableDatasetTypes.execute() - const expectedDatasetTypes = [ - { - id: 1, - name: 'dataset', - linkedMetadataBlocks: [], - availableLicenses: [], - description: - 'A study, experiment, set of observations, or publication. A dataset can comprise a single file or multiple files.', - displayName: 'Dataset' - } - ] - expect(actualDatasetTypes).toEqual(expectedDatasetTypes) + const expectedDatasetType = { + id: 1, + name: 'dataset', + linkedMetadataBlocks: [], + availableLicenses: [], + description: + 'A study, experiment, set of observations, or publication. A dataset can comprise a single file or multiple files.', + displayName: 'Dataset' + } + + expect(actualDatasetTypes).toContainEqual(expectedDatasetType) }) }) }) diff --git a/test/functional/files/GetFileCitationByFormat.test.ts b/test/functional/files/GetFileCitationByFormat.test.ts new file mode 100644 index 00000000..39a67032 --- /dev/null +++ b/test/functional/files/GetFileCitationByFormat.test.ts @@ -0,0 +1,169 @@ +import { + ApiConfig, + createDataset, + CreatedDatasetIdentifiers, + FileCitationFormat, + getDatasetFiles, + getFileCitationByFormat, + publishDataset, + updateFileMetadata, + VersionUpdateType +} from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + createCollectionViaApi, + publishCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { + deletePublishedDatasetViaApi, + waitForNoLocks, + deleteUnpublishedDatasetViaApi +} from '../../testHelpers/datasets/datasetHelper' +import { uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('execute', () => { + const testCollectionAlias = 'getFileCitationByFormatFunctionalTest' + const testTextFile1Name = 'test-file-1.txt' + let testDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await createCollectionViaApi(testCollectionAlias) + + try { + testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + } catch (error) { + throw new Error('Tests beforeAll(): Error while creating test dataset') + } + + await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name).catch(() => { + throw new Error(`Tests beforeAll(): Error while uploading file ${testTextFile1Name}`) + }) + }) + + afterAll(async () => { + try { + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test dataset') + } + + try { + await deleteCollectionViaApi(testCollectionAlias) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test collection') + } + }) + + const getTestFileId = async (): Promise<number> => { + const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId) + return datasetFiles.files[0].id + } + + test('should successfully get file citation in EndNote (XML) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE) + + expect(typeof citation).toBe('string') + expect(citation.trimStart()).toMatch(/^<\?xml/) + }) + + test('should successfully get file citation in RIS (plain text) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.RIS) + + expect(typeof citation).toBe('string') + // RIS records use TY (type) and ER (end of record) tags + expect(citation).toMatch(/TY\s+-/) + expect(citation).toMatch(/ER\s+-/) + }) + + test('should successfully get file citation in BibTeX (plain text) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX) + + expect(typeof citation).toBe('string') + // BibTeX entries start with @<entry-type>{ + expect(citation.trimStart()).toMatch(/^@\w+\{/) + }) + + test('should successfully get file citation in CSL (JSON) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.CSL) + + expect(typeof citation).toBe('string') + const parsed = JSON.parse(citation) + expect(typeof parsed).toBe('object') + expect(parsed).not.toBeNull() + }) + + test('should successfully get file citation in Internal (HTML) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.INTERNAL) + + expect(typeof citation).toBe('string') + // Internal HTML format includes anchor tags linking to the dataset + expect(citation).toMatch(/<a\s+href=/i) + }) + + test('preserves historical citations after publishing renamed file metadata', async () => { + const dataset = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + try { + await uploadFileViaApi(dataset.numericId, testTextFile1Name) + const fileId = (await getDatasetFiles.execute(dataset.numericId)).files[0].id + await publishCollectionViaApi(testCollectionAlias) + await publishDataset.execute(dataset.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(dataset.numericId) + await updateFileMetadata.execute(fileId, { label: 'renamed-file.txt' }) + expect( + await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE, ':draft') + ).toContain('<custom1>renamed-file.txt</custom1>') + await publishDataset.execute(dataset.numericId, VersionUpdateType.MINOR) + await waitForNoLocks(dataset.numericId) + expect( + await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE, '1.1') + ).toContain('<custom1>renamed-file.txt</custom1>') + expect( + await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE, '1.0') + ).toContain(`<custom1>${testTextFile1Name}</custom1>`) + expect( + await getFileCitationByFormat.execute(fileId, FileCitationFormat.RIS, '1.0') + ).toContain(`C1 - ${testTextFile1Name}`) + await uploadFileViaApi(dataset.numericId, 'test-file-2.txt') + const newFile = (await getDatasetFiles.execute(dataset.numericId)).files.find( + (file) => file.id !== fileId + ) + if (!newFile) throw new Error('Newly uploaded file was not returned') + await expect( + getFileCitationByFormat.execute(newFile.id, FileCitationFormat.ENDNOTE, '1.0') + ).rejects.toThrow('[400] File not found in dataset version: 1.0') + } finally { + await deletePublishedDatasetViaApi(dataset.persistentId) + } + }) + + test('should throw an error when the file id does not exist', async () => { + const nonExistentFileId = 2147483647 + + await expect( + getFileCitationByFormat.execute(nonExistentFileId, FileCitationFormat.BIBTEX) + ).rejects.toThrow(/\[404\]/) + }) +}) diff --git a/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts b/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts index 44dc67f9..f4d0c76b 100644 --- a/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts +++ b/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts @@ -18,7 +18,7 @@ describe('execute', () => { } catch (error) { throw new Error('Should not raise an error') } finally { - expect(metadataFieldInfos?.length).toBe(64) + expect(metadataFieldInfos?.length).toBeGreaterThanOrEqual(64) expect(metadataFieldInfos?.[0].name).toBe('authorName') expect(metadataFieldInfos?.[0].displayName).toBe('Author Name') } diff --git a/test/functional/templates/UpdateTemplateLicenseTerms.test.ts b/test/functional/templates/UpdateTemplateLicenseTerms.test.ts new file mode 100644 index 00000000..6169e00b --- /dev/null +++ b/test/functional/templates/UpdateTemplateLicenseTerms.test.ts @@ -0,0 +1,42 @@ +import { ApiConfig } from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' +import { getTemplatesByCollectionId, updateTemplateLicenseTerms } from '../../../src/templates' +import { + createDatasetTemplateViaApi, + deleteDatasetTemplateViaApi +} from '../../testHelpers/datasets/datasetTemplatesHelper' + +describe('UpdateTemplateLicenseTerms.execute', () => { + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should update template terms of use using custom terms', async () => { + const createdTemplate = await createDatasetTemplateViaApi(':root') + + expect(createdTemplate).toBeDefined() + if (!createdTemplate) { + throw new Error('Created template could not be found') + } + + await updateTemplateLicenseTerms.execute(createdTemplate.id, { + customTerms: { + termsOfUse: 'Updated template terms of use' + } + }) + + const updatedTemplates = await getTemplatesByCollectionId.execute(':root') + const updatedTemplate = updatedTemplates.find((template) => template.id === createdTemplate.id) + + expect(updatedTemplate?.termsOfUse.customTerms?.termsOfUse).toBe( + 'Updated template terms of use' + ) + + await deleteDatasetTemplateViaApi(createdTemplate.id) + }) +}) diff --git a/test/functional/templates/UpdateTemplateMetadata.test.ts b/test/functional/templates/UpdateTemplateMetadata.test.ts new file mode 100644 index 00000000..71361f02 --- /dev/null +++ b/test/functional/templates/UpdateTemplateMetadata.test.ts @@ -0,0 +1,76 @@ +import { ApiConfig } from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' +import { updateTemplateMetadata } from '../../../src/templates' +import { UpdateTemplateMetadataDTO } from '../../../src/templates/domain/dtos/UpdateTemplateMetadataDTO' +import { MetadataFieldTypeClass } from '../../../src/metadataBlocks/domain/models/MetadataBlock' +import { getTemplatesByCollectionId } from '../../../src/templates' +import { + createDatasetTemplateViaApi, + deleteDatasetTemplateViaApi +} from '../../testHelpers/datasets/datasetTemplatesHelper' + +describe('UpdateTemplateMetadata.execute', () => { + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should update template name and instructions', async () => { + const updatedTemplateName = `template-metadata-updated-${Date.now()}` + + const createdTemplate = await createDatasetTemplateViaApi(':root') + + expect(createdTemplate).toBeDefined() + if (!createdTemplate) { + throw new Error('Created template could not be found') + } + + const updatePayload: UpdateTemplateMetadataDTO = { + name: updatedTemplateName, + fields: [ + { + typeName: 'author', + typeClass: MetadataFieldTypeClass.Compound, + multiple: true, + value: [ + { + authorName: { + typeName: 'authorName', + typeClass: MetadataFieldTypeClass.Primitive, + value: 'Kraffmiller, Ellen' + }, + authorAffiliation: { + typeName: 'authorIdentifierScheme', + typeClass: MetadataFieldTypeClass.Primitive, + value: 'ORCID' + } + } + ] + } + ], + instructions: [ + { + instructionField: 'author', + instructionText: 'Updated author instructions' + } + ] + } + + await updateTemplateMetadata.execute(createdTemplate.id, updatePayload, true) + + const updatedTemplates = await getTemplatesByCollectionId.execute(':root') + const updatedTemplate = updatedTemplates.find((template) => template.id === createdTemplate.id) + + expect(updatedTemplate?.name).toBe(updatedTemplateName) + expect(updatedTemplate?.instructions).toContainEqual({ + instructionField: 'author', + instructionText: 'Updated author instructions' + }) + + await deleteDatasetTemplateViaApi(createdTemplate.id) + }) +}) diff --git a/test/functional/templates/UpdateTemplateTermsOfAccess.test.ts b/test/functional/templates/UpdateTemplateTermsOfAccess.test.ts new file mode 100644 index 00000000..9ba8f18e --- /dev/null +++ b/test/functional/templates/UpdateTemplateTermsOfAccess.test.ts @@ -0,0 +1,42 @@ +import { ApiConfig } from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' +import { getTemplatesByCollectionId, updateTemplateTermsOfAccess } from '../../../src/templates' +import { + createDatasetTemplateViaApi, + deleteDatasetTemplateViaApi +} from '../../testHelpers/datasets/datasetTemplatesHelper' + +describe('UpdateTemplateTermsOfAccess.execute', () => { + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should update template terms of access', async () => { + const createdTemplate = await createDatasetTemplateViaApi(':root') + + expect(createdTemplate).toBeDefined() + if (!createdTemplate) { + throw new Error('Created template could not be found') + } + + await updateTemplateTermsOfAccess.execute(createdTemplate.id, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Restricted access only' + }) + + const updatedTemplates = await getTemplatesByCollectionId.execute(':root') + const updatedTemplate = updatedTemplates.find((template) => template.id === createdTemplate.id) + + expect(updatedTemplate?.termsOfUse.termsOfAccess.fileAccessRequest).toBe(true) + expect(updatedTemplate?.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe( + 'Restricted access only' + ) + + await deleteDatasetTemplateViaApi(createdTemplate.id) + }) +}) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 861a970e..0e30d5f2 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -33,6 +33,7 @@ import { deletePublishedDatasetViaApi, deleteUnpublishedDatasetViaApi, publishDatasetViaApi, + waitForDatasetsIndexedInSolr, waitForNoLocks } from '../../testHelpers/datasets/datasetHelper' import { PublicationStatus } from '../../../src/core/domain/models/PublicationStatus' @@ -110,6 +111,18 @@ describe('CollectionsRepository', () => { expect(actual.alias).toBe(ROOT_COLLECTION_ALIAS) expect(actual.isReleased).toBe(true) }) + + test('should return theme for root collection', async () => { + const actual = await sut.getCollection() + expect(actual.alias).toBe(ROOT_COLLECTION_ALIAS) + // Root collection might or might not have a theme, but the property should be present if it does + // and we want to ensure the transformer doesn't fail. + // In a default Dataverse installation, root theme is usually undefined or has some default values. + const hasNoThemeOrThemeWithId = + actual.theme === undefined || Object.prototype.hasOwnProperty.call(actual.theme, 'id') + + expect(hasNoThemeOrThemeWithId).toBe(true) + }) }) describe('by string alias', () => { test('should return collection when it exists filtering by id AS (alias)', async () => { @@ -176,6 +189,13 @@ describe('CollectionsRepository', () => { expect(actualAfterDatasetDeletion.childCount).toBe(0) await deleteCollectionViaApi(parentCollectionAlias) }) + + test('should transform allowedDatasetTypes correctly when retrieving a collection', async () => { + const actual = await sut.getCollection(testCollectionAlias) + expect( + actual.allowedDatasetTypes === undefined || Array.isArray(actual.allowedDatasetTypes) + ).toBe(true) + }) }) describe('publishCollection', () => { @@ -1110,6 +1130,27 @@ describe('CollectionsRepository', () => { expect(updatedInputLevel?.required).toBe(false) }) + test('should update collection with only partial fields (name and affiliation)', async () => { + const collectionDTO = createCollectionDTO('partial-update-test') + const testCollectionId = await sut.createCollection(collectionDTO) + const createdCollection = await sut.getCollection(testCollectionId) + const partialUpdate: Partial<CollectionDTO> = { + name: 'Partially Updated Name', + affiliation: 'New Affiliation' + } + + await sut.updateCollection(testCollectionId, partialUpdate) + const updatedCollection = await sut.getCollection(testCollectionId) + + expect(updatedCollection.name).toBe('Partially Updated Name') + expect(updatedCollection.affiliation).toBe('New Affiliation') + expect(updatedCollection.alias).toBe(createdCollection.alias) + expect(updatedCollection.type).toBe(createdCollection.type) + expect(updatedCollection.contacts).toEqual(createdCollection.contacts) + + await deleteCollectionViaApi(collectionDTO.alias) + }) + test('should update the collection to inherit metadata blocks from parent collection', async () => { const parentCollectionAlias = 'inherit-metablocks-parent-update' const parentCollectionDTO = createCollectionDTO(parentCollectionAlias) @@ -1699,13 +1740,15 @@ describe('CollectionsRepository', () => { describe('getMyDataCollectionItems', () => { let testDatasetIds: CreatedDatasetIdentifiers + let testSubCollectionCreated = false const testTextFile1Name = 'test-file-2.txt' const testSubCollectionAlias = 'collectionsRepositoryMyDataCollection' const testCollectionName = 'Scientific Research' + const testUserName = `myDataUser${Date.now()}` beforeAll(async () => { const createSuperUser = true - const myDataUserApiToken = await createApiTokenViaApi('myDataUser', createSuperUser) + const myDataUserApiToken = await createApiTokenViaApi(testUserName, createSuperUser) ApiConfig.init( TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, @@ -1722,6 +1765,7 @@ describe('CollectionsRepository', () => { `Tests beforeAll(): Error while creating subcollection ${testSubCollectionAlias}` ) }) + testSubCollectionCreated = true try { testDatasetIds = await createDataset.execute( TestConstants.TEST_NEW_DATASET_DTO, @@ -1736,24 +1780,27 @@ describe('CollectionsRepository', () => { }) afterAll(async () => { - try { - await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) - } catch (error) { - throw new Error( - `Tests afterAll(): Error while deleting test dataset ${testDatasetIds.numericId}` - ) + if (testDatasetIds) { + try { + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + } catch (error) { + throw new Error( + `Tests afterAll(): Error while deleting test dataset ${testDatasetIds.numericId}` + ) + } } - try { - await deleteCollectionViaApi(testSubCollectionAlias) - } catch (error) { - throw new Error( - `Tests afterAll(): Error while deleting subcollection ${testSubCollectionAlias}` - ) + if (testSubCollectionCreated) { + try { + await deleteCollectionViaApi(testSubCollectionAlias) + } catch (error) { + throw new Error( + `Tests afterAll(): Error while deleting subcollection ${testSubCollectionAlias}` + ) + } } }) test('should return collection items given valid roleIds', async () => { - // Give enough time to Solr for indexing - await new Promise((resolve) => setTimeout(resolve, 5000)) + await waitForDatasetsIndexedInSolr(1, testSubCollectionAlias) await getCollection.execute(testSubCollectionAlias).then((collection) => { expect(collection).toBeDefined() expect(collection.name).toBe('Test Collection') @@ -2014,12 +2061,25 @@ describe('CollectionsRepository', () => { expect(actual.countPerObjectType.files).toBe(1) }) - test('should return error when role, type and publication status params are empty', async () => { - const expectedError = new ReadError('No results. Please select at least one Role.') - - await expect( - sut.getMyDataCollectionItems([], [], [], 0, 0, undefined, undefined) - ).rejects.toThrow(expectedError) + test('should return an empty item subset when role, type and publication status params are empty', async () => { + const actual = await sut.getMyDataCollectionItems([], [], [], 0, 0, undefined, undefined) + + expect(actual).toStrictEqual({ + items: [], + publicationStatusCounts: [ + { publicationStatus: PublicationStatus.Published, count: 0 }, + { publicationStatus: PublicationStatus.Unpublished, count: 0 }, + { publicationStatus: PublicationStatus.Draft, count: 0 }, + { publicationStatus: PublicationStatus.InReview, count: 0 }, + { publicationStatus: PublicationStatus.Deaccessioned, count: 0 } + ], + totalItemCount: 0, + countPerObjectType: { + collections: 0, + datasets: 0, + files: 0 + } + }) }) }) describe('linkCollection', () => { @@ -2083,12 +2143,16 @@ describe('CollectionsRepository', () => { const firstCollection = await sut.getCollection(firstCollectionAlias) const secondCollection = await sut.getCollection(secondCollectionAlias) + const collectionLinksBefore = await sut.getCollectionLinks(firstCollection.id) + expect( + collectionLinksBefore.linkedCollections.map((collection) => collection.alias) + ).toContain(secondCollection.alias) + await sut.unlinkCollection(secondCollection.id, firstCollection.id) - await new Promise((res) => setTimeout(res, 2000)) await sut.getCollection(secondCollectionAlias) - const collectionItemSubset = await sut.getCollectionItems(firstCollection.alias) - expect(collectionItemSubset.items).toStrictEqual([]) + const collectionLinksAfter = await sut.getCollectionLinks(firstCollection.id) + expect(collectionLinksAfter.linkedCollections).toStrictEqual([]) }) test('should throw error when unlinking a non-existent collection', async () => { @@ -2158,4 +2222,74 @@ describe('CollectionsRepository', () => { await expect(sut.getCollectionLinks(invalidCollectionId)).rejects.toThrow(expectedError) }) }) + + describe('collection storage drivers', () => { + const parentCollectionAlias = 'collectionStorageDriverParent' + const childCollectionAlias = 'collectionStorageDriverChild' + const standaloneCollectionAlias = 'collectionStorageDriverStandalone' + + beforeAll(async () => { + await createCollectionViaApi(parentCollectionAlias) + await createCollectionViaApi(childCollectionAlias, parentCollectionAlias) + await createCollectionViaApi(standaloneCollectionAlias) + }) + + afterAll(async () => { + await deleteCollectionViaApi(childCollectionAlias) + await deleteCollectionViaApi(parentCollectionAlias) + await deleteCollectionViaApi(standaloneCollectionAlias) + }) + + test('should return the directly assigned collection storage driver', async () => { + await sut.setCollectionStorageDriver(standaloneCollectionAlias, 'LocalStack') + + const storageDriver = await sut.getCollectionStorageDriver(standaloneCollectionAlias) + + expect(storageDriver.name).toBe('localstack1') + expect(storageDriver.label).toBe('LocalStack') + expect(storageDriver.type).toBe('s3') + expect(storageDriver.directUpload).toBe(true) + expect(storageDriver.directDownload).toBe(true) + expect(storageDriver.uploadOutOfBand).toBe(false) + }) + + test('should return the effective collection storage driver through inheritance', async () => { + await sut.setCollectionStorageDriver(parentCollectionAlias, 'LocalStack') + + const storageDriver = await sut.getCollectionStorageDriver(childCollectionAlias, true) + + expect(storageDriver.name).toBe('localstack1') + expect(storageDriver.label).toBe('LocalStack') + expect(storageDriver.type).toBe('s3') + }) + + test('should set and then clear the collection storage driver', async () => { + const setMessage = await sut.setCollectionStorageDriver( + standaloneCollectionAlias, + 'LocalStack' + ) + expect(setMessage).toContain('LocalStack') + + const assignedDriver = await sut.getCollectionStorageDriver(standaloneCollectionAlias) + expect(assignedDriver.name).toBe('localstack1') + + const deleteMessage = await sut.deleteCollectionStorageDriver(standaloneCollectionAlias) + expect(deleteMessage.toLowerCase()).toContain('local') + + const effectiveDriver = await sut.getCollectionStorageDriver(standaloneCollectionAlias, true) + expect(effectiveDriver.name).toBe('local') + expect(effectiveDriver.label).toBe('Local') + expect(effectiveDriver.type).toBe('file') + }) + + test('should return the allowed collection storage drivers', async () => { + const allowedDrivers = await sut.getAllowedCollectionStorageDrivers(standaloneCollectionAlias) + + expect(allowedDrivers).toMatchObject({ + LocalStack: 'localstack1', + Local: 'local', + Filesystem: 'file1' + }) + }) + }) }) diff --git a/test/integration/collections/SetDefaultContributorRole.test.ts b/test/integration/collections/SetDefaultContributorRole.test.ts new file mode 100644 index 00000000..79cc9be2 --- /dev/null +++ b/test/integration/collections/SetDefaultContributorRole.test.ts @@ -0,0 +1,57 @@ +import { SetDefaultContributorRole } from '../../../src/collections/domain/useCases/SetDefaultContributorRole' +import { CollectionsRepository } from '../../../src/collections/infra/repositories/CollectionsRepository' +import { ApiConfig } from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + createCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { RoleAlias } from '../../../src/roles/domain/models/RoleAlias' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' + +describe('SetDefaultContributorRole', () => { + const collectionsRepository = new CollectionsRepository() + const useCase = new SetDefaultContributorRole(collectionsRepository) + const testCollectionAlias = 'setDefaultContributorRoleTestCollection' + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await createCollectionViaApi(testCollectionAlias) + }) + + afterAll(async () => { + await deleteCollectionViaApi(testCollectionAlias) + }) + + test('should successfully set the default contributor role', async () => { + const roleAlias = RoleAlias.CURATOR + + await expect(useCase.execute(testCollectionAlias, roleAlias)).resolves.toBeUndefined() + }) + + test('should successfully set the default contributor role for the root collection', async () => { + const roleAlias = RoleAlias.CURATOR + + await expect(useCase.execute(undefined, roleAlias)).resolves.toBeUndefined() + }) + + test('should throw an error when the collection does not exist', async () => { + const nonExistentCollection = 'nonExistentCollection' + const roleAlias = RoleAlias.CURATOR + + await expect(useCase.execute(nonExistentCollection, roleAlias)).rejects.toThrow(WriteError) + }) + + test('should throw an error when the role alias does not exist', async () => { + const nonExistentRoleAlias = 'invalidRoleAlias' + + await expect(useCase.execute(testCollectionAlias, nonExistentRoleAlias)).rejects.toThrow( + WriteError + ) + }) +}) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 50077a4f..cb3cec95 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -11,7 +11,8 @@ import { deaccessionDatasetViaApi, createDatasetLicenseModel, setDatasetStorageSizeViaApi, - setUseStorageQuotasViaApi + setUseStorageQuotasViaApi, + loadMetadataBlockViaApi } from '../../testHelpers/datasets/datasetHelper' import { ReadError } from '../../../src/core/domain/repositories/ReadError' import { @@ -32,7 +33,9 @@ import { linkDatasetTypeWithMetadataBlocks, setAvailableLicensesForDatasetType, updateTermsOfAccess, - DatasetLicenseUpdateRequest + DatasetLicenseUpdateRequest, + getDatasetReviews, + DatasetReview } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -49,7 +52,8 @@ import { deleteCollectionViaApi, publishCollectionViaApi, ROOT_COLLECTION_ALIAS, - setStorageDriverViaApi + setStorageDriverViaApi, + setCollectionAllowedDatasetTypesViaApi } from '../../testHelpers/collections/collectionHelper' import { calculateBlobChecksum, @@ -61,6 +65,10 @@ import { DatasetVersionSummary, DatasetVersionSummaryStringValues } from '../../../src/datasets/domain/models/DatasetVersionSummaryInfo' +import { + replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker, + solrSchemaFieldExistsViaDocker +} from '../../testHelpers/search/solrHelper' import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' import { createTestFileUploadDestination } from '../../testHelpers/files/fileUploadDestinationHelper' @@ -104,6 +112,157 @@ const TEST_DIFF_DATASET_DTO: DatasetDTO = { ] } +const createReviewDatasetDTO = (itemReviewedUrl: string): DatasetDTO => ({ + license: TestConstants.TEST_NEW_DATASET_DTO.license, + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: 'Review of Dataset with a review', + author: [ + { + authorName: 'Reviewer, Dataverse', + authorAffiliation: 'Dataverse.org' + } + ], + datasetContact: [ + { + datasetContactEmail: 'reviewer@mailinator.com', + datasetContactName: 'Reviewer, Dataverse' + } + ], + dsDescription: [ + { + dsDescriptionValue: 'This is a review of a dataset.' + } + ], + subject: ['Medicine, Health and Life Sciences'] + } + }, + { + name: 'review', + fields: { + itemReviewed: { + itemReviewedUrl, + itemReviewedType: 'Dataset', + itemReviewedCitation: 'Dataset with a review, Dataverse, 2026' + } + } + }, + { + name: RUBRIC_METADATA_BLOCK_NAME, + fields: { + authorAndProvenance: 'High' + } + } + ] +}) + +const getPersistentIdUrl = (persistentId: string): string => { + return persistentId.startsWith('doi:') + ? `https://doi.org/${persistentId.replace(/^doi:/, '')}` + : persistentId +} + +const REVIEW_METADATA_BLOCK_TSV = [ + '#metadataBlock\tname\tdataverseAlias\tdisplayName', + '\treview\t\tReview Metadata', + [ + '#datasetField', + 'name', + 'title', + 'description', + 'watermark', + 'fieldType', + 'displayOrder', + 'displayFormat', + 'advancedSearchField', + 'allowControlledVocabulary', + 'allowmultiples', + 'facetable', + 'displayoncreate', + 'required', + 'parent', + 'metadatablock_id', + 'termURI' + ].join('\t'), + '\titemReviewed\tItem Reviewed\tThe item being reviewed\t\tnone\t1\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\t\treview\t', + '\titemReviewedUrl\tURL\tThe URL of the item being reviewed\t\turl\t2\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '\titemReviewedType\tType\tThe type of the item being reviewed\t\ttext\t3\t\tFALSE\tTRUE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '\titemReviewedCitation\tCitation\tThe full bibliographic citation of the item being reviewed\t\ttextbox\t4\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '#controlledVocabulary\tDatasetField\tValue\tidentifier\tdisplayOrder', + '\titemReviewedType\tAudiovisual\t\t0', + '\titemReviewedType\tAward\t\t1', + '\titemReviewedType\tBook\t\t2', + '\titemReviewedType\tBook Chapter\t\t3', + '\titemReviewedType\tCollection\t\t4', + '\titemReviewedType\tComputational Notebook\t\t5', + '\titemReviewedType\tConference Paper\t\t6', + '\titemReviewedType\tConference Proceeding\t\t7', + '\titemReviewedType\tDataPaper\t\t8', + '\titemReviewedType\tDataset\t\t9', + '\titemReviewedType\tDissertation\t\t10', + '\titemReviewedType\tEvent\t\t11', + '\titemReviewedType\tImage\t\t12', + '\titemReviewedType\tInteractive Resource\t\t13', + '\titemReviewedType\tInstrument\t\t14', + '\titemReviewedType\tJournal\t\t15', + '\titemReviewedType\tJournal Article\t\t16', + '\titemReviewedType\tModel\t\t17', + '\titemReviewedType\tOutput Management Plan\t\t18', + '\titemReviewedType\tPeer Review\t\t19', + '\titemReviewedType\tPhysical Object\t\t20', + '\titemReviewedType\tPreprint\t\t21', + '\titemReviewedType\tProject\t\t22', + '\titemReviewedType\tReport\t\t23', + '\titemReviewedType\tService\t\t24', + '\titemReviewedType\tSoftware\t\t25', + '\titemReviewedType\tSound\t\t26', + '\titemReviewedType\tStandard\t\t27', + '\titemReviewedType\tStudy Registration\t\t28', + '\titemReviewedType\tText\t\t29', + '\titemReviewedType\tWorkflow\t\t30', + '\titemReviewedType\tOther\t\t31' +].join('\n') + +const RUBRIC_METADATA_BLOCK_NAME = 'rubric_trusteddatadimensionsintensities' +const RUBRIC_METADATA_BLOCK_TSV = [ + '#metadataBlock\tname\tdataverseAlias\tdisplayName\tblockURI', + `\t${RUBRIC_METADATA_BLOCK_NAME}\t\tTrusted Data Dimensions and Intensities\t`, + [ + '#datasetField', + 'name', + 'title', + 'description', + 'watermark', + 'fieldType', + 'displayOrder', + 'displayFormat', + 'advancedSearchField', + 'allowControlledVocabulary', + 'allowmultiples', + 'facetable', + 'displayoncreate', + 'required', + 'parent', + 'metadatablock_id', + 'termURI' + ].join('\t'), + `\tauthorAndProvenance\tAuthor and Provenance\tThe level of trust in the data creators and in other provenance information\t\ttext\t1\t\tTRUE\tTRUE\tFALSE\tTRUE\tFALSE\tFALSE\t\t${RUBRIC_METADATA_BLOCK_NAME}\t`, + '#controlledVocabulary\tDatasetField\tValue\tidentifier\tdisplayOrder', + '\tauthorAndProvenance\tLow\t\t0', + '\tauthorAndProvenance\tMedium\t\t1', + '\tauthorAndProvenance\tHigh\t\t2' +].join('\n') + +const REVIEW_SOLR_SCHEMA_FIELD_NAMES = [ + 'itemReviewed', + 'itemReviewedCitation', + 'itemReviewedType', + 'itemReviewedUrl', + 'authorAndProvenance' +] + describe('DatasetsRepository', () => { const testCollectionAlias = 'datasetsRepositoryTestCollection' @@ -613,6 +772,43 @@ describe('DatasetsRepository', () => { }) }) + describe('exportDatasetMetadata', () => { + let draftDatasetIds: CreatedDatasetIdentifiers + let publishedDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + draftDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + publishedDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(publishedDatasetIds.numericId) + await waitForNoLocks(publishedDatasetIds.numericId, 10) + }) + + afterAll(async () => { + await deleteUnpublishedDatasetViaApi(draftDatasetIds.numericId) + await deletePublishedDatasetViaApi(publishedDatasetIds.persistentId) + }) + + test('should export latest published dataset metadata in DDI format by default', async () => { + const metadata = await sut.exportDatasetMetadata(publishedDatasetIds.persistentId, 'ddi') + + expect(typeof metadata.content).toBe('string') + expect(metadata.content.length).toBeGreaterThan(0) + expect(metadata.contentType).toMatch(/xml/) + }) + + test('should export draft dataset metadata in DDI format', async () => { + const metadata = await sut.exportDatasetMetadata( + draftDatasetIds.numericId, + 'ddi', + DatasetNotNumberedVersion.DRAFT + ) + + expect(typeof metadata.content).toBe('string') + expect(metadata.content.length).toBeGreaterThan(0) + expect(metadata.contentType).toMatch(/xml/) + }) + }) + describe('getDatasetVersionDiff', () => { let testDatasetIds: CreatedDatasetIdentifiers @@ -1554,6 +1750,241 @@ describe('DatasetsRepository', () => { }, 180000) }) + describe('getDatasetVersions', () => { + const testDatasetVersionsCollectionAlias = 'testDatasetVersionsCollection' + + beforeAll(async () => { + await createCollectionViaApi(testDatasetVersionsCollectionAlias) + await publishCollectionViaApi(testDatasetVersionsCollectionAlias) + await setStorageDriverViaApi(testDatasetVersionsCollectionAlias, 'LocalStack') + }) + + afterAll(async () => { + await deleteCollectionViaApi(testDatasetVersionsCollectionAlias) + }) + + test('should return version fields and metadata blocks, and exclude metadata blocks when requested', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + const testLicense = TestConstants.TEST_NEW_DATASET_DTO.license + if (testLicense === undefined) { + throw new Error('The test dataset must define a license.') + } + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + const actual = await sut.getDatasetVersions(testDatasetIds.persistentId) + const version = actual.versions[0] + + expect(actual.versions).toHaveLength(1) + expect(version).toEqual( + expect.objectContaining({ + id: testDatasetIds.numericId, + persistentId: testDatasetIds.persistentId, + versionId: expect.any(Number), + versionInfo: expect.objectContaining({ + majorNumber: 1, + minorNumber: 0, + state: 'RELEASED', + lastUpdateTime: expect.any(String), + releaseTime: expect.any(Date), + createTime: expect.any(Date) + }), + internalVersionNumber: expect.any(Number), + license: expect.objectContaining({ + name: testLicense.name, + uri: testLicense.uri, + iconUri: testLicense.iconUri + }), + publicationDate: expect.any(String), + citationDate: expect.any(String), + metadataBlocks: expect.arrayContaining([ + { + name: 'citation', + fields: expect.objectContaining({ + title: TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.title, + subject: TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.subject + }) + } + ]), + datasetType: 'dataset' + }) + ) + + const excludedMetadataBlocks = await sut.getDatasetVersions( + testDatasetIds.persistentId, + undefined, + undefined, + true + ) + + expect(excludedMetadataBlocks.versions).toHaveLength(1) + expect(excludedMetadataBlocks.versions[0].metadataBlocks).toBeUndefined() + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }, 180000) + + test('should return draft dataset versions correctly', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toBeGreaterThan(0) + expect(actual.versions[0].versionInfo.state).toBe('DRAFT') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should return deaccessioned dataset versions correctly', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + await deaccessionDatasetViaApi(testDatasetIds.numericId, '1.0') + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toBeGreaterThan(0) + expect(actual.versions[0].versionInfo.majorNumber).toBe(1) + expect(actual.versions[0].versionInfo.minorNumber).toBe(0) + expect(actual.versions[0].versionInfo.state).toBe('DEACCESSIONED') + expect(actual.versions[0].versionInfo.deaccessionNote).toBe('Test reason.') + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + test('should return dataset versions correctly after 1st publish and metadata fields update', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + const metadataBlocksRepository = new MetadataBlocksRepository() + const citationMetadataBlock = await metadataBlocksRepository.getMetadataBlockByName( + 'citation' + ) + + await sut.updateDataset( + testDatasetIds.numericId, + { + license: createDatasetLicenseModel(true), + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: 'Updated Dataset Title' + } + } + ] + }, + [citationMetadataBlock] + ) + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toEqual(2) + expect(actual.versions[0].versionInfo.state).toBe('DRAFT') + expect(actual.versions[0].metadataBlocks?.[0].fields.title).toBe('Updated Dataset Title') + + expect(actual.versions[1].versionInfo.majorNumber).toBe(1) + expect(actual.versions[1].versionInfo.minorNumber).toBe(0) + expect(actual.versions[1].versionInfo.state).toBe('RELEASED') + expect(actual.versions[1].metadataBlocks?.[0].fields.title).toBe( + TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.title + ) + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + test('should return dataset versions with pagination', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const metadataBlocksRepository = new MetadataBlocksRepository() + const citationMetadataBlock = await metadataBlocksRepository.getMetadataBlockByName( + 'citation' + ) + + for (let i = 1; i <= 21; i++) { + await sut.updateDataset( + testDatasetIds.numericId, + { + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: `Updated Dataset Title - Version ${i}` + } + } + ] + }, + [citationMetadataBlock] + ) + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MINOR) + await waitForNoLocks(testDatasetIds.numericId, 10) + } + + const firstPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 0) + + expect(firstPage.versions.length).toBe(5) + expect(firstPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(firstPage.versions[0].versionInfo.minorNumber).toBe(21) + expect(firstPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(firstPage.versions[4].versionInfo.minorNumber).toBe(17) + + // Test pagination with limit=5, offset=5 (second page) + const secondPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 5) + expect(secondPage.versions.length).toBe(5) + expect(secondPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(secondPage.versions[0].versionInfo.minorNumber).toBe(16) + expect(secondPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(secondPage.versions[4].versionInfo.minorNumber).toBe(12) + + // Test pagination with limit=5, offset=10 (third page) + const thirdPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 10) + expect(thirdPage.versions.length).toBe(5) + expect(thirdPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(thirdPage.versions[0].versionInfo.minorNumber).toBe(11) + expect(thirdPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(thirdPage.versions[4].versionInfo.minorNumber).toBe(7) + + // Test that all versions are returned without pagination + const allVersions = await sut.getDatasetVersions(testDatasetIds.numericId) + expect(allVersions.versions.length).toBe(22) // 1 initial + 21 updates + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }, 180000) + + test('should return error when dataset does not exist', async () => { + const expectedError = new ReadError( + `[404] Dataset with ID ${nonExistentTestDatasetId} not found.` + ) + + await expect(sut.getDatasetVersions(nonExistentTestDatasetId)).rejects.toThrow(expectedError) + }) + }) + describe('getDatasetDownloadCount', () => { const testGetDatasetDownloadCountCollectionAlias = 'testGetDatasetDownloadCountCollection' let testDatasetIds: CreatedDatasetIdentifiers @@ -1936,6 +2367,209 @@ describe('DatasetsRepository', () => { }) }) + describe('getDatasetReviews use case', () => { + const collectionAlias = `datasetReviews${randomUUID().replace(/-/g, '').slice(0, 8)}` + const reviewDatasetTypeName = 'review' + let targetDatasetIds: CreatedDatasetIdentifiers | undefined + let reviewDatasetIds: CreatedDatasetIdentifiers | undefined + let reviewDatasetTypeCreatedByTest = false + let reviewDatasetTypeIdCreatedByTest: number | undefined + let collectionCreated = false + + beforeAll(async () => { + await assertDatasetReviewsEndpointAvailable() + await ensureReviewMetadataBlocksExist() + await ensureReviewSolrSchemaFieldsExist() + await ensureReviewDatasetTypeExists() + await createCollectionViaApi(collectionAlias) + collectionCreated = true + await setCollectionAllowedDatasetTypesViaApi(collectionAlias, [ + defaultDatasetType, + reviewDatasetTypeName + ]) + await publishCollectionViaApi(collectionAlias) + + targetDatasetIds = await createDataset.execute( + { + ...TestConstants.TEST_NEW_DATASET_DTO, + metadataBlockValues: [ + { + ...TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0], + fields: { + ...TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields, + title: 'Dataset with a review' + } + } + ] + }, + collectionAlias + ) + await publishDatasetViaApi(targetDatasetIds.numericId) + await waitForNoLocks(targetDatasetIds.numericId, 10) + + reviewDatasetIds = await createDataset.execute( + createReviewDatasetDTO(getPersistentIdUrl(targetDatasetIds.persistentId)), + collectionAlias, + reviewDatasetTypeName + ) + await publishDatasetViaApi(reviewDatasetIds.numericId) + await waitForNoLocks(reviewDatasetIds.numericId, 10) + await waitForDatasetsIndexedInSolr(2, collectionAlias) + }) + + afterAll(async () => { + if (reviewDatasetIds) { + await deletePublishedDatasetViaApi(reviewDatasetIds.persistentId).catch(() => + deleteUnpublishedDatasetViaApi(reviewDatasetIds?.numericId as number).catch( + () => undefined + ) + ) + } + if (targetDatasetIds) { + await deletePublishedDatasetViaApi(targetDatasetIds.persistentId).catch(() => + deleteUnpublishedDatasetViaApi(targetDatasetIds?.numericId as number).catch( + () => undefined + ) + ) + } + if (collectionCreated) { + await deleteCollectionViaApi(collectionAlias).catch(() => undefined) + } + + if (reviewDatasetTypeCreatedByTest) { + await deleteDatasetType + .execute(reviewDatasetTypeIdCreatedByTest as number) + .catch(() => undefined) + } + }) + + test('should return reviews when providing a dataset persistent id', async () => { + const actual = await waitForDatasetReviews(targetDatasetIds?.persistentId as string) + + expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) + }) + + test('should return reviews when providing a numeric dataset id', async () => { + const actual = await waitForDatasetReviews(targetDatasetIds?.numericId as number) + + expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) + }) + + const assertDatasetReviewsEndpointAvailable = async (): Promise<void> => { + if (!(await isDatasetReviewsEndpointAvailable())) { + throw new Error('Expected Dataverse test server to expose /api/datasets/{id}/reviews.') + } + } + + const isDatasetReviewsEndpointAvailable = async (): Promise<boolean> => { + try { + await sut.getDatasetReviews(nonExistentTestDatasetId) + return true + } catch (error) { + return !(error as Error).message.includes('API endpoint does not exist') + } + } + + const isMetadataBlockAvailable = async (metadataBlockName: string): Promise<boolean> => { + const metadataBlocksRepository = new MetadataBlocksRepository() + + try { + await metadataBlocksRepository.getMetadataBlockByName(metadataBlockName) + return true + } catch { + return false + } + } + + const ensureReviewMetadataBlocksExist = async (): Promise<void> => { + await ensureMetadataBlockExists('review', REVIEW_METADATA_BLOCK_TSV) + await ensureMetadataBlockExists(RUBRIC_METADATA_BLOCK_NAME, RUBRIC_METADATA_BLOCK_TSV) + } + + const ensureMetadataBlockExists = async ( + metadataBlockName: string, + metadataBlockTsv: string + ): Promise<void> => { + if (await isMetadataBlockAvailable(metadataBlockName)) { + return + } + + await loadMetadataBlockViaApi(metadataBlockTsv) + + if (!(await isMetadataBlockAvailable(metadataBlockName))) { + throw new Error(`${metadataBlockName} metadata block was loaded but is still unavailable.`) + } + } + + const ensureReviewSolrSchemaFieldsExist = async (): Promise<void> => { + if (await reviewSolrSchemaFieldsExist()) { + return + } + + await replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker() + + if (!(await reviewSolrSchemaFieldsExist())) { + throw new Error('Solr schema was regenerated but review metadata fields are unavailable.') + } + } + + const reviewSolrSchemaFieldsExist = async (): Promise<boolean> => { + const fieldExists = await Promise.all( + REVIEW_SOLR_SCHEMA_FIELD_NAMES.map((fieldName) => solrSchemaFieldExistsViaDocker(fieldName)) + ) + + return fieldExists.every(Boolean) + } + + const ensureReviewDatasetTypeExists = async () => { + const reviewDatasetType = await getDatasetAvailableDatasetType + .execute(reviewDatasetTypeName) + .catch(async () => { + reviewDatasetTypeCreatedByTest = true + return await addDatasetType.execute({ + name: reviewDatasetTypeName, + displayName: 'Review', + description: 'A review of a dataset compiled by the expert community.', + linkedMetadataBlocks: [], + availableLicenses: [] + }) + }) + + reviewDatasetTypeIdCreatedByTest = reviewDatasetType.id + + await linkDatasetTypeWithMetadataBlocks.execute(reviewDatasetType.id as number, [ + 'review', + RUBRIC_METADATA_BLOCK_NAME + ]) + } + + const waitForDatasetReviews = async ( + datasetId: number | string, + maxRetries = 10 + ): Promise<DatasetReview[]> => { + for (let retry = 0; retry < maxRetries; retry++) { + const reviews = await getDatasetReviews.execute(datasetId) + + if (reviews.some((review) => review.id === reviewDatasetIds?.numericId)) { + return reviews + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + return await getDatasetReviews.execute(datasetId) + } + + const getExpectedReviewDataset = (): Partial<DatasetReview> => ({ + id: reviewDatasetIds?.numericId, + persistentId: reviewDatasetIds?.persistentId, + persistentIdUrl: getPersistentIdUrl(reviewDatasetIds?.persistentId as string), + title: 'Review of Dataset with a review', + authors: ['Reviewer, Dataverse'], + description: 'This is a review of a dataset.' + }) + }) + describe('updateTermsOfAccess', () => { let testDatasetIds: CreatedDatasetIdentifiers diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 6e0c9bbc..0231f6d6 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -29,7 +29,19 @@ import { } from '../../../src/datasets' import { FileModel } from '../../../src/files/domain/models/FileModel' import { FileCounts } from '../../../src/files/domain/models/FileCounts' -import { FileDownloadSizeMode, WriteError } from '../../../src' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' +import { + FileDownloadSizeMode, + WriteError, + VersionUpdateType, + deaccessionDataset, + restrictFile, + getDatasetFiles, + getMaxEmbargoDurationInMonths, + publishDataset, + updateFileMetadata +} from '../../../src' +import { createBuiltInUser } from '../../testHelpers/users/builtinUserApiHelper' import { deaccessionDatasetViaApi, publishDatasetViaApi, @@ -656,6 +668,259 @@ describe('FilesRepository', () => { }) }) + describe('getFileCitationByFormat', () => { + describe('version-specific file citations', () => { + const alias = `citationVersion${Date.now()}` + const originalLabel = 'test-file-1.txt' + const draftLabel = 'renamed-citation-file.txt' + let dataset: CreatedDatasetIdentifiers + let fileId: number + let persistentId: string + let otherUserKey: string + + const authenticate = (key?: string) => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, key) + } + + beforeAll(async () => { + authenticate(process.env.TEST_API_KEY) + await createCollectionViaApi(alias) + await publishCollectionViaApi(alias) + dataset = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO, alias) + await uploadFileViaApi(dataset.numericId, originalLabel) + const files = await getDatasetFiles.execute(dataset.numericId) + fileId = files.files[0].id + await registerFileViaApi(fileId) + persistentId = (await getDatasetFiles.execute(dataset.numericId)).files[0].persistentId + await publishDataset.execute(dataset.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(dataset.numericId) + await updateFileMetadata.execute(fileId, { label: draftLabel }) + otherUserKey = await createBuiltInUser(`citationReader${Date.now()}`) + }) + + beforeEach(() => authenticate(process.env.TEST_API_KEY)) + afterEach(() => authenticate(process.env.TEST_API_KEY)) + + afterAll(async () => { + authenticate(process.env.TEST_API_KEY) + if (dataset) await deletePublishedDatasetViaApi(dataset.persistentId) + await deleteCollectionViaApi(alias) + }) + + test('selects archived metadata and rejects a deaccessioned version', async () => { + const lifecycleDataset = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + alias + ) + try { + await uploadFileViaApi(lifecycleDataset.numericId, originalLabel) + const lifecycleFileId = (await getDatasetFiles.execute(lifecycleDataset.numericId)) + .files[0].id + await publishDataset.execute(lifecycleDataset.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(lifecycleDataset.numericId) + await updateFileMetadata.execute(lifecycleFileId, { label: draftLabel }) + await publishDataset.execute(lifecycleDataset.numericId, VersionUpdateType.MINOR) + await waitForNoLocks(lifecycleDataset.numericId) + expect( + await sut.getFileCitationByFormat(lifecycleFileId, FileCitationFormat.ENDNOTE, '1.0') + ).toContain(`<custom1>${originalLabel}</custom1>`) + await deaccessionDataset.execute(lifecycleDataset.numericId, '1.0', { + deaccessionReason: 'Test version-specific citations' + }) + await expect( + sut.getFileCitationByFormat(lifecycleFileId, FileCitationFormat.ENDNOTE, '1.0') + ).rejects.toThrow('[400] Dataset version not found: 1.0') + } finally { + await deletePublishedDatasetViaApi(lifecycleDataset.persistentId) + } + }) + + test.each([ + { state: 'restricted', restricted: true, embargoed: false }, + { state: 'embargoed', restricted: false, embargoed: true }, + { state: 'embargoed and restricted', restricted: true, embargoed: true } + ])('requires file access for $state file citations', async ({ restricted, embargoed }) => { + const embargoSetting = '/admin/settings/:MaxEmbargoDurationInMonths' + let originalEmbargoDuration: number | undefined + let embargoSettingChanged = false + const restrictedDataset = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + alias + ) + try { + await uploadFileViaApi(restrictedDataset.numericId, originalLabel) + const restrictedFileId = (await getDatasetFiles.execute(restrictedDataset.numericId)) + .files[0].id + if (restricted) { + await restrictFile.execute(restrictedFileId, { + restrict: true, + enableAccessRequest: true + }) + } + if (embargoed) { + try { + originalEmbargoDuration = await getMaxEmbargoDurationInMonths.execute() + } catch (error) { + if (!(error instanceof ReadError) || !error.message.includes('[404]')) throw error + } + await sut.doPut(embargoSetting, '1') + embargoSettingChanged = true + const dateAvailable = new Date() + dateAvailable.setUTCDate(dateAvailable.getUTCDate() + 7) + // No public SDK use case currently sets file embargoes. + await sut.doPost( + `/datasets/${restrictedDataset.numericId}/files/actions/:set-embargo`, + { + fileIds: [restrictedFileId], + dateAvailable: dateAvailable.toISOString().slice(0, 10), + reason: 'Test version-specific citations' + } + ) + } + await publishDataset.execute(restrictedDataset.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(restrictedDataset.numericId) + expect( + await sut.getFileCitationByFormat(restrictedFileId, FileCitationFormat.ENDNOTE, '1.0') + ).toContain(`<custom1>${originalLabel}</custom1>`) + authenticate(otherUserKey) + await expect( + sut.getFileCitationByFormat(restrictedFileId, FileCitationFormat.ENDNOTE, '1.0') + ).rejects.toThrow(/\[403\]/) + } finally { + authenticate(process.env.TEST_API_KEY) + try { + await deletePublishedDatasetViaApi(restrictedDataset.persistentId) + } finally { + if (embargoSettingChanged) { + if (originalEmbargoDuration === undefined) { + await sut.doDelete(embargoSetting) + } else { + await sut.doPut(embargoSetting, String(originalEmbargoDuration)) + } + } + } + } + }) + + describe.each(['numeric', 'persistent'])('%s identifier', (identifierType) => { + let identifier: number | string + beforeEach(() => { + identifier = identifierType === 'numeric' ? fileId : persistentId + }) + + test.each([ + ['1.0', originalLabel], + [DatasetNotNumberedVersion.LATEST_PUBLISHED, originalLabel], + [DatasetNotNumberedVersion.DRAFT, draftLabel], + [DatasetNotNumberedVersion.LATEST, draftLabel] + ])('selects metadata for %s with authorized credentials', async (version, label) => { + const citation = await sut.getFileCitationByFormat( + identifier, + FileCitationFormat.ENDNOTE, + version + ) + expect(citation).toContain(`<custom1>${label}</custom1>`) + }) + + test.each([ + '1.0', + DatasetNotNumberedVersion.LATEST_PUBLISHED, + DatasetNotNumberedVersion.LATEST + ])('returns published metadata anonymously for %s', async (version) => { + authenticate() + const citation = await sut.getFileCitationByFormat( + identifier, + FileCitationFormat.ENDNOTE, + version + ) + expect(citation).toContain(`<custom1>${originalLabel}</custom1>`) + }) + + test('rejects anonymous access to a draft', async () => { + authenticate() + await expect( + sut.getFileCitationByFormat(identifier, FileCitationFormat.ENDNOTE, ':draft') + ).rejects.toThrow(/\[401\]/) + }) + + test('rejects a real user without draft permissions', async () => { + authenticate(otherUserKey) + await expect( + sut.getFileCitationByFormat(identifier, FileCitationFormat.ENDNOTE, ':draft') + ).rejects.toThrow( + /\[401\] User @citationReader\d+ is not permitted to perform requested action/ + ) + }) + + test.each(['666.0', 'invalid'])('rejects invalid dataset version %s', async (version) => { + await expect( + sut.getFileCitationByFormat(identifier, FileCitationFormat.ENDNOTE, version) + ).rejects.toThrow(/\[400\]/) + }) + }) + }) + + test('should return EndNote citation as XML', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.ENDNOTE) + + expect(typeof citation).toBe('string') + expect(citation.trimStart()).toMatch(/^<\?xml/) + }) + + test('should return RIS citation as plain text', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.RIS) + + expect(typeof citation).toBe('string') + // RIS records use TY (type) and ER (end of record) tags + expect(citation).toMatch(/TY\s+-/) + expect(citation).toMatch(/ER\s+-/) + }) + + test('should return BibTeX citation as plain text', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.BIBTEX) + + expect(typeof citation).toBe('string') + // BibTeX entries start with @<entry-type>{ + expect(citation.trimStart()).toMatch(/^@\w+\{/) + }) + + test('should return BibTeX citation when file is requested by persistent id', async () => { + expect(testFilePersistentId).toBeTruthy() + + const citation = await sut.getFileCitationByFormat( + testFilePersistentId, + FileCitationFormat.BIBTEX + ) + + expect(typeof citation).toBe('string') + // BibTeX entries start with @<entry-type>{ + expect(citation.trimStart()).toMatch(/^@\w+\{/) + }) + + test('should return CSL citation as JSON', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.CSL) + + expect(typeof citation).toBe('string') + const parsed = JSON.parse(citation) + expect(typeof parsed).toBe('object') + expect(parsed).not.toBeNull() + }) + + test('should return Internal citation as HTML', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.INTERNAL) + + expect(typeof citation).toBe('string') + // Internal HTML format includes anchor tags linking to the dataset + expect(citation).toMatch(/<a\s+href=/i) + }) + + test('should return error when file does not exist', async () => { + await expect( + sut.getFileCitationByFormat(nonExistentFiledId, FileCitationFormat.BIBTEX) + ).rejects.toThrow(ReadError) + }) + }) + describe('getFileUploadDestination', () => { const testCollectionAlias = 'getFileUploadDestinationsTestCollection' let testDataset2Ids: CreatedDatasetIdentifiers diff --git a/test/integration/guestbooks/GuestbooksRepository.test.ts b/test/integration/guestbooks/GuestbooksRepository.test.ts index 4674d3e0..8e90719c 100644 --- a/test/integration/guestbooks/GuestbooksRepository.test.ts +++ b/test/integration/guestbooks/GuestbooksRepository.test.ts @@ -2,6 +2,7 @@ import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ import { ApiConfig, ReadError, WriteError } from '../../../src' import { GuestbooksRepository } from '../../../src/guestbooks/infra/repositories/GuestbooksRepository' import { CreateGuestbookDTO } from '../../../src/guestbooks/domain/dtos/CreateGuestbookDTO' +import { EditGuestbookDTO } from '../../../src/guestbooks/domain/dtos/EditGuestbookDTO' import { TestConstants } from '../../testHelpers/TestConstants' import { createDataset, @@ -9,15 +10,28 @@ import { DatasetNotNumberedVersion, getDataset } from '../../../src/datasets' -import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper' +import { + deletePublishedDatasetViaApi, + deleteUnpublishedDatasetViaApi, + publishDatasetViaApi, + waitForNoLocks +} from '../../testHelpers/datasets/datasetHelper' import { createCollectionViaApi, - deleteCollectionViaApi + deleteCollectionViaApi, + publishCollectionViaApi } from '../../testHelpers/collections/collectionHelper' import { CollectionPayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload' +import { AccessRepository } from '../../../src/access/infra/repositories/AccessRepository' +import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO' +import { testTextFile1Name, uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' +import { FileOrderCriteria } from '../../../src/files/domain/models/FileCriteria' describe('GuestbooksRepository', () => { const sut = new GuestbooksRepository() + const accessRepository = new AccessRepository() + const filesRepository = new FilesRepository() const testCollectionAlias = 'testGuestbooksRepository' let testCollectionId: number let createdGuestbookId: number @@ -70,6 +84,7 @@ describe('GuestbooksRepository', () => { await createCollectionViaApi(testCollectionAlias).then( (collectionPayload: CollectionPayload) => (testCollectionId = collectionPayload.id) ) + await publishCollectionViaApi(testCollectionAlias) }) afterAll(async () => { @@ -105,7 +120,10 @@ describe('GuestbooksRepository', () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) const actual = await sut.getGuestbooksByCollectionId(testCollectionId) expect(actual.length).toBeGreaterThan(0) - expect(actual.some((guestbook) => guestbook.id === createdGuestbookId)).toBe(true) + const createdGuestbook = actual.find((guestbook) => guestbook.id === createdGuestbookId) + expect(createdGuestbook).toBeDefined() + expect(createdGuestbook?.usageCount).toBeUndefined() + expect(createdGuestbook?.responseCount).toBeUndefined() }) test('should list guestbooks for collection by collection alias', async () => { @@ -118,11 +136,236 @@ describe('GuestbooksRepository', () => { expect(actual.some((guestbook) => guestbook.id === createdByAliasGuestbookId)).toBe(true) }) + test('should list guestbooks for collection with stats', async () => { + const createdGuestbookIdWithStats = await sut.createGuestbook( + testCollectionAlias, + createGuestbookDTO + ) + const actual = await sut.getGuestbooksByCollectionId(testCollectionAlias, true) + const createdGuestbookWithStats = actual.find( + (guestbook) => guestbook.id === createdGuestbookIdWithStats + ) + + expect(createdGuestbookWithStats).toBeDefined() + expect(createdGuestbookWithStats?.usageCount).toEqual(expect.any(Number)) + expect(createdGuestbookWithStats?.responseCount).toEqual(expect.any(Number)) + }) + + test('should increment usageCount when assigned by the dataset admin and responseCount only when a guest submits a response', async () => { + let statsDatasetIds: CreatedDatasetIdentifiers | undefined + let statsDatasetPublished = false + const guestbookResponse: GuestbookResponseDTO = { + guestbookResponse: { + name: 'Guestbook Stats Test', + email: 'guestbook-stats@example.edu' + } + } + const statsGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: 'guestbook stats test', + customQuestions: [] + }) + + try { + const initialStats = await getGuestbookStats(statsGuestbookId) + statsDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + await uploadFileViaApi(statsDatasetIds.numericId, testTextFile1Name) + const datasetFiles = await filesRepository.getDatasetFiles( + statsDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + const fileId = datasetFiles.files[0].id + + await sut.assignDatasetGuestbook(statsDatasetIds.numericId, statsGuestbookId) + + const statsAfterAssignment = await getGuestbookStats(statsGuestbookId) + expect(statsAfterAssignment.usageCount).toBe((initialStats.usageCount ?? 0) + 1) + expect(statsAfterAssignment.responseCount).toBe(initialStats.responseCount ?? 0) + + await publishDatasetViaApi(statsDatasetIds.numericId) + statsDatasetPublished = true + await waitForNoLocks(statsDatasetIds.numericId, 10) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + undefined, + () => null + ) + await accessRepository.submitGuestbookForDatafileDownload(fileId, guestbookResponse) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + const statsAfterResponse = await getGuestbookStats(statsGuestbookId) + expect(statsAfterResponse.usageCount).toBe(statsAfterAssignment.usageCount) + expect(statsAfterResponse.responseCount).toBe((statsAfterAssignment.responseCount ?? 0) + 1) + } finally { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + if (statsDatasetIds !== undefined) { + if (statsDatasetPublished) { + await deletePublishedDatasetViaApi(statsDatasetIds.persistentId) + } else { + await deleteUnpublishedDatasetViaApi(statsDatasetIds.numericId) + } + } + } + }) + + test('should include hierarchical owner guestbooks when includeInherited is true', async () => { + const uniqueSuffix = Date.now().toString() + const childCollectionAlias = `testGuestbooksInheritedChild${uniqueSuffix}` + const parentGuestbookName = `parent inherited guestbook ${uniqueSuffix}` + const childGuestbookName = `child inherited guestbook ${uniqueSuffix}` + + let childCollectionId: number | undefined + + try { + await createCollectionViaApi(childCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + await publishCollectionViaApi(childCollectionAlias) + + const parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + const childGuestbookId = await sut.createGuestbook(childCollectionAlias, { + ...createGuestbookDTO, + name: childGuestbookName, + customQuestions: [] + }) + + const withoutInherited = await sut.getGuestbooksByCollectionId(childCollectionAlias) + const withInherited = await sut.getGuestbooksByCollectionId( + childCollectionAlias, + false, + true + ) + + expect(childCollectionId).toBeDefined() + expect(withoutInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withoutInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(false) + + expect(withInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(true) + + const inheritedGuestbook = withInherited.find( + (guestbook) => guestbook.id === parentGuestbookId + ) + expect(inheritedGuestbook?.name).toBe(parentGuestbookName) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(childCollectionAlias) + } + } + }) + + test('should not include hierarchical owner guestbooks when includeInherited is false', async () => { + const uniqueSuffix = Date.now().toString() + const childCollectionAlias = `testGuestbooksNoInheritedChild${uniqueSuffix}` + const parentGuestbookName = `parent non inherited guestbook ${uniqueSuffix}` + const childGuestbookName = `child non inherited guestbook ${uniqueSuffix}` + + let childCollectionId: number | undefined + + try { + await createCollectionViaApi(childCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + await publishCollectionViaApi(childCollectionAlias) + + const parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + const childGuestbookId = await sut.createGuestbook(childCollectionAlias, { + ...createGuestbookDTO, + name: childGuestbookName, + customQuestions: [] + }) + + const withoutInherited = await sut.getGuestbooksByCollectionId( + childCollectionAlias, + false, + false + ) + + expect(childCollectionId).toBeDefined() + expect(withoutInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withoutInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(false) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(childCollectionAlias) + } + } + }) + + test('should return inherited guestbooks for unpublished child collection when includeInherited is true', async () => { + const uniqueSuffix = Date.now().toString() + const unpublishedChildCollectionAlias = `testGuestbooksUnpublishedChild${uniqueSuffix}` + const parentGuestbookName = `parent unpublished child guestbook ${uniqueSuffix}` + let childCollectionId: number | undefined + let parentGuestbookId: number | undefined + + try { + await createCollectionViaApi(unpublishedChildCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + + parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + + const actual = await sut.getGuestbooksByCollectionId( + unpublishedChildCollectionAlias, + false, + true + ) + + expect(childCollectionId).toBeDefined() + expect(parentGuestbookId).toBeDefined() + expect(actual.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(true) + expect(actual.some((guestbook) => guestbook.name === parentGuestbookName)).toBe(true) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(unpublishedChildCollectionAlias) + } + } + }) + test('should return error when collection does not exist', async () => { await expect(sut.getGuestbooksByCollectionId(999999)).rejects.toThrow(ReadError) }) }) + const getGuestbookStats = async (guestbookId: number) => { + const guestbooks = await sut.getGuestbooksByCollectionId(testCollectionAlias, true) + const guestbook = guestbooks.find((guestbook) => guestbook.id === guestbookId) + + if (guestbook === undefined) { + throw new Error(`Guestbook ${guestbookId} was not found in collection stats.`) + } + + return guestbook + } + describe('getGuestbook', () => { test('should get guestbook by id', async () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) @@ -136,6 +379,350 @@ describe('GuestbooksRepository', () => { }) }) + describe('editGuestbook', () => { + const buildEditGuestbookDTO = ( + currentGuestbook: Awaited<ReturnType<GuestbooksRepository['getGuestbook']>>, + overrides: Partial<EditGuestbookDTO> + ): EditGuestbookDTO => ({ + name: currentGuestbook.name, + enabled: currentGuestbook.enabled, + emailRequired: currentGuestbook.emailRequired, + nameRequired: currentGuestbook.nameRequired, + institutionRequired: currentGuestbook.institutionRequired, + positionRequired: currentGuestbook.positionRequired, + createTime: currentGuestbook.createTime, + customQuestions: currentGuestbook.customQuestions ?? [], + ...overrides + }) + + test('should update existing custom question when custom question id is provided', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit custom question with id ${uniqueSuffix}` + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + const existingQuestion = guestbookBeforeEdit.customQuestions[0] + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited custom question with id guestbook ${uniqueSuffix}`, + customQuestions: [ + { + id: existingQuestion.id, + question: `updated custom question ${uniqueSuffix}`, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + } + ] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.customQuestions).toHaveLength(1) + expect(actual.customQuestions[0]).toEqual( + expect.objectContaining({ + id: existingQuestion.id, + question: editedGuestbook.customQuestions?.[0].question, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + }) + ) + }) + + test('should create replacement custom question when custom question id is not provided', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit custom question without id ${uniqueSuffix}` + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + const existingQuestion = guestbookBeforeEdit.customQuestions[0] + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited custom question without id guestbook ${uniqueSuffix}`, + customQuestions: [ + { + question: `replacement custom question ${uniqueSuffix}`, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + } + ] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.customQuestions).toHaveLength(1) + expect(actual.customQuestions[0]).toEqual( + expect.objectContaining({ + question: editedGuestbook.customQuestions?.[0].question, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + }) + ) + expect(actual.customQuestions[0].id).toBeDefined() + expect(actual.customQuestions[0].id).not.toBe(existingQuestion.id) + }) + + test('should edit guestbook without custom questions', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit no questions ${uniqueSuffix}`, + enabled: false, + emailRequired: false, + nameRequired: false, + customQuestions: [] + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited guestbook ${uniqueSuffix}`, + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: true, + positionRequired: false, + customQuestions: [] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.enabled).toBe(true) + expect(actual.emailRequired).toBe(true) + expect(actual.nameRequired).toBe(true) + expect(actual.institutionRequired).toBe(true) + expect(actual.positionRequired).toBe(false) + expect(actual.customQuestions ?? []).toHaveLength(0) + }) + + test('should edit guestbook with a textarea custom question', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit textarea ${uniqueSuffix}`, + customQuestions: [] + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited textarea guestbook ${uniqueSuffix}`, + customQuestions: [ + { + question: `textarea question ${uniqueSuffix}`, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + } + ] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.customQuestions).toHaveLength(1) + expect(actual.customQuestions[0]).toEqual( + expect.objectContaining({ + question: editedGuestbook.customQuestions?.[0].question, + required: false, + displayOrder: 0, + type: 'textarea', + hidden: false + }) + ) + }) + + test('should edit guestbook with an options custom question', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit options ${uniqueSuffix}`, + customQuestions: [] + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited options guestbook ${uniqueSuffix}`, + customQuestions: [ + { + question: `options question ${uniqueSuffix}`, + required: true, + displayOrder: 0, + type: 'options', + hidden: false, + optionValues: [ + { value: 'Red', displayOrder: 0 }, + { value: 'Blue', displayOrder: 1 } + ] + } + ] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.customQuestions).toHaveLength(1) + expect(actual.customQuestions[0]).toEqual( + expect.objectContaining({ + question: editedGuestbook.customQuestions?.[0].question, + required: true, + displayOrder: 0, + type: 'options', + hidden: false, + optionValues: expect.arrayContaining([ + expect.objectContaining({ value: 'Red', displayOrder: 0 }), + expect.objectContaining({ value: 'Blue', displayOrder: 1 }) + ]) + }) + ) + }) + + test('should remove custom questions when custom question field is empty', async () => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionId, { + ...createGuestbookDTO, + name: `guestbook edit empty field ${uniqueSuffix}` + }) + const guestbookBeforeEdit = await sut.getGuestbook(guestbookId) + expect(guestbookBeforeEdit.customQuestions).toHaveLength(3) + + const editedGuestbook = buildEditGuestbookDTO(guestbookBeforeEdit, { + name: `edited empty field guestbook ${uniqueSuffix}`, + customQuestions: [] + }) + + await sut.editGuestbook(guestbookId, editedGuestbook) + + const actual = await sut.getGuestbook(guestbookId) + expect(actual.name).toBe(editedGuestbook.name) + expect(actual.customQuestions ?? []).toHaveLength(0) + }) + + test('should return error when guestbook does not exist', async () => { + await expect( + sut.editGuestbook(999999, { ...createGuestbookDTO, createTime: '2026-06-12T00:00:00Z' }) + ).rejects.toThrow(WriteError) + }) + }) + + describe('getGuestbookResponsesByGuestbookId', () => { + test('should return responses for one guestbook', async () => { + const setup = await createGuestbookDownloadSetup('guestbook responses endpoint test') + + try { + const actual = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId) + + expect(actual.guestbookResponses.length).toBeGreaterThan(0) + expect(actual.totalGuestbookResponseCount).toBeGreaterThanOrEqual(1) + expect(actual.guestbookResponses[0].datasetPid).toBe( + setup.datasetPersistentId.split('/').slice(-2).join('/') + ) + expect(actual.guestbookResponses[0].email).toBe(setup.email) + expect(actual.guestbookResponses[0].fileName).toBe(testTextFile1Name) + } finally { + await cleanupGuestbookDownloadSetup(setup) + } + }) + + test('should return paginated responses for one guestbook', async () => { + const setup = await createGuestbookDownloadSetup('paginated guestbook responses test') + const secondResponseEmail = `guestbook-pagination-${Date.now()}@example.edu` + + try { + await submitGuestbookDownloadResponse( + setup.fileId, + `Guestbook Pagination ${Date.now()}`, + secondResponseEmail + ) + + const firstPage = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId, 1, 0) + const secondPage = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId, 1, 1) + + expect(firstPage.guestbookResponses).toHaveLength(1) + expect(secondPage.guestbookResponses).toHaveLength(1) + expect(firstPage.totalGuestbookResponseCount).toBeGreaterThanOrEqual(2) + expect(secondPage.totalGuestbookResponseCount).toBeGreaterThanOrEqual(2) + expect(firstPage.guestbookResponses[0].email).not.toBe( + secondPage.guestbookResponses[0].email + ) + expect([ + firstPage.guestbookResponses[0].email, + secondPage.guestbookResponses[0].email + ]).toEqual(expect.arrayContaining([setup.email, secondResponseEmail])) + } finally { + await cleanupGuestbookDownloadSetup(setup) + } + }) + + test('should return error when guestbook does not exist', async () => { + await expect(sut.getGuestbookResponsesByGuestbookId(999999)).rejects.toThrow(ReadError) + }) + }) + + describe('downloadGuestbookResponsesByCollectionId', () => { + test('should download all guestbook responses for a collection without guestbook id', async () => { + const setups: GuestbookDownloadSetup[] = [] + + try { + setups.push(await createGuestbookDownloadSetup('all responses export test one')) + setups.push(await createGuestbookDownloadSetup('all responses export test two')) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(testCollectionAlias) + + expect(actual).toContain('Guestbook, Dataset, Dataset PID, Date, Type, File Name') + expect(actual).toContain(setups[0].guestbookName) + expect(actual).toContain(setups[0].datasetPersistentId) + expect(actual).toContain(setups[0].email) + expect(actual).toContain(setups[1].guestbookName) + expect(actual).toContain(setups[1].datasetPersistentId) + expect(actual).toContain(setups[1].email) + } finally { + await cleanupGuestbookDownloadSetups(setups) + } + }) + + test('should download responses only for the specified guestbook with guestbook id', async () => { + const setups: GuestbookDownloadSetup[] = [] + + try { + setups.push(await createGuestbookDownloadSetup('single guestbook export target test')) + setups.push(await createGuestbookDownloadSetup('single guestbook export other test')) + + const actual = await sut.downloadGuestbookResponsesByCollectionId( + testCollectionAlias, + setups[0].guestbookId + ) + + expect(actual).toContain('Guestbook, Dataset, Dataset PID, Date, Type, File Name') + expect(actual).toContain(setups[0].guestbookName) + expect(actual).toContain(setups[0].datasetPersistentId) + expect(actual).toContain(setups[0].email) + expect(actual).toContain(testTextFile1Name) + expect(actual).not.toContain(setups[1].guestbookName) + expect(actual).not.toContain(setups[1].datasetPersistentId) + expect(actual).not.toContain(setups[1].email) + } finally { + await cleanupGuestbookDownloadSetups(setups) + } + }) + }) + describe('setGuestbookEnabled', () => { test('should disable guestbook', async () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) @@ -233,4 +820,90 @@ describe('GuestbooksRepository', () => { }) }) }) + + interface GuestbookDownloadSetup { + guestbookId: number + guestbookName: string + datasetNumericId: number + datasetPersistentId: string + fileId: number + email: string + } + + const createGuestbookDownloadSetup = async ( + guestbookName: string + ): Promise<GuestbookDownloadSetup> => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: `${guestbookName}-${uniqueSuffix}`, + customQuestions: [] + }) + const datasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + await uploadFileViaApi(datasetIds.numericId, testTextFile1Name) + const datasetFiles = await filesRepository.getDatasetFiles( + datasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + const fileId = datasetFiles.files[0].id + const email = `guestbook-download-${uniqueSuffix}@example.edu` + + await sut.assignDatasetGuestbook(datasetIds.numericId, guestbookId) + await publishDatasetViaApi(datasetIds.numericId) + await waitForNoLocks(datasetIds.numericId, 10) + + await submitGuestbookDownloadResponse(fileId, `Guestbook Download ${uniqueSuffix}`, email) + + return { + guestbookId, + guestbookName: `${guestbookName}-${uniqueSuffix}`, + datasetNumericId: datasetIds.numericId, + datasetPersistentId: datasetIds.persistentId, + fileId, + email + } + } + + const submitGuestbookDownloadResponse = async (fileId: number, name: string, email: string) => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + undefined, + () => null + ) + await accessRepository.submitGuestbookForDatafileDownload(fileId, { + guestbookResponse: { + name, + email + } + }) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + } + + const cleanupGuestbookDownloadSetup = async (setup: { + datasetNumericId: number + datasetPersistentId: string + }) => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await deletePublishedDatasetViaApi(setup.datasetPersistentId) + } + + const cleanupGuestbookDownloadSetups = async (setups: GuestbookDownloadSetup[]) => { + await Promise.all(setups.map((setup) => cleanupGuestbookDownloadSetup(setup))) + } }) diff --git a/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts b/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts index de94eb7c..edbf6820 100644 --- a/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts +++ b/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts @@ -19,7 +19,7 @@ describe('getAllFacetableMetadataFields', () => { test('should return all facetable metadata fields', async () => { const actual = await sut.getAllFacetableMetadataFields() - expect(actual.length).toBe(64) + expect(actual.length).toBeGreaterThanOrEqual(64) expect(actual[0].name).toBe('authorName') expect(actual[0].displayName).toBe('Author Name') }) diff --git a/test/integration/templates/TemplateRepository.test.ts b/test/integration/templates/TemplateRepository.test.ts index 8a68a683..38b33a03 100644 --- a/test/integration/templates/TemplateRepository.test.ts +++ b/test/integration/templates/TemplateRepository.test.ts @@ -1,5 +1,11 @@ import { ApiConfig, MetadataFieldTypeClass, WriteError } from '../../../src' -import { createTemplate, getTemplatesByCollectionId } from '../../../src/templates' +import { + createTemplate, + getTemplatesByCollectionId, + updateTemplateLicenseTerms, + updateTemplateMetadata, + updateTemplateTermsOfAccess +} from '../../../src/templates' import { CreateTemplateDTO } from '../../../src/templates/domain/dtos/CreateTemplateDTO' import { TemplatesRepository } from '../../../src/templates/infra/repositories/TemplatesRepository' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -370,4 +376,116 @@ describe('TemplatesRepository', () => { await expect(sut.unsetTemplateAsDefault('invalidCollectionAlias')).rejects.toThrow() }) }) + + describe('updateTemplateMetadata', () => { + test('should update template metadata and instructions', async () => { + const templateName = `Template for UpdateTemplateMetadata ${Date.now()}` + await createTemplate.execute( + { + name: templateName, + isDefault: false, + fields: [ + { + typeName: 'author', + typeClass: MetadataFieldTypeClass.Compound, + multiple: true, + value: [ + { + authorName: { + typeName: 'authorName', + typeClass: MetadataFieldTypeClass.Primitive, + value: 'Belicheck, Bill' + }, + authorAffiliation: { + typeName: 'authorIdentifierScheme', + typeClass: MetadataFieldTypeClass.Primitive, + value: 'ORCID' + } + } + ] + } + ], + instructions: [ + { + instructionField: 'author', + instructionText: 'The author data' + } + ] + }, + testCollectionAlias + ) + const templates = await getTemplatesByCollectionId.execute(testCollectionAlias) + const templateId = templates[templates.length - 1].id + + await updateTemplateMetadata.execute( + templateId, + { + name: `${templateName} updated`, + instructions: [ + { + instructionField: 'author', + instructionText: 'Updated author instructions' + } + ] + }, + true + ) + + const updatedTemplate = await sut.getTemplate(templateId) + + expect(updatedTemplate.name).toBe(`${templateName} updated`) + expect(updatedTemplate.instructions).toContainEqual({ + instructionField: 'author', + instructionText: 'Updated author instructions' + }) + + await deleteDatasetTemplateViaApi(templateId) + }) + }) + + describe('updateTemplateLicenseTerms', () => { + test('should update template custom terms of use', async () => { + const templateName = `Template for UpdateTemplateLicenseTerms ${Date.now()}` + await createTemplate.execute({ name: templateName, isDefault: false }, testCollectionAlias) + const templates = await getTemplatesByCollectionId.execute(testCollectionAlias) + const templateId = templates[templates.length - 1].id + + await updateTemplateLicenseTerms.execute(templateId, { + customTerms: { + termsOfUse: 'Updated template terms of use' + } + }) + + const updatedTemplate = await sut.getTemplate(templateId) + + expect(updatedTemplate.termsOfUse.customTerms?.termsOfUse).toBe( + 'Updated template terms of use' + ) + + await deleteDatasetTemplateViaApi(templateId) + }) + }) + + describe('updateTemplateTermsOfAccess', () => { + test('should update template terms of access', async () => { + const templateName = `Template for UpdateTemplateTermsOfAccess ${Date.now()}` + await createTemplate.execute({ name: templateName, isDefault: false }, testCollectionAlias) + const templates = await getTemplatesByCollectionId.execute(testCollectionAlias) + const templateId = templates[templates.length - 1].id + + await updateTemplateTermsOfAccess.execute(templateId, { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'Restricted access only' + }) + + const updatedTemplate = await sut.getTemplate(templateId) + + expect(updatedTemplate.termsOfUse.termsOfAccess.fileAccessRequest).toBe(true) + expect(updatedTemplate.termsOfUse.termsOfAccess.termsOfAccessForRestrictedFiles).toBe( + 'Restricted access only' + ) + + await deleteDatasetTemplateViaApi(templateId) + }) + }) }) diff --git a/test/testHelpers/collections/collectionHelper.ts b/test/testHelpers/collections/collectionHelper.ts index b19b668f..9a2eafe2 100644 --- a/test/testHelpers/collections/collectionHelper.ts +++ b/test/testHelpers/collections/collectionHelper.ts @@ -1,6 +1,9 @@ -import { Collection, CollectionFacet } from '../../../src/collections' +import { Collection, CollectionFacet, CollectionTheme } from '../../../src/collections' import { DvObjectType } from '../../../src' -import { CollectionPayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload' +import { + CollectionPayload, + CollectionThemePayload +} from '../../../src/collections/infra/repositories/transformers/CollectionPayload' import { TestConstants } from '../TestConstants' import axios from 'axios' import { CollectionDTO } from '../../../src/collections/domain/dtos/CollectionDTO' @@ -22,7 +25,7 @@ const DATAVERSE_API_REQUEST_HEADERS = { headers: { 'Content-Type': 'application/json', 'X-Dataverse-Key': process.env.TEST_API_KEY } } -export const createCollectionModel = (): Collection => { +export const createCollectionModel = (theme?: CollectionTheme): Collection => { const collectionModel: Collection = { id: COLLECTION_ID, alias: COLLECTION_ALIAS_STR, @@ -45,14 +48,23 @@ export const createCollectionModel = (): Collection => { displayOrder: 0 } ], + allowedDatasetTypes: [ + { + id: 1, + name: 'review', + displayName: 'Review', + description: 'A review of a dataset compiled by the expert community.' + } + ], isMetadataBlockRoot: true, isFacetRoot: true, - childCount: 0 + childCount: 0, + ...(theme && { theme }) } return collectionModel } -export const createCollectionPayload = (): CollectionPayload => { +export const createCollectionPayload = (theme?: CollectionThemePayload): CollectionPayload => { const collectionPayload: CollectionPayload = { id: COLLECTION_ID, alias: COLLECTION_ALIAS_STR, @@ -75,9 +87,18 @@ export const createCollectionPayload = (): CollectionPayload => { displayOrder: 0 } ], + allowedDatasetTypes: [ + { + id: 1, + name: 'review', + displayName: 'Review', + description: 'A review of a dataset compiled by the expert community.' + } + ], isMetadataBlockRoot: true, isFacetRoot: true, - childCount: 0 + childCount: 0, + ...(theme && { theme }) } return collectionPayload } @@ -134,7 +155,7 @@ export async function setStorageDriverViaApi( ): Promise<void> { try { return await axios.put( - `${TestConstants.TEST_API_URL}/admin/dataverse/${collectionAlias}/storageDriver`, + `${TestConstants.TEST_API_URL}/dataverses/${collectionAlias}/storageDriver`, driverLabel, { headers: { 'Content-Type': 'text/plain', 'X-Dataverse-Key': process.env.TEST_API_KEY } @@ -158,6 +179,28 @@ export async function publishCollectionViaApi(collectionAlias: string): Promise< } } +export async function setCollectionAllowedDatasetTypesViaApi( + collectionAlias: string, + allowedDatasetTypes: string[] +): Promise<void> { + try { + return await axios.put( + `${TestConstants.TEST_API_URL}/dataverses/${collectionAlias}/attribute/allowedDatasetTypes`, + undefined, + { + params: { + value: allowedDatasetTypes.join(',') + }, + ...DATAVERSE_API_REQUEST_HEADERS + } + ) + } catch (error) { + throw new Error( + `Error while setting allowed dataset types for test collection ${collectionAlias}` + ) + } +} + export const createCollectionDTO = (alias = 'test-collection'): CollectionDTO => { return { alias: alias, @@ -225,11 +268,12 @@ export const createCollectionFacetRequestPayload = (): CollectionFacetPayload => } export const CONTENT_FIELD_WITH_ALL_TAGS = - '<h1 class="rte-heading">A title</h1><p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p><p class="rte-paragraph">Una lista desordenada:</p><ul class="rte-bullet-list"><li><p class="rte-paragraph">Item</p></li><li><p class="rte-paragraph">Item</p></li></ul><p class="rte-paragraph">Una lista ordenada:</p><ol class="rte-ordered-list"><li><p class="rte-paragraph">Item 1</p></li><li><p class="rte-paragraph">Item 2</p></li></ol><blockquote class="rte-blockquote"><p class="rte-paragraph">Este es un blockquote.</p></blockquote><p class="rte-paragraph">Esto que viene es un bloque de codigo.</p><pre class="rte-code-block"><code> <Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => { console.log({ value }) return ( <Col> <RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /> {invalid && <div className={styles["error-msg"]}>{error?.message}</div>} </Col> ) }} /></code></pre>' + '<h1 class="rte-heading">A title</h1><p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p><p class="rte-paragraph"><strong class="rte-bold">Negrita</strong> <em class="rte-italic">seguida de italica</em></p><p class="rte-paragraph">Una lista desordenada:</p><ul class="rte-bullet-list"><li><p class="rte-paragraph">Item</p></li><li><p class="rte-paragraph">Item</p></li></ul><p class="rte-paragraph">Una lista ordenada:</p><ol class="rte-ordered-list"><li><p class="rte-paragraph">Item 1</p></li><li><p class="rte-paragraph">Item 2</p></li></ol><blockquote class="rte-blockquote"><p class="rte-paragraph">Este es un blockquote.</p></blockquote><p class="rte-paragraph">Esto que viene es un bloque de codigo.</p><pre class="rte-code-block"><code> <Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => { console.log({ value }) return ( <Col> <RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /> {invalid && <div className={styles["error-msg"]}>{error?.message}</div>} </Col> ) }} /></code></pre>' -export const EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS = +export const SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS = '<h1 class="rte-heading">A title</h1>\n' + '<p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p>\n' + + '<p class="rte-paragraph"><strong class="rte-bold">Negrita</strong> <em class="rte-italic">seguida de italica</em></p>\n' + '<p class="rte-paragraph">Una lista desordenada:</p>\n' + '<ul class="rte-bullet-list">\n' + ' <li><p class="rte-paragraph">Item</p></li>\n' + diff --git a/test/testHelpers/datasets/datasetHelper.ts b/test/testHelpers/datasets/datasetHelper.ts index 097de6f5..b5057c3c 100644 --- a/test/testHelpers/datasets/datasetHelper.ts +++ b/test/testHelpers/datasets/datasetHelper.ts @@ -353,6 +353,26 @@ export const setDatasetStorageSizeViaApi = async ( } } +export const loadMetadataBlockViaApi = async (metadataBlockTsv: string): Promise<AxiosResponse> => { + try { + return await axios.post( + `${TestConstants.TEST_API_URL}/admin/datasetfield/load`, + metadataBlockTsv, + { + headers: { + 'Content-Type': 'text/tab-separated-values', + 'X-Dataverse-Key': process.env.TEST_API_KEY + } + } + ) + } catch (error) { + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.message ?? error.message}` + : `${error}` + throw new Error(`Error while loading metadata block. Reason was: ${message}`) + } +} + export const deaccessionDatasetViaApi = async ( datasetId: number, versionId: string diff --git a/test/testHelpers/datasets/datasetReviewHelper.ts b/test/testHelpers/datasets/datasetReviewHelper.ts new file mode 100644 index 00000000..97837a22 --- /dev/null +++ b/test/testHelpers/datasets/datasetReviewHelper.ts @@ -0,0 +1,62 @@ +import { DatasetReview } from '../../../src/datasets/domain/models/DatasetReview' +import { DatasetReviewPayload } from '../../../src/datasets/infra/repositories/transformers/DatasetReviewPayload' + +export const createDatasetReviewModel = (): DatasetReview => ({ + title: 'Review of Pediatric Asthma', + authors: ['Wazowski, Mike'], + persistentId: 'doi:10.5072/FK2/1WD6BX', + persistentIdUrl: 'https://doi.org/10.5072/FK2/1WD6BX', + id: 13, + citation: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + citationHtml: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", <a href="https://doi.org/10.5072/FK2/1WD6BX">https://doi.org/10.5072/FK2/1WD6BX</a>, Root, DRAFT VERSION', + datePublished: '', + description: 'This is a review of a dataset.', + rubricMetadataBlocks: [ + { + name: 'rubric_trusteddatadimensionsintensities', + displayName: 'Trusted Data Dimensions and Intensities', + fields: [ + { + typeName: 'licensingAndLegalClarity', + value: 'High' + }, + { + typeName: 'authorAndProvenance', + value: 'Medium' + } + ] + } + ] +}) + +export const createDatasetReviewPayload = (): DatasetReviewPayload => ({ + title: 'Review of Pediatric Asthma', + authors: ['Wazowski, Mike'], + persistentId: 'doi:10.5072/FK2/1WD6BX', + persistentIdUrl: 'https://doi.org/10.5072/FK2/1WD6BX', + id: 13, + citation: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + citationHtml: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", <a href="https://doi.org/10.5072/FK2/1WD6BX">https://doi.org/10.5072/FK2/1WD6BX</a>, Root, DRAFT VERSION', + datePublished: '', + description: 'This is a review of a dataset.', + rubricMetadataBlocks: [ + { + name: 'rubric_trusteddatadimensionsintensities', + displayName: 'Trusted Data Dimensions and Intensities', + fields: [ + { + typeName: 'licensingAndLegalClarity', + value: 'High' + }, + { + typeName: 'authorAndProvenance', + value: 'Medium' + } + ] + } + ] +}) diff --git a/test/testHelpers/html/htmlNormalizer.ts b/test/testHelpers/html/htmlNormalizer.ts new file mode 100644 index 00000000..3eac3c7f --- /dev/null +++ b/test/testHelpers/html/htmlNormalizer.ts @@ -0,0 +1,117 @@ +import { JSDOM } from 'jsdom' + +const WHITESPACE_SENSITIVE_TAGS = new Set(['pre', 'textarea']) + +const BLOCK_TAGS = new Set([ + 'address', + 'article', + 'aside', + 'blockquote', + 'body', + 'br', + 'div', + 'dd', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'li', + 'main', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'ul' +]) + +const document = new JSDOM('').window.document + +const isElement = (node: Node | null | undefined): node is Element => + node !== null && node !== undefined && node.nodeType === node.ELEMENT_NODE + +const isText = (node: Node): node is Text => node.nodeType === node.TEXT_NODE + +const tagNameOf = (element: Element): string => element.tagName.toLowerCase() + +const isBlockBoundary = (sibling: Node | undefined, parent: Node): boolean => + sibling === undefined + ? !isElement(parent) || BLOCK_TAGS.has(tagNameOf(parent)) + : isElement(sibling) && BLOCK_TAGS.has(tagNameOf(sibling)) + +const sortAttributes = (element: Element): void => { + const attributes = Array.from(element.attributes).sort((one, other) => + one.name.localeCompare(other.name) + ) + attributes.forEach((attribute) => element.removeAttribute(attribute.name)) + attributes.forEach((attribute) => element.setAttribute(attribute.name, attribute.value)) +} + +const normalizeText = (text: Text, afterBoundary: boolean, beforeBoundary: boolean): void => { + if (text.data.trim() === '') { + if (afterBoundary || beforeBoundary) { + text.remove() + } else { + text.data = ' ' + } + return + } + + let collapsed = text.data.replace(/\s+/g, ' ') + if (afterBoundary) { + collapsed = collapsed.replace(/^ /, '') + } + if (beforeBoundary) { + collapsed = collapsed.replace(/ $/, '') + } + text.data = collapsed +} + +const normalizeChildren = (parent: Node, whitespaceSensitive: boolean): void => { + const childrenAsParsed = Array.from(parent.childNodes) + + childrenAsParsed.forEach((child, index) => { + if (isElement(child)) { + sortAttributes(child) + normalizeChildren( + child, + whitespaceSensitive || WHITESPACE_SENSITIVE_TAGS.has(tagNameOf(child)) + ) + return + } + if (isText(child) && !whitespaceSensitive) { + normalizeText( + child, + isBlockBoundary(childrenAsParsed[index - 1], parent), + isBlockBoundary(childrenAsParsed[index + 1], parent) + ) + } + }) +} + +export const normalizeHtml = (html: string): string => { + const template = document.createElement('template') + template.innerHTML = html + normalizeChildren(template.content, false) + return template.innerHTML +} diff --git a/test/testHelpers/search/solrHelper.ts b/test/testHelpers/search/solrHelper.ts new file mode 100644 index 00000000..830797ae --- /dev/null +++ b/test/testHelpers/search/solrHelper.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import axios from 'axios' +import { TestConstants } from '../TestConstants' + +const execFileAsync = promisify(execFile) + +const TEST_SOLR_CONTAINER_NAME = 'test_solr' +const TEST_SOLR_SCHEMA_PATH = '/var/solr/data/collection1/conf/schema.xml' +const TEST_SOLR_COLLECTION_URL = 'http://localhost:8983/solr/collection1' +const TEST_SOLR_CORE_ADMIN_URL = 'http://localhost:8983/solr/admin/cores' + +const DATAVERSE_API_REQUEST_HEADERS = { + headers: { 'X-Dataverse-Key': process.env.TEST_API_KEY } +} + +type ExecFileError = Error & { + stderr?: string + stdout?: string +} + +export const solrSchemaFieldExistsViaDocker = async (fieldName: string): Promise<boolean> => { + const statusCode = await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'curl', + '-sS', + '-o', + '/tmp/solr-schema-field-response.json', + '-w', + '%{http_code}', + `${TEST_SOLR_COLLECTION_URL}/schema/fields/${encodeURIComponent(fieldName)}` + ]) + + if (statusCode.trim() === '200') { + return true + } + + if (statusCode.trim() === '404') { + return false + } + + throw new Error(`Unexpected Solr schema field check status for ${fieldName}: ${statusCode}`) +} + +export const replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker = async (): Promise<void> => { + const generatedSchemaFragment = await getDataverseGeneratedSolrSchemaFragment() + const currentSchema = await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'cat', + TEST_SOLR_SCHEMA_PATH + ]) + const mergedSchema = mergeGeneratedSchemaFragment(currentSchema, generatedSchemaFragment) + await copySchemaToSolrContainer(mergedSchema) + await reloadSolrCoreViaDocker() +} + +const getDataverseGeneratedSolrSchemaFragment = async (): Promise<string> => { + try { + const response = await axios.get( + `${TestConstants.TEST_API_URL}/admin/index/solr/schema`, + DATAVERSE_API_REQUEST_HEADERS + ) + + if (typeof response.data !== 'string') { + throw new Error('Generated schema response was not text.') + } + + return response.data + } catch (error) { + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.message ?? error.message}` + : `${error}` + throw new Error(`Error while getting Dataverse-generated Solr schema. Reason was: ${message}`) + } +} + +const copySchemaToSolrContainer = async (schemaXml: string): Promise<void> => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dataverse-solr-schema-')) + const tempSchemaPath = path.join(tempDir, 'schema.xml') + + try { + fs.writeFileSync(tempSchemaPath, schemaXml) + await runDockerCommand([ + 'cp', + tempSchemaPath, + `${TEST_SOLR_CONTAINER_NAME}:${TEST_SOLR_SCHEMA_PATH}` + ]) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +} + +const reloadSolrCoreViaDocker = async (): Promise<void> => { + await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'curl', + '-sS', + `${TEST_SOLR_CORE_ADMIN_URL}?action=RELOAD&core=collection1&wt=json` + ]) +} + +const mergeGeneratedSchemaFragment = ( + currentSchema: string, + generatedSchemaFragment: string +): string => { + const generatedSchemaLines = generatedSchemaFragment.split('\n') + const fieldLines = generatedSchemaLines.filter((line) => line.includes('<field ')) + const copyFieldLines = generatedSchemaLines.filter((line) => line.includes('<copyField ')) + + return replaceSchemaSection( + replaceSchemaSection( + currentSchema, + '<!-- SCHEMA-FIELDS::BEGIN -->', + '<!-- SCHEMA-FIELDS::END -->', + fieldLines + ), + '<!-- SCHEMA-COPY-FIELDS::BEGIN -->', + '<!-- SCHEMA-COPY-FIELDS::END -->', + copyFieldLines + ) +} + +const replaceSchemaSection = ( + schema: string, + beginMarker: string, + endMarker: string, + replacementLines: string[] +): string => { + const beginIndex = schema.indexOf(beginMarker) + const endIndex = schema.indexOf(endMarker) + + if (beginIndex === -1 || endIndex === -1 || beginIndex > endIndex) { + throw new Error(`Could not find Solr schema section ${beginMarker}.`) + } + + return [ + schema.slice(0, beginIndex + beginMarker.length), + '', + replacementLines.join('\n'), + schema.slice(endIndex) + ].join('\n') +} + +const runDockerCommand = async (args: string[]): Promise<string> => { + try { + const { stdout } = await execFileAsync('docker', args, { maxBuffer: 10 * 1024 * 1024 }) + return stdout + } catch (error) { + const execError = error as ExecFileError + throw new Error( + `Docker command failed: docker ${args.join(' ')}. Reason was: ${ + execError.stderr ?? execError.stdout ?? execError.message + }` + ) + } +} diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index d099acb1..075034cc 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -16,7 +16,13 @@ import { } from '../../testHelpers/collections/collectionHelper' import { TestConstants } from '../../testHelpers/TestConstants' import { ReadError, WriteError } from '../../../src' -import { ROOT_COLLECTION_ID } from '../../../src/collections/domain/models/Collection' +import { + ROOT_COLLECTION_ID, + CollectionTheme +} from '../../../src/collections/domain/models/Collection' +import { CollectionThemePayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload' +import { AllowedStorageDrivers } from '../../../src/collections/domain/models/AllowedStorageDrivers' +import { StorageDriver } from '../../../src/core/domain/models/StorageDriver' import { createCollectionUserPermissionsModel, createCollectionUserPermissionsPayload @@ -42,15 +48,64 @@ import { OrderType, SortType } from '../../../src/collections/domain/models/CollectionSearchCriteria' +import { RoleAlias } from '../../../src/roles/domain/models/RoleAlias' describe('CollectionsRepository', () => { const sut: CollectionsRepository = new CollectionsRepository() + const testStorageDriver: StorageDriver = { + name: 'local', + type: 'filesystem', + label: 'Local Storage', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + } + const testStorageDriverResponse = { + data: { + status: 'OK', + data: testStorageDriver + } + } + const testStorageDriverList: AllowedStorageDrivers = { + Filesystem: 'file1', + LocalStack: 'localstack1' + } + const testStorageDriverListResponse = { + data: { + status: 'OK', + data: testStorageDriverList + } + } + const testStorageDriverMessageResponse = { + data: { + status: 'OK', + data: { + message: 'Storage set to: local/Local Storage' + } + } + } + const testDeleteStorageDriverMessageResponse = { + data: { + status: 'OK', + data: { + message: 'Storage driver cleared. Falling back to default: local' + } + } + } const testCollectionSuccessfulResponse = { data: { status: 'OK', data: createCollectionPayload() } } + const testSetDefaultContributorRoleResponse = { + data: { + status: 'OK', + data: { + message: 'Default contributor role has been set' + } + } + } const testCollectionModel = createCollectionModel() beforeEach(() => { @@ -95,6 +150,31 @@ describe('CollectionsRepository', () => { expect(error).toBeInstanceOf(Error) }) }) + + describe('with theme', () => { + test('should return Collection with theme when providing a numeric id and the collection has a theme with only some fields', async () => { + const testThemePayload: CollectionThemePayload = { + id: 1 + } + const testCollectionPayload = createCollectionPayload(testThemePayload) + const testThemeModel: CollectionTheme = { + id: 1 + } + const testCollectionModelWithTheme = createCollectionModel(testThemeModel) + + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: testCollectionPayload + } + }) + + const actual = await sut.getCollection(testCollectionModel.id) + + expect(actual).toStrictEqual(testCollectionModelWithTheme) + }) + }) + describe('by alias id', () => { test('should return a Collection when providing the Collection alias is successful', async () => { jest.spyOn(axios, 'get').mockResolvedValue(testCollectionSuccessfulResponse) @@ -144,6 +224,130 @@ describe('CollectionsRepository', () => { }) }) + describe('getCollectionStorageDriver', () => { + test('should return collection storage driver when request is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testStorageDriverResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/storageDriver` + const expectedRequestConfigApiKey = { + params: { + getEffective: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + + const actual = await sut.getCollectionStorageDriver('test-collection') + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, expectedRequestConfigApiKey) + expect(actual).toStrictEqual(testStorageDriver) + }) + + test('should include getEffective query param when requested', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testStorageDriverResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/storageDriver` + const expectedRequestConfigApiKey = { + params: { + getEffective: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + + await sut.getCollectionStorageDriver('test-collection', true) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, expectedRequestConfigApiKey) + }) + + test('should return error on repository read error', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + let error = undefined as unknown as ReadError + + await sut.getCollectionStorageDriver('test-collection').catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('setCollectionStorageDriver', () => { + test('should call the API with a plain text request payload', async () => { + jest.spyOn(axios, 'put').mockResolvedValue(testStorageDriverMessageResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/storageDriver` + const expectedRequestConfigApiKey = { + params: {}, + headers: { + 'Content-Type': 'text/plain', + 'X-Dataverse-Key': TestConstants.TEST_DUMMY_API_KEY + } + } + + const actual = await sut.setCollectionStorageDriver('test-collection', 'Local Storage') + + expect(axios.put).toHaveBeenCalledWith( + expectedApiEndpoint, + 'Local Storage', + expectedRequestConfigApiKey + ) + expect(actual).toBe('Storage set to: local/Local Storage') + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'put').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + let error = undefined as unknown as WriteError + + await sut + .setCollectionStorageDriver('test-collection', 'Local Storage') + .catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('deleteCollectionStorageDriver', () => { + test('should delete collection storage driver when request is successful', async () => { + jest.spyOn(axios, 'delete').mockResolvedValue(testDeleteStorageDriverMessageResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/storageDriver` + + const actual = await sut.deleteCollectionStorageDriver('test-collection') + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toBe('Storage driver cleared. Falling back to default: local') + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'delete').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + let error = undefined as unknown as WriteError + + await sut.deleteCollectionStorageDriver('test-collection').catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('getAllowedCollectionStorageDrivers', () => { + test('should return allowed collection storage drivers when request is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testStorageDriverListResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/allowedStorageDrivers` + + const actual = await sut.getAllowedCollectionStorageDrivers('test-collection') + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(testStorageDriverList) + }) + + test('should return error on repository read error', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + let error = undefined as unknown as ReadError + + await sut.getAllowedCollectionStorageDrivers('test-collection').catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + describe('createCollection', () => { const testNewCollection = createCollectionDTO() @@ -698,4 +902,32 @@ describe('CollectionsRepository', () => { }) }) }) + + describe('setDefaultContributorRole', () => { + const testRoleAlias = RoleAlias.CURATOR + + test('should call the API', async () => { + jest.spyOn(axios, 'put').mockResolvedValue(testSetDefaultContributorRoleResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/test-collection/defaultContributorRole/${testRoleAlias}` + + await sut.setDefaultContributorRole('test-collection', testRoleAlias) + + expect(axios.put).toHaveBeenCalledWith( + expectedApiEndpoint, + '{}', + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'put').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + let error = undefined as unknown as WriteError + + await sut + .setDefaultContributorRole('test-collection', testRoleAlias) + .catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) }) diff --git a/test/unit/collections/DeleteCollectionStorageDriver.test.ts b/test/unit/collections/DeleteCollectionStorageDriver.test.ts new file mode 100644 index 00000000..f654a17d --- /dev/null +++ b/test/unit/collections/DeleteCollectionStorageDriver.test.ts @@ -0,0 +1,30 @@ +import { WriteError } from '../../../src' +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { DeleteCollectionStorageDriver } from '../../../src/collections/domain/useCases/DeleteCollectionStorageDriver' + +describe('DeleteCollectionStorageDriver (unit)', () => { + test('should delete collection storage driver on repository success', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.deleteCollectionStorageDriver = jest + .fn() + .mockResolvedValue('Storage driver cleared. Falling back to default: local') + const sut = new DeleteCollectionStorageDriver(collectionsRepositoryStub) + + const actual = await sut.execute('test-collection') + + expect(actual).toBe('Storage driver cleared. Falling back to default: local') + expect(collectionsRepositoryStub.deleteCollectionStorageDriver).toHaveBeenCalledWith( + 'test-collection' + ) + }) + + test('should return error result on repository error', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.deleteCollectionStorageDriver = jest + .fn() + .mockRejectedValue(new WriteError('[404] Collection not found')) + const sut = new DeleteCollectionStorageDriver(collectionsRepositoryStub) + + await expect(sut.execute('test-collection')).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/collections/GetAllowedCollectionStorageDrivers.test.ts b/test/unit/collections/GetAllowedCollectionStorageDrivers.test.ts new file mode 100644 index 00000000..eb802980 --- /dev/null +++ b/test/unit/collections/GetAllowedCollectionStorageDrivers.test.ts @@ -0,0 +1,36 @@ +import { ReadError } from '../../../src' +import { AllowedStorageDrivers } from '../../../src/collections/domain/models/AllowedStorageDrivers' +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { GetAllowedCollectionStorageDrivers } from '../../../src/collections/domain/useCases/GetAllowedCollectionStorageDrivers' + +describe('GetAllowedCollectionStorageDrivers (unit)', () => { + const testStorageDrivers: AllowedStorageDrivers = { + Filesystem: 'file1', + LocalStack: 'localstack1' + } + + test('should return allowed collection storage drivers on repository success', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.getAllowedCollectionStorageDrivers = jest + .fn() + .mockResolvedValue(testStorageDrivers) + const sut = new GetAllowedCollectionStorageDrivers(collectionsRepositoryStub) + + const actual = await sut.execute('test-collection') + + expect(actual).toEqual(testStorageDrivers) + expect(collectionsRepositoryStub.getAllowedCollectionStorageDrivers).toHaveBeenCalledWith( + 'test-collection' + ) + }) + + test('should return error result on repository error', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.getAllowedCollectionStorageDrivers = jest + .fn() + .mockRejectedValue(new ReadError('[404] Collection not found')) + const sut = new GetAllowedCollectionStorageDrivers(collectionsRepositoryStub) + + await expect(sut.execute('test-collection')).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/collections/GetCollectionStorageDriver.test.ts b/test/unit/collections/GetCollectionStorageDriver.test.ts new file mode 100644 index 00000000..8472f149 --- /dev/null +++ b/test/unit/collections/GetCollectionStorageDriver.test.ts @@ -0,0 +1,56 @@ +import { ReadError } from '../../../src' +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { GetCollectionStorageDriver } from '../../../src/collections/domain/useCases/GetCollectionStorageDriver' +import { StorageDriver } from '../../../src/core/domain/models/StorageDriver' + +describe('GetCollectionStorageDriver (unit)', () => { + const testStorageDriver: StorageDriver = { + name: 'local', + type: 'filesystem', + label: 'Local Storage', + directUpload: true, + directDownload: true, + uploadOutOfBand: false + } + + test('should return collection storage driver on repository success', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.getCollectionStorageDriver = jest + .fn() + .mockResolvedValue(testStorageDriver) + const sut = new GetCollectionStorageDriver(collectionsRepositoryStub) + + const actual = await sut.execute('test-collection') + + expect(actual).toEqual(testStorageDriver) + expect(collectionsRepositoryStub.getCollectionStorageDriver).toHaveBeenCalledWith( + 'test-collection', + false + ) + }) + + test('should request effective storage driver when requested', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.getCollectionStorageDriver = jest + .fn() + .mockResolvedValue(testStorageDriver) + const sut = new GetCollectionStorageDriver(collectionsRepositoryStub) + + await sut.execute('test-collection', true) + + expect(collectionsRepositoryStub.getCollectionStorageDriver).toHaveBeenCalledWith( + 'test-collection', + true + ) + }) + + test('should return error result on repository error', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.getCollectionStorageDriver = jest + .fn() + .mockRejectedValue(new ReadError('[404] Collection not found')) + const sut = new GetCollectionStorageDriver(collectionsRepositoryStub) + + await expect(sut.execute('test-collection')).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/collections/SetCollectionStorageDriver.test.ts b/test/unit/collections/SetCollectionStorageDriver.test.ts new file mode 100644 index 00000000..ac4fcf13 --- /dev/null +++ b/test/unit/collections/SetCollectionStorageDriver.test.ts @@ -0,0 +1,31 @@ +import { WriteError } from '../../../src' +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { SetCollectionStorageDriver } from '../../../src/collections/domain/useCases/SetCollectionStorageDriver' + +describe('SetCollectionStorageDriver (unit)', () => { + test('should set collection storage driver on repository success', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.setCollectionStorageDriver = jest + .fn() + .mockResolvedValue('Storage set to: local/Local Storage') + const sut = new SetCollectionStorageDriver(collectionsRepositoryStub) + + const actual = await sut.execute('test-collection', 'Local Storage') + + expect(actual).toBe('Storage set to: local/Local Storage') + expect(collectionsRepositoryStub.setCollectionStorageDriver).toHaveBeenCalledWith( + 'test-collection', + 'Local Storage' + ) + }) + + test('should return error result on repository error', async () => { + const collectionsRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionsRepositoryStub.setCollectionStorageDriver = jest + .fn() + .mockRejectedValue(new WriteError('[403] Permission denied')) + const sut = new SetCollectionStorageDriver(collectionsRepositoryStub) + + await expect(sut.execute('test-collection', 'Local Storage')).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/collections/SetDefaultContributorRole.test.ts b/test/unit/collections/SetDefaultContributorRole.test.ts new file mode 100644 index 00000000..f3587b6e --- /dev/null +++ b/test/unit/collections/SetDefaultContributorRole.test.ts @@ -0,0 +1,30 @@ +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { WriteError } from '../../../src' +import { SetDefaultContributorRole } from '../../../src/collections/domain/useCases/SetDefaultContributorRole' +import { RoleAlias } from '../../../src/roles/domain/models/RoleAlias' + +describe('execute', () => { + test('should set default contributor role on repository success', async () => { + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.setDefaultContributorRole = jest.fn().mockResolvedValue(undefined) + const testSetDefaultContributorRole = new SetDefaultContributorRole(collectionRepositoryStub) + + await expect( + testSetDefaultContributorRole.execute(1, RoleAlias.CURATOR) + ).resolves.toBeUndefined() + expect(collectionRepositoryStub.setDefaultContributorRole).toHaveBeenCalledWith(1, 'curator') + }) + + test('should return error result on repository error', async () => { + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.setDefaultContributorRole = jest + .fn() + .mockRejectedValue(new WriteError()) + const testSetDefaultContributorRole = new SetDefaultContributorRole(collectionRepositoryStub) + + await expect(testSetDefaultContributorRole.execute(1, RoleAlias.CURATOR)).rejects.toThrow( + WriteError + ) + expect(collectionRepositoryStub.setDefaultContributorRole).toHaveBeenCalledWith(1, 'curator') + }) +}) diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index b87e05de..67015187 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -26,6 +26,10 @@ import { createDatasetPreviewModel, createDatasetPreviewPayload } from '../../testHelpers/datasets/datasetPreviewHelper' +import { + createDatasetReviewModel, + createDatasetReviewPayload +} from '../../testHelpers/datasets/datasetReviewHelper' import { createDatasetDTO, createDatasetDeaccessionDTO, @@ -790,6 +794,102 @@ describe('DatasetsRepository', () => { }) }) + describe('getDatasetReviews', () => { + const testDatasetReviews = [createDatasetReviewModel()] + const testDatasetReviewsResponse = { + data: { + status: 'OK', + data: { + reviews: [createDatasetReviewPayload()] + } + } + } + + describe('by numeric id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/reviews` + + test('should return dataset reviews when providing id and response is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testDatasetReviewsResponse) + + // API Key auth + let actual = await sut.getDatasetReviews(testDatasetModel.id) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(testDatasetReviews) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + actual = await sut.getDatasetReviews(testDatasetModel.id) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + expect(actual).toStrictEqual(testDatasetReviews) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as ReadError + await sut.getDatasetReviews(testDatasetModel.id).catch((e) => (error = e)) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('by persistent id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/:persistentId/reviews?persistentId=${TestConstants.TEST_DUMMY_PERSISTENT_ID}` + + test('should return dataset reviews when providing persistent id and response is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testDatasetReviewsResponse) + + // API Key auth + let actual = await sut.getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(testDatasetReviews) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + actual = await sut.getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + expect(actual).toStrictEqual(testDatasetReviews) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as ReadError + await sut + .getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + .catch((e) => (error = e)) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(Error) + }) + }) + }) + describe('createDataset', () => { const testNewDataset = createDatasetDTO() const testMetadataBlocks = [createDatasetMetadataBlockModel()] diff --git a/test/unit/datasets/ExportDatasetMetadata.test.ts b/test/unit/datasets/ExportDatasetMetadata.test.ts new file mode 100644 index 00000000..6791859f --- /dev/null +++ b/test/unit/datasets/ExportDatasetMetadata.test.ts @@ -0,0 +1,42 @@ +import { ExportDatasetMetadata } from '../../../src/datasets/domain/useCases/ExportDatasetMetadata' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { ExportedDatasetMetadata } from '../../../src/datasets/domain/models/ExportedDatasetMetadata' +import { DatasetNotNumberedVersion } from '../../../src/datasets/domain/models/DatasetNotNumberedVersion' + +describe('ExportDatasetMetadata.execute', () => { + const testDatasetId = 1 + const testExporter = 'ddi' + + test('should return exported dataset metadata on repository success', async () => { + const expectedMetadata: ExportedDatasetMetadata = { + content: '<codeBook></codeBook>', + contentType: 'application/xml' + } + + const datasetsRepositoryStub: IDatasetsRepository = { + exportDatasetMetadata: jest.fn().mockResolvedValue(expectedMetadata) + } as unknown as IDatasetsRepository + + const sut = new ExportDatasetMetadata(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId, testExporter, DatasetNotNumberedVersion.DRAFT) + + expect(actual).toEqual(expectedMetadata) + expect(datasetsRepositoryStub.exportDatasetMetadata).toHaveBeenCalledWith( + testDatasetId, + testExporter, + DatasetNotNumberedVersion.DRAFT + ) + }) + + test('should throw ReadError on repository failure', async () => { + const datasetsRepositoryStub: IDatasetsRepository = { + exportDatasetMetadata: jest.fn().mockRejectedValue(new ReadError()) + } as unknown as IDatasetsRepository + + const sut = new ExportDatasetMetadata(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId, testExporter)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/datasets/GetDatasetReviews.test.ts b/test/unit/datasets/GetDatasetReviews.test.ts new file mode 100644 index 00000000..f42d023d --- /dev/null +++ b/test/unit/datasets/GetDatasetReviews.test.ts @@ -0,0 +1,29 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { DatasetReview } from '../../../src/datasets/domain/models/DatasetReview' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { GetDatasetReviews } from '../../../src/datasets/domain/useCases/GetDatasetReviews' +import { createDatasetReviewModel } from '../../testHelpers/datasets/datasetReviewHelper' + +describe('GetDatasetReviews', () => { + const testDatasetId = 'doi:10.77777/FK2/AAAAAA' + + test('should return dataset reviews on repository success', async () => { + const testDatasetReviews: DatasetReview[] = [createDatasetReviewModel()] + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetReviews = jest.fn().mockResolvedValue(testDatasetReviews) + const sut = new GetDatasetReviews(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId) + + expect(actual).toEqual(testDatasetReviews) + expect(datasetsRepositoryStub.getDatasetReviews).toHaveBeenCalledWith(testDatasetId) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetReviews = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetDatasetReviews(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/datasets/GetDatasetStorageDriver.test.ts b/test/unit/datasets/GetDatasetStorageDriver.test.ts index bd55d164..02570237 100644 --- a/test/unit/datasets/GetDatasetStorageDriver.test.ts +++ b/test/unit/datasets/GetDatasetStorageDriver.test.ts @@ -1,6 +1,6 @@ import { GetDatasetStorageDriver } from '../../../src/datasets/domain/useCases/GetDatasetStorageDriver' import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' -import { StorageDriver } from '../../../src/datasets/domain/models/StorageDriver' +import { StorageDriver } from '../../../src/core/domain/models/StorageDriver' import { ReadError } from '../../../src/core/domain/repositories/ReadError' describe('GetDatasetStorageDriver (unit)', () => { diff --git a/test/unit/datasets/GetDatasetVersions.test.ts b/test/unit/datasets/GetDatasetVersions.test.ts new file mode 100644 index 00000000..63388882 --- /dev/null +++ b/test/unit/datasets/GetDatasetVersions.test.ts @@ -0,0 +1,39 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { createDatasetModel } from '../../testHelpers/datasets/datasetHelper' +import { GetDatasetVersions } from '../../../src/datasets/domain/useCases/GetDatasetVersions' +import { DatasetVersionSubset } from '../../../src/datasets/domain/models/DatasetVersionSubset' + +const testDatasetId = 1 + +describe('execute', () => { + test('should return dataset versions on repository success', async () => { + const testDatasetVersionsSubset: DatasetVersionSubset = { + versions: [createDatasetModel()] + } + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersions = jest + .fn() + .mockResolvedValue(testDatasetVersionsSubset) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId) + + expect(actual).toEqual(testDatasetVersionsSubset) + expect(datasetsRepositoryStub.getDatasetVersions).toHaveBeenCalledWith( + testDatasetId, + undefined, + undefined, + undefined, + undefined + ) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersions = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/datasets/datasetTransformers.test.ts b/test/unit/datasets/datasetTransformers.test.ts index 7e4185c7..31b7bf5d 100644 --- a/test/unit/datasets/datasetTransformers.test.ts +++ b/test/unit/datasets/datasetTransformers.test.ts @@ -57,4 +57,13 @@ describe('transformNewDatasetModelToRequestPayload', () => { expect(actual).toEqual(expectedNewDatasetRequestPayload) }) + + it('should include the template id at the top level of a new dataset request payload', () => { + const testDataset = { ...createDatasetDTO(), templateId: 3 } + const testMetadataBlocks = [createDatasetMetadataBlockModel()] + + const actual = transformDatasetModelToNewDatasetRequestPayload(testDataset, testMetadataBlocks) + + expect(actual).toEqual({ ...createNewDatasetRequestPayload(), templateId: 3 }) + }) }) diff --git a/test/unit/files/FilesRepository.test.ts b/test/unit/files/FilesRepository.test.ts index 4a9df202..f1729abf 100644 --- a/test/unit/files/FilesRepository.test.ts +++ b/test/unit/files/FilesRepository.test.ts @@ -30,6 +30,7 @@ import { createFileCountsPayload } from '../../testHelpers/files/fileCountsHelper' import { createFilesTotalDownloadSizePayload } from '../../testHelpers/files/filesTotalDownloadSizeHelper' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' import { FileDownloadSizeMode, WriteError } from '../../../src' import { createMultipartFileUploadDestinationModel, @@ -1164,6 +1165,93 @@ describe('FilesRepository', () => { }) }) + describe('getFileCitationByFormat', () => { + describe.each([123, 'doi:10.5072/FK2/TEST/FILE'])('file identifier %s', (fileId) => { + test.each([undefined, '1.0', ':draft', ':latest', ':latest-published'])( + 'should send version %s with authentication', + async (version) => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: 'citation' }) + const identifier = + typeof fileId === 'number' + ? `${fileId}/citation/EndNote` + : `:persistentId/citation/EndNote?persistentId=${fileId}` + + await expect( + sut.getFileCitationByFormat(fileId, FileCitationFormat.ENDNOTE, version) + ).resolves.toBe('citation') + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/access/datafile/${identifier}`, + { + ...TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY, + params: version === undefined ? {} : { version } + } + ) + } + ) + }) + + test.each([ + { + format: FileCitationFormat.ENDNOTE, + apiValue: 'EndNote', + contentType: 'XML', + responseData: '<?xml version="1.0" encoding="UTF-8"?><xml><records></records></xml>', + expected: '<?xml version="1.0" encoding="UTF-8"?><xml><records></records></xml>' + }, + { + format: FileCitationFormat.RIS, + apiValue: 'RIS', + contentType: 'plain text', + responseData: 'TY - DATA\nT1 - Test\nER - ', + expected: 'TY - DATA\nT1 - Test\nER - ' + }, + { + format: FileCitationFormat.BIBTEX, + apiValue: 'BibTeX', + contentType: 'plain text', + responseData: '@article{test}', + expected: '@article{test}' + }, + { + format: FileCitationFormat.CSL, + apiValue: 'CSL', + contentType: 'JSON', + // axios auto-parses JSON responses to objects; the repository stringifies them back. + responseData: [{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }], + expected: JSON.stringify([{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }]) + }, + { + format: FileCitationFormat.INTERNAL, + apiValue: 'Internal', + contentType: 'HTML', + responseData: '<a href="https://doi.org/10.5072/FK2/TEST">Test Dataset</a>', + expected: '<a href="https://doi.org/10.5072/FK2/TEST">Test Dataset</a>' + } + ])( + 'should call /citation/$apiValue and return $contentType citation for $apiValue format', + async ({ format, apiValue, responseData, expected }) => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: responseData }) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/access/datafile/${testFile.id}/citation/${apiValue}` + + const actual = await sut.getFileCitationByFormat(testFile.id, format) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toEqual(expected) + } + ) + + test('should return error on repository read error', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect( + sut.getFileCitationByFormat(testFile.id, FileCitationFormat.BIBTEX) + ).rejects.toThrow(ReadError) + }) + }) + describe('deleteFile', () => { describe('by numeric id', () => { test('should return undefined on success', async () => { diff --git a/test/unit/files/GetFileCitationByFormat.test.ts b/test/unit/files/GetFileCitationByFormat.test.ts new file mode 100644 index 00000000..23a3c7fc --- /dev/null +++ b/test/unit/files/GetFileCitationByFormat.test.ts @@ -0,0 +1,81 @@ +import { ReadError } from '../../../src' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { GetFileCitationByFormat } from '../../../src/files/domain/useCases/GetFileCitationByFormat' + +describe('execute', () => { + const testId = 1 + + test.each([ + { + format: FileCitationFormat.ENDNOTE, + contentType: 'XML', + citation: '<?xml version="1.0" encoding="UTF-8"?><xml><records></records></xml>' + }, + { + format: FileCitationFormat.RIS, + contentType: 'plain text', + citation: 'TY - DATA\nT1 - Test\nER - ' + }, + { + format: FileCitationFormat.BIBTEX, + contentType: 'plain text', + citation: '@article{test}' + }, + { + format: FileCitationFormat.CSL, + contentType: 'JSON', + citation: JSON.stringify([{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }]) + }, + { + format: FileCitationFormat.INTERNAL, + contentType: 'HTML', + citation: '<a href="https://doi.org/10.5072/FK2/TEST">Test Dataset</a>' + } + ])( + 'should return file citation in $contentType when format is $format', + async ({ format, citation }) => { + const filesRepositoryStub = <IFilesRepository>{} + filesRepositoryStub.getFileCitationByFormat = jest.fn().mockResolvedValue(citation) + + const sut = new GetFileCitationByFormat(filesRepositoryStub) + + const actual = await sut.execute(testId, format) + + expect(actual).toEqual(citation) + expect(filesRepositoryStub.getFileCitationByFormat).toHaveBeenCalledWith( + testId, + format, + undefined + ) + } + ) + + test.each(['1.0', ':draft', ':latest', ':latest-published'])( + 'should forward dataset version %s and a persistent identifier', + async (version) => { + const repository = <IFilesRepository>{} + repository.getFileCitationByFormat = jest.fn().mockResolvedValue('citation') + const sut = new GetFileCitationByFormat(repository) + const fileId = 'doi:10.5072/FK2/TEST/FILE' + + await expect(sut.execute(fileId, FileCitationFormat.ENDNOTE, version)).resolves.toBe( + 'citation' + ) + expect(repository.getFileCitationByFormat).toHaveBeenCalledWith( + fileId, + FileCitationFormat.ENDNOTE, + version + ) + } + ) + + test('should return error result on repository error', async () => { + const filesRepositoryStub = <IFilesRepository>{} + filesRepositoryStub.getFileCitationByFormat = jest.fn().mockRejectedValue(new ReadError()) + + const sut = new GetFileCitationByFormat(filesRepositoryStub) + + await expect(sut.execute(testId, FileCitationFormat.BIBTEX)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts b/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts new file mode 100644 index 00000000..002f75e8 --- /dev/null +++ b/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts @@ -0,0 +1,32 @@ +import { ReadError } from '../../../src' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { DownloadGuestbookResponsesByCollectionId } from '../../../src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId' + +describe('DownloadGuestbookResponsesByCollectionId', () => { + const collectionIdOrAlias = 'collectionAlias' + const csvResponse = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + + test('should download guestbook responses for collection', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest.fn().mockResolvedValue(csvResponse) + + const sut = new DownloadGuestbookResponsesByCollectionId(repository) + const actual = await sut.execute(collectionIdOrAlias) + + expect(repository.downloadGuestbookResponsesByCollectionId).toHaveBeenCalledWith( + collectionIdOrAlias + ) + expect(actual).toEqual(csvResponse) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new DownloadGuestbookResponsesByCollectionId(repository) + + await expect(sut.execute(collectionIdOrAlias)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts b/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts new file mode 100644 index 00000000..857829de --- /dev/null +++ b/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts @@ -0,0 +1,34 @@ +import { ReadError } from '../../../src' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { DownloadGuestbookResponsesOfAGuestbook } from '../../../src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook' + +describe('DownloadGuestbookResponsesOfAGuestbook', () => { + const collectionIdOrAlias = 'collectionAlias' + const guestbookId = 12 + const csvResponse = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + + test('should download guestbook responses for one guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest.fn().mockResolvedValue(csvResponse) + + const sut = new DownloadGuestbookResponsesOfAGuestbook(repository) + const actual = await sut.execute(collectionIdOrAlias, guestbookId) + + expect(repository.downloadGuestbookResponsesByCollectionId).toHaveBeenCalledWith( + collectionIdOrAlias, + guestbookId + ) + expect(actual).toEqual(csvResponse) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new DownloadGuestbookResponsesOfAGuestbook(repository) + + await expect(sut.execute(collectionIdOrAlias, guestbookId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/EditGuestbook.test.ts b/test/unit/guestbooks/EditGuestbook.test.ts new file mode 100644 index 00000000..4ac358ec --- /dev/null +++ b/test/unit/guestbooks/EditGuestbook.test.ts @@ -0,0 +1,56 @@ +import { WriteError } from '../../../src' +import { EditGuestbookDTO } from '../../../src/guestbooks/domain/dtos/EditGuestbookDTO' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { EditGuestbook } from '../../../src/guestbooks/domain/useCases/EditGuestbook' + +describe('EditGuestbook', () => { + const editGuestbookDTO: EditGuestbookDTO = { + name: 'my edited guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + createTime: '2026-06-12T00:00:00Z', + customQuestions: [ + { + id: 1, + question: "how's your day", + required: true, + displayOrder: 0, + type: 'text', + hidden: false + }, + { + question: 'What color car do you drive', + required: true, + displayOrder: 1, + type: 'options', + hidden: false, + optionValues: [ + { id: 10, value: 'Red', displayOrder: 0 }, + { value: 'White', displayOrder: 1 } + ] + } + ] + } + + test('should edit guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.editGuestbook = jest.fn().mockResolvedValue(undefined) + + const sut = new EditGuestbook(repository) + const actual = await sut.execute(123, editGuestbookDTO) + + expect(repository.editGuestbook).toHaveBeenCalledWith(123, editGuestbookDTO) + expect(actual).toBeUndefined() + }) + + test('should throw WriteError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.editGuestbook = jest.fn().mockRejectedValue(new WriteError()) + const sut = new EditGuestbook(repository) + + await expect(sut.execute(123, editGuestbookDTO)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts b/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts new file mode 100644 index 00000000..9a1aac4e --- /dev/null +++ b/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts @@ -0,0 +1,74 @@ +import { ReadError } from '../../../src' +import { + EventType, + GuestbookResponse, + GuestbookResponseSubset +} from '../../../src/guestbooks/domain/models/GuestbookResponse' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { GetGuestbookResponsesByGuestbookId } from '../../../src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId' + +describe('GetGuestbookResponsesByGuestbookId', () => { + const guestbookId = 12 + const limit = 10 + const offset = 0 + const guestbookResponses: GuestbookResponse[] = [ + { + id: 13, + dataset: 'Replication Data for:', + datasetPid: 'FK2/BQEPWW', + date: '2026-06-08T23:50:49Z', + type: EventType.DOWNLOAD, + fileName: 'dp_statistics_for_grade_grouped_by_student_id.html', + fileId: 3, + name: 'Guest', + email: 'guest@example.edu', + customQuestions: [ + { + question: 'What is your intended use?', + response: 'Teaching' + } + ] + } + ] + const guestbookResponseSubset: GuestbookResponseSubset = { + guestbookResponses, + totalGuestbookResponseCount: guestbookResponses.length + } + + test('should return guestbook responses for one guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest + .fn() + .mockResolvedValue(guestbookResponseSubset) + + const sut = new GetGuestbookResponsesByGuestbookId(repository) + const actual = await sut.execute(guestbookId) + + expect(repository.getGuestbookResponsesByGuestbookId).toHaveBeenCalledWith( + guestbookId, + limit, + offset + ) + expect(actual).toEqual(guestbookResponseSubset) + }) + + test('should pass pagination to repository', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest + .fn() + .mockResolvedValue(guestbookResponseSubset) + + const sut = new GetGuestbookResponsesByGuestbookId(repository) + await sut.execute(guestbookId, 25, 50) + + expect(repository.getGuestbookResponsesByGuestbookId).toHaveBeenCalledWith(guestbookId, 25, 50) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetGuestbookResponsesByGuestbookId(repository) + + await expect(sut.execute(guestbookId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts index 527e7b7f..d912e283 100644 --- a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts +++ b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts @@ -4,7 +4,7 @@ import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositori import { GetGuestbooksByCollectionId } from '../../../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId' describe('GetGuestbooksByCollectionId', () => { - const guestbooks: Guestbook[] = [ + const guestbooksWithoutStats: Guestbook[] = [ { id: 12, name: 'test', @@ -18,17 +18,68 @@ describe('GetGuestbooksByCollectionId', () => { dataverseId: 10 } ] + const guestbooksWithStats: Guestbook[] = [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10, + usageCount: 3, + responseCount: 2 + } + ] const collectionId = 'collectionAlias' test('should return guestbooks for collection', async () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository - repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooks) + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) const sut = new GetGuestbooksByCollectionId(repository) const actual = await sut.execute(collectionId) expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId) - expect(actual).toEqual(guestbooks) + expect(actual).toEqual(guestbooksWithoutStats) + expect(actual[0].usageCount).toBeUndefined() + expect(actual[0].responseCount).toBeUndefined() + }) + + test('should request guestbooks with stats when includeStats is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, true, false) + expect(actual).toEqual(guestbooksWithStats) + }) + + test('should request guestbooks with inherited guestbooks when includeInherited is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, false, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, false, true) + expect(actual).toEqual(guestbooksWithoutStats) + }) + + test('should request guestbooks with options object when includeInherited is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, undefined, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, false, true) + expect(actual).toEqual(guestbooksWithoutStats) }) test('should throw ReadError when repository fails', async () => { diff --git a/test/unit/guestbooks/GuestbooksRepository.test.ts b/test/unit/guestbooks/GuestbooksRepository.test.ts new file mode 100644 index 00000000..c81f80b9 --- /dev/null +++ b/test/unit/guestbooks/GuestbooksRepository.test.ts @@ -0,0 +1,349 @@ +import axios from 'axios' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { GuestbooksRepository } from '../../../src/guestbooks/infra/repositories/GuestbooksRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { TestConstants } from '../../testHelpers/TestConstants' +import { EventType } from '../../../src/guestbooks/domain/models/GuestbookResponse' +import { EditGuestbookDTO } from '../../../src/guestbooks/domain/dtos/EditGuestbookDTO' + +describe('GuestbooksRepository', () => { + const sut = new GuestbooksRepository() + const collectionIdOrAlias = 'collectionAlias' + const guestbooksResponseWithoutStats = { + data: { + status: 'OK', + data: [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10 + } + ] + } + } + const guestbooksResponse = { + data: { + status: 'OK', + data: [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10, + usageCount: 3, + responseCount: 2 + } + ] + } + } + const guestbookResponsesResponse = { + data: { + status: 'OK', + data: [ + { + id: 13, + dataset: 'Replication Data for:', + datasetPid: 'FK2/BQEPWW', + date: '2026-06-08T23:50:49Z', + type: EventType.DOWNLOAD, + fileName: 'dp_statistics_for_grade_grouped_by_student_id.html', + fileId: 3, + name: 'Guest', + email: 'guest@example.edu' + } + ] + } + } + const guestbookResponsesOfAGuestbookResponse = { + data: { + status: 'OK', + data: { + guestbook: guestbooksResponse.data.data[0], + responses: guestbookResponsesResponse.data.data, + pagination: { + next: `${TestConstants.TEST_API_URL}/guestbooks/12/responses?limit=10&offset=10`, + totalResponses: 1 + } + } + } + } + const guestbookResponsesCsv = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + const editGuestbookDTO: EditGuestbookDTO = { + name: 'edited test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + createTime: '2026-06-12T00:00:00Z', + customQuestions: [ + { + id: 1, + question: "how's your day", + required: true, + displayOrder: 0, + type: 'text', + hidden: false + }, + { + question: 'What color car do you drive', + required: true, + displayOrder: 1, + type: 'options', + hidden: false, + optionValues: [ + { id: 10, value: 'Red', displayOrder: 0 }, + { value: 'White', displayOrder: 1 } + ] + } + ] + } + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + + jest.clearAllMocks() + }) + + describe('getGuestbooksByCollectionId', () => { + test('should list guestbooks without stats by default', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponseWithoutStats) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(guestbooksResponseWithoutStats.data.data) + expect(actual[0].usageCount).toBeUndefined() + expect(actual[0].responseCount).toBeUndefined() + }) + + test('should list guestbooks with stats when includeStats is true', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponse) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias, true) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + { + params: { + includeStats: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual[0].usageCount).toBe(3) + expect(actual[0].responseCount).toBe(2) + }) + + test('should list guestbooks with inherited guestbooks when includeInherited is true', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponseWithoutStats) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias, false, true) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + { + params: { + includeInherited: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual(guestbooksResponseWithoutStats.data.data) + }) + + test('should list guestbooks with stats and inherited guestbooks when both options are true', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponse) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias, true, true) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + { + params: { + includeStats: true, + includeInherited: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual(guestbooksResponse.data.data) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.getGuestbooksByCollectionId(collectionIdOrAlias, true)).rejects.toThrow( + ReadError + ) + }) + }) + + describe('editGuestbook', () => { + test('should edit guestbook', async () => { + jest.spyOn(axios, 'put').mockResolvedValue({ data: { status: 'OK' } }) + + const actual = await sut.editGuestbook(12, editGuestbookDTO) + + expect(axios.put).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12`, + JSON.stringify(editGuestbookDTO), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + const requestPayload = JSON.parse( + (axios.put as jest.Mock).mock.calls[0][1] as string + ) as EditGuestbookDTO & { id?: number } + expect(requestPayload.id).toBeUndefined() + expect(requestPayload?.customQuestions?.[0].id).toBe(1) + expect(requestPayload?.customQuestions?.[1].optionValues?.[0].id).toBe(10) + expect(actual).toBeUndefined() + }) + + test('should edit guestbook without custom questions in payload', async () => { + jest.spyOn(axios, 'put').mockResolvedValue({ data: { status: 'OK' } }) + const editGuestbookDTOWithoutCustomQuestions: EditGuestbookDTO = { + name: editGuestbookDTO.name, + enabled: editGuestbookDTO.enabled, + emailRequired: editGuestbookDTO.emailRequired, + nameRequired: editGuestbookDTO.nameRequired, + institutionRequired: editGuestbookDTO.institutionRequired, + positionRequired: editGuestbookDTO.positionRequired, + createTime: editGuestbookDTO.createTime + } + + const actual = await sut.editGuestbook(12, editGuestbookDTOWithoutCustomQuestions) + + expect(axios.put).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12`, + JSON.stringify(editGuestbookDTOWithoutCustomQuestions), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + const requestPayload = JSON.parse( + (axios.put as jest.Mock).mock.calls[0][1] as string + ) as EditGuestbookDTO + expect(requestPayload.customQuestions).toBeUndefined() + expect(actual).toBeUndefined() + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'put').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.editGuestbook(12, editGuestbookDTO)).rejects.toThrow() + }) + }) + + describe('getGuestbookResponsesByGuestbookId', () => { + test('should list guestbook responses for a guestbook', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbookResponsesOfAGuestbookResponse) + + const actual = await sut.getGuestbookResponsesByGuestbookId(12) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12/responses`, + { + params: { + limit: 10, + offset: 0 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual({ + guestbookResponses: guestbookResponsesOfAGuestbookResponse.data.data.responses, + totalGuestbookResponseCount: + guestbookResponsesOfAGuestbookResponse.data.data.pagination.totalResponses + }) + }) + + test('should list guestbook responses with pagination', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbookResponsesOfAGuestbookResponse) + + const actual = await sut.getGuestbookResponsesByGuestbookId(12, 25, 50) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12/responses`, + { + params: { + limit: 25, + offset: 50 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual({ + guestbookResponses: guestbookResponsesOfAGuestbookResponse.data.data.responses, + totalGuestbookResponseCount: + guestbookResponsesOfAGuestbookResponse.data.data.pagination.totalResponses + }) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.getGuestbookResponsesByGuestbookId(12)).rejects.toThrow(ReadError) + }) + }) + + describe('downloadGuestbookResponsesByCollectionId', () => { + test('should download guestbook responses for collection', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: guestbookResponsesCsv }) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/${collectionIdOrAlias}/guestbookResponses`, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(guestbookResponsesCsv) + }) + + test('should download guestbook responses filtered by guestbook id', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: guestbookResponsesCsv }) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias, 12) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/${collectionIdOrAlias}/guestbookResponses`, + { + params: { + guestbookId: 12 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual(guestbookResponsesCsv) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect( + sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias) + ).rejects.toThrow(ReadError) + }) + }) +}) diff --git a/test/unit/templates/UpdateTemplateLicenseTerms.test.ts b/test/unit/templates/UpdateTemplateLicenseTerms.test.ts new file mode 100644 index 00000000..3fdabded --- /dev/null +++ b/test/unit/templates/UpdateTemplateLicenseTerms.test.ts @@ -0,0 +1,36 @@ +import { WriteError } from '../../../src' +import { UpdateTemplateLicenseTermsDTO } from '../../../src/templates/domain/dtos/UpdateTemplateLicenseTermsDTO' +import { ITemplatesRepository } from '../../../src/templates/domain/repositories/ITemplatesRepository' +import { UpdateTemplateLicenseTerms } from '../../../src/templates/domain/useCases/UpdateTemplateLicenseTerms' + +describe('UpdateTemplateLicenseTerms.execute', () => { + const testTemplateId = 1 + const testPayload = { + customTerms: { + termsOfUse: 'custom terms' + } + } as UpdateTemplateLicenseTermsDTO + + test('should call repository with provided payload', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateLicenseTerms = jest.fn().mockResolvedValue(undefined) + const sut = new UpdateTemplateLicenseTerms(templatesRepositoryStub) + + await sut.execute(testTemplateId, testPayload) + + expect(templatesRepositoryStub.updateTemplateLicenseTerms).toHaveBeenCalledWith( + testTemplateId, + testPayload + ) + }) + + test('should throw WriteError on repository error', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateLicenseTerms = jest + .fn() + .mockRejectedValue(new WriteError()) + const sut = new UpdateTemplateLicenseTerms(templatesRepositoryStub) + + await expect(sut.execute(testTemplateId, testPayload)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/templates/UpdateTemplateMetadata.test.ts b/test/unit/templates/UpdateTemplateMetadata.test.ts new file mode 100644 index 00000000..d0d7057e --- /dev/null +++ b/test/unit/templates/UpdateTemplateMetadata.test.ts @@ -0,0 +1,45 @@ +import { WriteError } from '../../../src' +import { UpdateTemplateMetadataDTO } from '../../../src/templates/domain/dtos/UpdateTemplateMetadataDTO' +import { ITemplatesRepository } from '../../../src/templates/domain/repositories/ITemplatesRepository' +import { UpdateTemplateMetadata } from '../../../src/templates/domain/useCases/UpdateTemplateMetadata' + +describe('UpdateTemplateMetadata.execute', () => { + const testTemplateId = 1 + const testPayload = { name: 'updated template name' } as UpdateTemplateMetadataDTO + + test('should call repository with replace=false by default', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateMetadata = jest.fn().mockResolvedValue(undefined) + const sut = new UpdateTemplateMetadata(templatesRepositoryStub) + + await sut.execute(testTemplateId, testPayload) + + expect(templatesRepositoryStub.updateTemplateMetadata).toHaveBeenCalledWith( + testTemplateId, + testPayload, + false + ) + }) + + test('should call repository with provided replace flag', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateMetadata = jest.fn().mockResolvedValue(undefined) + const sut = new UpdateTemplateMetadata(templatesRepositoryStub) + + await sut.execute(testTemplateId, testPayload, true) + + expect(templatesRepositoryStub.updateTemplateMetadata).toHaveBeenCalledWith( + testTemplateId, + testPayload, + true + ) + }) + + test('should throw WriteError on repository error', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateMetadata = jest.fn().mockRejectedValue(new WriteError()) + const sut = new UpdateTemplateMetadata(templatesRepositoryStub) + + await expect(sut.execute(testTemplateId, testPayload)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/templates/UpdateTemplateTermsOfAccess.test.ts b/test/unit/templates/UpdateTemplateTermsOfAccess.test.ts new file mode 100644 index 00000000..20bb1c86 --- /dev/null +++ b/test/unit/templates/UpdateTemplateTermsOfAccess.test.ts @@ -0,0 +1,35 @@ +import { WriteError } from '../../../src' +import { ITemplatesRepository } from '../../../src/templates/domain/repositories/ITemplatesRepository' +import { UpdateTemplateTermsOfAccess } from '../../../src/templates/domain/useCases/UpdateTemplateTermsOfAccess' +import { TermsOfAccess } from '../../../src/datasets/domain/models/Dataset' + +describe('UpdateTemplateTermsOfAccess.execute', () => { + const testTemplateId = 1 + const testTermsOfAccess = { + fileAccessRequest: true, + termsOfAccessForRestrictedFiles: 'restricted' + } as TermsOfAccess + + test('should call repository with provided terms of access', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateTermsOfAccess = jest.fn().mockResolvedValue(undefined) + const sut = new UpdateTemplateTermsOfAccess(templatesRepositoryStub) + + await sut.execute(testTemplateId, testTermsOfAccess) + + expect(templatesRepositoryStub.updateTemplateTermsOfAccess).toHaveBeenCalledWith( + testTemplateId, + testTermsOfAccess + ) + }) + + test('should throw WriteError on repository error', async () => { + const templatesRepositoryStub: ITemplatesRepository = {} as ITemplatesRepository + templatesRepositoryStub.updateTemplateTermsOfAccess = jest + .fn() + .mockRejectedValue(new WriteError()) + const sut = new UpdateTemplateTermsOfAccess(templatesRepositoryStub) + + await expect(sut.execute(testTemplateId, testTermsOfAccess)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/testHelpers/htmlNormalizer.test.ts b/test/unit/testHelpers/htmlNormalizer.test.ts new file mode 100644 index 00000000..7d76b12d --- /dev/null +++ b/test/unit/testHelpers/htmlNormalizer.test.ts @@ -0,0 +1,109 @@ +import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer' +import { + CONTENT_FIELD_WITH_ALL_TAGS, + SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS +} from '../../testHelpers/collections/collectionHelper' + +describe('normalizeHtml', () => { + describe('differences the server may introduce', () => { + test('should ignore the order of attributes', () => { + expect( + normalizeHtml('<a target="_blank" rel="nofollow" class="x" href="https://a.b">t</a>') + ).toEqual( + normalizeHtml('<a class="x" href="https://a.b" rel="nofollow" target="_blank">t</a>') + ) + }) + + test('should ignore indentation introduced between block elements', () => { + expect(normalizeHtml('<ul><li><p>Item</p></li></ul>')).toEqual( + normalizeHtml('<ul>\n <li>\n <p>Item</p>\n </li>\n</ul>') + ) + }) + + test('should ignore indentation around the content of a block element', () => { + expect(normalizeHtml('<p>Item</p>')).toEqual(normalizeHtml('<p>\n Item\n</p>')) + }) + + test('should ignore the case of tag and attribute names', () => { + expect(normalizeHtml('<P CLASS="x">t</P>')).toEqual(normalizeHtml('<p class="x">t</p>')) + }) + + test('should ignore the order of attributes whose values contain angle brackets', () => { + expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).toEqual( + normalizeHtml('<a href="/example" title="A > B">link</a>') + ) + }) + + test('should ignore the order of attributes quoted with single quotes', () => { + expect(normalizeHtml("<a title='A > B' href='/example'>link</a>")).toEqual( + normalizeHtml('<a href="/example" title="A > B">link</a>') + ) + }) + + test('should ignore equivalent spellings of a void element', () => { + expect(normalizeHtml('<p>a<br>b</p>')).toEqual(normalizeHtml('<p>a<br/>b</p>')) + }) + + test('should ignore equivalent spellings of an escaped character', () => { + expect(normalizeHtml('<p>a & b</p>')).toEqual(normalizeHtml('<p>a & b</p>')) + }) + + test('should treat the sent and server-returned forms of the featured item fixture as equal', () => { + expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).toEqual( + normalizeHtml(SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS) + ) + }) + }) + + describe('differences that must still be detected', () => { + test('should not ignore differing text content', () => { + expect(normalizeHtml('<p>Item</p>')).not.toEqual(normalizeHtml('<p>Other</p>')) + }) + + test('should not ignore differing attribute values', () => { + expect(normalizeHtml('<a href="https://a.b">t</a>')).not.toEqual( + normalizeHtml('<a href="https://evil.example">t</a>') + ) + }) + + test('should not ignore a dropped attribute', () => { + expect(normalizeHtml('<a rel="nofollow" href="https://a.b">t</a>')).not.toEqual( + normalizeHtml('<a href="https://a.b">t</a>') + ) + }) + + test('should not ignore a differing attribute value that contains angle brackets', () => { + expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).not.toEqual( + normalizeHtml('<a title="A > C" href="/example">link</a>') + ) + }) + + test('should not ignore differing structure', () => { + expect(normalizeHtml('<ul><li>a</li><li>b</li></ul>')).not.toEqual( + normalizeHtml('<ul><li>a</li></ul>') + ) + }) + + test('should not ignore a changed tag', () => { + expect(normalizeHtml('<strong>t</strong>')).not.toEqual(normalizeHtml('<em>t</em>')) + }) + + test('should preserve whitespace inside a preformatted block', () => { + expect(normalizeHtml('<pre><code> indented\n lines</code></pre>')).not.toEqual( + normalizeHtml('<pre><code>indented lines</code></pre>') + ) + }) + + test('should preserve the whitespace that separates inline elements in the featured item fixture', () => { + expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).not.toEqual( + normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS.replace('</strong> <em', '</strong><em')) + ) + }) + + test('should preserve whitespace that separates inline elements', () => { + expect(normalizeHtml('<p><em>a</em> <em>b</em></p>')).not.toEqual( + normalizeHtml('<p><em>a</em><em>b</em></p>') + ) + }) + }) +})