diff --git a/README.md b/README.md index 82c86912..f91d7c92 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,13 @@ npx @eslint/config-inspector In VSCode, [make sure](https://eslint.nuxt.com/packages/module#vs-code) your ESlint VS Code extension (vscode-eslint) is at least v3.0.10 (released June 2024). Turn on the 'Format on Save' setting. +# NPM dependency notes + +To document why some of package.json is the way it is (since JSON doesn't support comments): + +1. The Vue version is overridden because of the issue described in the 'tip' in the installation section of https://pinia.vuejs.org/ssr/nuxt.html +1. `@rollup/rollup-linux-x64-gnu` is an optional dependency as a fix for the issue that Rollup describes [here](https://github.com/rollup/rollup/blob/f83b3151e93253a45f5b8ccb9ccb2e04214bc490/native.js#L59) and which occurred for us when doing an installation with npm on Docker on CI. Their suggested fix does not work for our use case, because removing package-lock.json prevents the use of `npm ci`, so instead we use the solution suggested [here](https://github.com/vitejs/vite/discussions/15532#discussioncomment-10192839). + # CI Playwright tests produce HTML reports when they run, whether on CI or not, showing visual snapshots at each timestep in each test. If you need to open these, follow the instructions [here](https://playwright.dev/docs/ci-intro#html-report), particularly '[Viewing the HTML report](https://playwright.dev/docs/ci-intro#viewing-the-html-report)'. diff --git a/assets/icons/index.js b/assets/icons/index.js index c6d1acd3..1b328705 100644 --- a/assets/icons/index.js +++ b/assets/icons/index.js @@ -1,25 +1,35 @@ import { + cilArrowRight, cilArrowThickToLeft, cilBookmark, + cilBug, cilChevronLeft, cilCloudDownload, cilGlobeAlt, cilHistory, + cilIndustry, + cilLink, cilMenu, cilNoteAdd, cilPlus, cilShareAlt, + cilShieldAlt, } from "@coreui/icons"; export const iconsSet = { - cilMenu, - cilCloudDownload, - cilPlus, + cilArrowRight, + cilArrowThickToLeft, cilBookmark, + cilBug, + cilChevronLeft, + cilCloudDownload, + cilGlobeAlt, cilHistory, - cilShareAlt, + cilIndustry, + cilLink, + cilMenu, cilNoteAdd, - cilGlobeAlt, - cilArrowThickToLeft, - cilChevronLeft, + cilPlus, + cilShareAlt, + cilShieldAlt, }; diff --git a/assets/scss/_theme.scss b/assets/scss/_theme.scss index 8ecd6308..1020b58f 100644 --- a/assets/scss/_theme.scss +++ b/assets/scss/_theme.scss @@ -64,4 +64,59 @@ body { } } } - \ No newline at end of file + +.form-label { + color: rgba(37, 43, 54, 0.65); +} + +.row > span.form-icon, .row > .form-label { + width: auto; // Avoids width: 100% being applied when inside a .row + padding: 0; +} + +// Overriding '0' to add the !important flag +.btn-group > .btn:not(:last-child):not(.dropdown-toggle) { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.btn-group > label.btn { + border-radius: 0.75rem; +} + +.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show { + background-color: var(--cui-btn-color); +} + +.btn-group-lg > label.btn { + border-radius: 1rem; +} + +.form-select, .btn { + border-radius: 0.75rem; + + &.form-select-lg, &.btn-lg { + border-radius: 1rem; + } +} + +// Undoes styling for '.btn-check + .btn:hover' to (nearly) match that of '.btn:hover' +.btn-group { + .btn-check + .btn:hover { + color: var(--cui-btn-hover-color); + background-color: var(--cui-btn-hover-bg); + border-color: var(--cui-btn-hover-border-color); + } + + .btn-check:not(:checked) + .btn:hover { + background-color: var(--cui-btn-hover-bg); + } +} + +.button-group-container .btn-outline-primary { + background-color: var(--cui-tertiary-bg); +} + +.btn:disabled, .btn.disabled, fieldset:disabled .btn { + opacity: 0.5; +} diff --git a/assets/scss/_variables.scss b/assets/scss/_variables.scss index d4574c21..dd74b4f7 100644 --- a/assets/scss/_variables.scss +++ b/assets/scss/_variables.scss @@ -1,6 +1,8 @@ $app-header-height: 70px; $app-header-margin-bottom: 1.5rem; $min-wrapper-height: calc(100dvh - $app-header-height - $app-header-margin-bottom); +$container-padding: 1.5rem; +$sidebar-narrow-width: 4rem; // Imperial Brand // ============== @@ -43,3 +45,4 @@ $grid-breakpoints: ( xl: 1200px, xxl: 1400px ); +$cui-tertiary-bg: rgb(243, 244, 247) diff --git a/components/AppHeader.vue b/components/AppHeader.vue index 9144d66a..05bf5089 100644 --- a/components/AppHeader.vue +++ b/components/AppHeader.vue @@ -75,7 +75,6 @@ onBeforeUnmount(() => { .header-toggler { margin-inline-start: -14px; } -$sidebar-narrow-width: 4rem; .full-breadcrumb-container { min-height: 2.5rem !important; background-color: rgb(250, 250, 250); diff --git a/components/ParameterForm.vue b/components/ParameterForm.vue new file mode 100644 index 00000000..902936c3 --- /dev/null +++ b/components/ParameterForm.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/components/ParameterIcon.vue b/components/ParameterIcon.vue new file mode 100644 index 00000000..beb452ba --- /dev/null +++ b/components/ParameterIcon.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/docker/Dockerfile b/docker/Dockerfile index eed41241..0293da4d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,6 +7,7 @@ ENV NODE_ENV=production WORKDIR /src COPY . . +RUN npm install -g npm@latest RUN npm ci # Generate the prisma client code diff --git a/layouts/default.vue b/layouts/default.vue index 7f2ad0b3..7479a6a8 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -9,7 +9,7 @@ />
- +
@@ -27,8 +27,6 @@ function handleToggleSidebarVisibility() { diff --git a/pages/scenarios/new.vue b/pages/scenarios/new.vue new file mode 100644 index 00000000..249b9af2 --- /dev/null +++ b/pages/scenarios/new.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/public/icons/wikimediaVirusIcon.svg b/public/icons/wikimediaVirusIcon.svg new file mode 100644 index 00000000..d990c7f7 --- /dev/null +++ b/public/icons/wikimediaVirusIcon.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/api/metadata.get.ts b/server/api/metadata.get.ts new file mode 100644 index 00000000..a046af46 --- /dev/null +++ b/server/api/metadata.get.ts @@ -0,0 +1,12 @@ +import { getMetadata } from "@/server/handlers/metadata"; +import { defineRApiEventHandler } from "~/server/utils/defineRApiEventHandler"; +import type { MetadataResponse } from "@/types/daedalusApiResponseTypes"; + +export default defineRApiEventHandler( + async (event): Promise => { + // Delegate to getMetadata so that the logic can be unit-tested. + const metadataResponse = await getMetadata(event); + + return metadataResponse; + }, +); diff --git a/server/api/versions.get.ts b/server/api/versions.get.ts index 389d9285..bcb2bcc5 100644 --- a/server/api/versions.get.ts +++ b/server/api/versions.get.ts @@ -1,13 +1,12 @@ import { getVersionData } from "@/server/handlers/versions"; -import { defineEventHandlerWithErrors } from "@/server/utils/defineEventHandlerWithErrors"; +import { defineRApiEventHandler } from "@/server/utils/defineRApiEventHandler"; import type { VersionDataResponse } from "@/types/daedalusApiResponseTypes"; -export default defineEventHandlerWithErrors( - // TODO: Consider cacheing this server-side https://nitro.unjs.io/guide/cache - defineEventHandler(async (event): Promise => { +export default defineRApiEventHandler( + async (event): Promise => { // Delegate to getVersionData so that the logic can be unit-tested. const versionDataResponse = await getVersionData(event); return versionDataResponse; - }), + }, ); diff --git a/server/handlers/metadata.ts b/server/handlers/metadata.ts new file mode 100644 index 00000000..4844141a --- /dev/null +++ b/server/handlers/metadata.ts @@ -0,0 +1,20 @@ +import type { EventHandlerRequest, H3Event } from "h3"; +import { fetchRApi } from "@/server/utils/rApi"; +import type { Metadata, MetadataResponse } from "@/types/daedalusApiResponseTypes"; + +const rApiMetadataEndpoint = "/metadata"; + +export const getMetadata = async (event?: H3Event): Promise => { + const response = await fetchRApi( // Since we aren't transforming the R API's response, we can re-use the type interface for the web app's response (Metadata) as the interface for the R API's response. + rApiMetadataEndpoint, + {}, + event, + ); + + return { + statusText: response.statusText, + statusCode: response.statusCode, + errors: response?.errors || null, + data: response?.data as Metadata, + } as MetadataResponse; +}; diff --git a/server/utils/defineEventHandlerWithErrors.ts b/server/utils/defineRApiEventHandler.ts similarity index 52% rename from server/utils/defineEventHandlerWithErrors.ts rename to server/utils/defineRApiEventHandler.ts index f26f9748..23e97611 100644 --- a/server/utils/defineEventHandlerWithErrors.ts +++ b/server/utils/defineRApiEventHandler.ts @@ -1,11 +1,12 @@ -import type { EventHandler, EventHandlerRequest } from "h3"; +import type { EventHandler, H3Event } from "h3"; import type { ApiResponse } from "@/types/daedalusApiResponseTypes"; -export const defineEventHandlerWithErrors = ( - handler: EventHandler, -): EventHandler => - defineEventHandler(async (event) => { - const response = await handler(event) as ApiResponse; +// A wrapper for Nuxt's defineEventHandler that handles errors from the R API. +export const defineRApiEventHandler = ( + callback: (event: H3Event) => Promise, +): EventHandler => + defineEventHandler(async (event) => { + const response = await callback(event) as ApiResponse; if (response.errors || !response.data) { throw createError({ diff --git a/tests/e2e/rApi.spec.ts b/tests/e2e/helpers/checkRApiServer.ts similarity index 56% rename from tests/e2e/rApi.spec.ts rename to tests/e2e/helpers/checkRApiServer.ts index 94d2231b..c418f089 100644 --- a/tests/e2e/rApi.spec.ts +++ b/tests/e2e/helpers/checkRApiServer.ts @@ -1,7 +1,7 @@ -import { expect, request, test } from "@playwright/test"; +import { expect, request } from "@playwright/test"; // Check that we are not interacting with the mocked R API server. -const checkRApiServer = async () => { +export default async () => { const rApiContext = await request.newContext({ baseURL: "http://localhost:8001" }); try { const response = await rApiContext.get("/mock-smoke"); @@ -10,13 +10,3 @@ const checkRApiServer = async () => { console.warn("As expected, the mock server couldn't be found. The test will attempt to use the real server."); } }; - -test("Can access data from the R API", async ({ page, baseURL }) => { - checkRApiServer(); - - await page.goto(`${baseURL}/`); - - const html = await page.innerHTML("body"); - await expect(html).toContain("Home page"); - expect(html).toMatch(/Model version: (\d+\.)?(\d+\.)?(\*|\d+)/); -}); diff --git a/tests/e2e/runAnalysis.spec.ts b/tests/e2e/runAnalysis.spec.ts new file mode 100644 index 00000000..3cbfa7d3 --- /dev/null +++ b/tests/e2e/runAnalysis.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import checkRApiServer from "./helpers/checkRApiServer"; + +test.beforeAll(async () => { + checkRApiServer(); +}); + +test("Can request a scenario analysis run", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/`); // Should redirect to new scenario page + await expect(page.getByText("Simulate a new scenario")).toBeVisible(); + + await page.selectOption('select[id="pathogen"]', { label: "Influenza 1957" }); + await page.selectOption('select[id="response"]', { label: "Elimination" }); + await page.selectOption('select[id="country"]', { label: "United States" }); + await page.click('div[aria-label="Advance vaccine investment"] label[for="medium"]'); + + await page.click('button:has-text("Run")'); + + // TODO: Continue writing test + // await expect(page.getByText("Simulate a new scenario")).not.toBeVisible(); +}); diff --git a/tests/integration/rApi.spec.ts b/tests/integration/rApi.spec.ts index 0a7c1bc7..c8091b0f 100644 --- a/tests/integration/rApi.spec.ts +++ b/tests/integration/rApi.spec.ts @@ -15,11 +15,9 @@ const env = dotenv.config().parsed; let rApiBaseUrl = env!.NUXT_R_API_BASE; rApiBaseUrl = rApiBaseUrl.endsWith("/") ? rApiBaseUrl.slice(0, -1) : rApiBaseUrl; -const sendGetRequest = async () => await nuxtTestUtilsFetch("/api/versions"); - // We configure mockoon to return different responses for the same request, sequentially: // https://mockoon.com/docs/latest/route-responses/multiple-responses/#sequential-route-response -describe("api/versions", { sequential: true }, async () => { +describe("endpoints which consume the R API", { sequential: true }, async () => { beforeAll(async () => { // Verify that the user of the test suite has started the mock server // by checking that the server is listening on localhost:8001/mock-smoke @@ -46,41 +44,82 @@ describe("api/versions", { sequential: true }, async () => { await setup(); // Start the Nuxt server - it("returns a successful response when the mock server responds successfully", async () => { - const response = await sendGetRequest(); + describe("api/versions", async () => { + it("returns a successful response when the mock server responds successfully", async () => { + const response = await nuxtTestUtilsFetch("/api/versions"); - expect(response.ok).toBe(true); - expect(response.status).toBe(200); - expect(response.statusText).toBe("OK"); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + expect(response.statusText).toBe("OK"); - const json = await response.json(); - expect(json.daedalusModel).toBe("1.2.3.4.5.6.7.8"); - expect(json.daedalusApi).toBe("8.7.6.5.4.3.2.1"); - expect(json.daedalusWebApp).toMatch(/(\d+\.)?(\d+\.)?(\*|\d+)/); - }); + const json = await response.json(); + expect(json.daedalusModel).toBe("1.2.3.4.5.6.7.8"); + expect(json.daedalusApi).toBe("8.7.6.5.4.3.2.1"); + expect(json.daedalusWebApp).toMatch(/(\d+\.)?(\d+\.)?(\*|\d+)/); + }); + + it("returns a response with informative errors when the mock server responds with an error", async () => { + const response = await nuxtTestUtilsFetch("/api/versions"); + + expect(response.ok).toBe(false); + expect(response.status).toBe(404); + expect(response.statusText).toBe("Not Found"); + const json = await response.json(); + expect(json.data[0].error).toBe("NOT_FOUND"); + expect(json.data[0].detail).toBe("Resource not found"); + expect(json.message).toBe("NOT_FOUND: Resource not found"); // A concatenation of the error details from the R API. + }); + + it("returns a response with informative errors when the mock server doesn't respond in time", async () => { + // https://mockoon.com/tutorials/getting-started/#step-5-add-a-delay-to-the-response + + const response = await nuxtTestUtilsFetch("/api/versions"); - it("returns a response with informative errors when the mock server responds with an error", async () => { - const response = await sendGetRequest(); + expect(response.ok).toBe(false); + expect(response.status).toBe(500); + expect(response.statusText).toBe("error"); - expect(response.ok).toBe(false); - expect(response.status).toBe(404); - expect(response.statusText).toBe("Not Found"); - const json = await response.json(); - expect(json.data[0].error).toBe("NOT_FOUND"); - expect(json.data[0].detail).toBe("Resource not found"); - expect(json.message).toBe("NOT_FOUND: Resource not found"); // A concatenation of the error details from the R API. + const json = await response.json(); + expect(json.message).toBe("Unknown error: No response from the API"); + }); }); - it("returns a response with informative errors when the mock server doesn't respond in time", async () => { - // https://mockoon.com/tutorials/getting-started/#step-5-add-a-delay-to-the-response + describe("api/metadata", async () => { + it("returns a successful response when the mock server responds successfully", async () => { + const response = await nuxtTestUtilsFetch("/api/metadata"); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + expect(response.statusText).toBe("OK"); + + const json = await response.json(); + expect(json.modelVersion).toBe("0.1.0"); + expect(json.parameters[0].id).toBe("country"); + }); + + it("returns a response with informative errors when the mock server responds with an error", async () => { + const response = await nuxtTestUtilsFetch("/api/metadata"); + + expect(response.ok).toBe(false); + expect(response.status).toBe(404); + expect(response.statusText).toBe("Not Found"); + const json = await response.json(); + expect(json.data[0].error).toBe("NOT_FOUND"); + expect(json.data[0].detail).toBe("Resource not found"); + expect(json.message).toBe("NOT_FOUND: Resource not found"); // A concatenation of the error details from the R API. + }); + + it("returns a response with informative errors when the mock server doesn't respond in time", async () => { + // https://mockoon.com/tutorials/getting-started/#step-5-add-a-delay-to-the-response - const response = await sendGetRequest(); + const response = await nuxtTestUtilsFetch("/api/metadata"); - expect(response.ok).toBe(false); - expect(response.status).toBe(500); - expect(response.statusText).toBe("error"); + expect(response.ok).toBe(false); + expect(response.status).toBe(500); + expect(response.statusText).toBe("error"); - const json = await response.json(); - expect(json.message).toBe("Unknown error: No response from the API"); + const json = await response.json(); + expect(json.message).toBe("Unknown error: No response from the API"); + }); }); }); diff --git a/tests/unit/components/ParameterForm.spec.ts b/tests/unit/components/ParameterForm.spec.ts new file mode 100644 index 00000000..e00166e8 --- /dev/null +++ b/tests/unit/components/ParameterForm.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import { FetchError } from "ofetch"; + +import ParameterForm from "@/components/ParameterForm.vue"; + +const stubs = { + CIcon: true, +}; + +const globeParameter = { + id: "region", + label: "Region", + parameterType: "globeSelect", + defaultOption: "HVN", + ordered: false, + options: [ + { id: "CLD", label: "Cloud Nine" }, + { id: "HVN", label: "Heaven" }, + ], +}; + +const selectParameters = [ + { + id: "long_list", + label: "Drop Down", + parameterType: "select", + defaultOption: null, + ordered: false, + options: [ + { id: "1", label: "Option 1" }, + { id: "2", label: "Option 2" }, + { id: "3", label: "Option 3" }, + { id: "4", label: "Option 4" }, + { id: "5", label: "Option 5" }, + { id: "6", label: "Option 6" }, + ], + }, + { + id: "short_list", + label: "Radio Buttons", + parameterType: "select", + defaultOption: "no", + ordered: false, + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, +]; + +const metadata = { modelVersion: "0.0.0", parameters: [...selectParameters, globeParameter] }; + +describe("parameter form", () => { + it("adds a resize event listener on mount and removes it on unmount", async () => { + const addEventListenerSpy = vi.spyOn(window, "addEventListener"); + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + + const component = await mountSuspended(ParameterForm, { + props: { metadata: undefined, metadataFetchStatus: "pending", metadataFetchError: null }, + global: { stubs }, + }); + expect(addEventListenerSpy).toHaveBeenCalledWith("resize", expect.any(Function)); + + component.unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith("resize", expect.any(Function)); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it("renders the correct parameter labels, inputs, options, and default values", async () => { + const component = await mountSuspended(ParameterForm, { + props: { metadata, metadataFetchStatus: "success", metadataFetchError: null }, + global: { stubs }, + }); + + expect(component.text()).toContain("Region"); + expect(component.text()).toContain("Drop Down"); + expect(component.text()).toContain("Radio Buttons"); + + const selectElements = component.findAll("select"); + expect(selectElements.length).toBe(2); + + selectElements.forEach((selectElement, index) => { + const correctLabel = ["Drop Down", "Region"][index]; + const paramId = selectElement.element.attributes.getNamedItem("id")!.value; + expect(component.find(`label[for=${paramId}]`).element.textContent).toBe(correctLabel); + expect(selectElement.element.attributes.getNamedItem("aria-label")!.value).toBe(correctLabel); + }); + + expect(selectElements[0].findAll("option").map((option) => { + return { value: option.element.value, label: option.text(), selected: option.element.selected }; + })).toEqual([ + { value: "1", label: "Option 1", selected: true }, + { value: "2", label: "Option 2", selected: false }, + { value: "3", label: "Option 3", selected: false }, + { value: "4", label: "Option 4", selected: false }, + { value: "5", label: "Option 5", selected: false }, + { value: "6", label: "Option 6", selected: false }, + ]); + + expect(selectElements[1].findAll("option").map((option) => { + return { value: option.element.value, label: option.text(), selected: option.element.selected }; + })).toEqual([ + { value: "CLD", label: "Cloud Nine", selected: false }, + { value: "HVN", label: "Heaven", selected: true }, + ]); + + // As this parameter's options are all single words and there aren't more than 4, it should render as radio buttons. + const buttonGroupLabel = component.find(".button-group-container").find("label"); + expect(buttonGroupLabel.element.attributes.getNamedItem("for")!.value).toBe("short_list"); + expect(buttonGroupLabel.text()).toBe("Radio Buttons"); + const shortList = component.findComponent({ name: "CButtonGroup" }); + expect(shortList.findAll("input").map((input) => { + return { value: input.element.value, label: input.element.labels![0].textContent, checked: input.element.checked }; + })).toEqual([ + { value: "yes", label: "Yes", checked: false }, + { value: "no", label: "No", checked: true }, + ]); + }); + + it("initialises formData with defaults and updates formData when a parameter is changed", async () => { + const component = await mountSuspended(ParameterForm, { + props: { metadata, metadataFetchStatus: "success", metadataFetchError: null }, + global: { stubs }, + }); + + const cForm = component.findComponent({ name: "CForm" }); + let formData = JSON.parse(cForm.element.attributes.getNamedItem("data-test")!.value); + expect(formData.region).toBe("HVN"); + expect(formData.long_list).toBe("1"); + expect(formData.short_list).toBe("no"); + + const selectElements = component.findAll("select"); + const longListDropDown = selectElements[0]; + const countrySelect = selectElements[1]; + + // Verify that the select elements are the ones we think they are + selectElements.forEach((selectElement, index) => { + const correctLabel = ["Drop Down", "Region"][index]; + const paramId = selectElement.element.attributes.getNamedItem("id")!.value; + expect(component.find(`label[for=${paramId}]`).element.textContent).toBe(correctLabel); + }); + + await longListDropDown.findAll("option").at(2)!.setSelected(); + await countrySelect.findAll("option").at(0)!.setSelected(); + await component.findComponent({ name: "CButtonGroup" }).find("input[value='yes']").setChecked(); + + formData = JSON.parse(cForm.element.attributes.getNamedItem("data-test")!.value); + expect(formData.region).toBe("CLD"); + expect(formData.long_list).toBe("3"); + expect(formData.short_list).toBe("yes"); + }); + + it("displays CAlert with error message when metadataFetchStatus is 'error'", async () => { + const error = new FetchError("There was a bee-related issue."); + + const component = await mountSuspended(ParameterForm, { + props: { metadata: undefined, metadataFetchStatus: "error", metadataFetchError: error }, + global: { stubs }, + }); + + expect(component.findComponent({ name: "CAlert" }).exists()).toBe(true); + expect(component.text()).toContain("Failed to retrieve metadata from R API."); + expect(component.text()).toContain("There was a bee-related issue."); + }); + + it("displays CSpinner when metadataFetchStatus is 'pending'", async () => { + const component = await mountSuspended(ParameterForm, { + props: { metadata: undefined, metadataFetchStatus: "pending", metadataFetchError: null }, + global: { stubs }, + }); + + expect(component.findComponent({ name: "CSpinner" }).exists()).toBe(true); + }); +}); diff --git a/tests/unit/server/handlers/metadata.spec.ts b/tests/unit/server/handlers/metadata.spec.ts new file mode 100644 index 00000000..2741460b --- /dev/null +++ b/tests/unit/server/handlers/metadata.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerEndpoint } from "@nuxt/test-utils/runtime"; +import { getMetadata } from "@/server/handlers/metadata"; + +const mockedVersionResponse = vi.fn(); +const exampleMetadata = { + modelVersion: "0.1.0", + parameters: [ + { + id: "param1", + label: "Parameter 1", + parameterType: "select", + defaultOption: "option1", + ordered: false, + options: [{ + id: "option1", + label: "Option 1", + }], + }, + { + id: "param2", + label: "Parameter 2", + parameterType: "globeSelect", + defaultOption: null, + ordered: true, + options: [{ + id: "option1", + label: "Option 1", + }], + }, + ], +}; + +registerEndpoint("/metadata", mockedVersionResponse); + +describe("get metadata", () => { + describe("when the R API response is successful", () => { + it("should return the expected metadata", async () => { + mockedVersionResponse.mockImplementation(() => { + return { + status: "success", + errors: null, + data: exampleMetadata, + }; + }); + + const response = await getMetadata(); + + expect(response.data).toEqual(exampleMetadata); + expect(response.errors).toBeNull(); + expect(response.statusCode).toBe(200); + expect(response.statusText).toBe(""); + }); + }); + + describe("when the R API response is unsuccessful", () => { + it("passes on the status code and message", async () => { + mockedVersionResponse.mockImplementation(() => { + throw createError({ + statusCode: 418, + statusMessage: "I'm a teapot", + }); + }); + + const response = await getMetadata(); + + // NB Couldn't find a way to expose error details in the response using registerEndpoint, + // but the error details are passed on in the real implementation and this is tested in + // the integration tests. + expect(response.data).toBeNull(); + expect(response.statusCode).toBe(418); + expect(response.statusText).toBe("I'm a teapot"); + }); + }); +}); diff --git a/types/daedalusApiResponseTypes.ts b/types/daedalusApiResponseTypes.ts index abeb00c5..33656655 100644 --- a/types/daedalusApiResponseTypes.ts +++ b/types/daedalusApiResponseTypes.ts @@ -11,6 +11,7 @@ export interface ApiResponse { data: T | null } +// Version export interface VersionData { daedalusModel: string daedalusApi: string @@ -18,3 +19,28 @@ export interface VersionData { } export interface VersionDataResponse extends ApiResponse { } + +// Metadata +export interface ParameterOption { + id: string + label: string +} + +export enum ParameterType { + Select = "select", + GlobeSelect = "globeSelect", +} +export interface Parameter { + id: string + label: string + parameterType: ParameterType + defaultOption: string | null + ordered: boolean + options: Array +} +export interface Metadata { + modelVersion: string + parameters: Array +} + +export interface MetadataResponse extends ApiResponse { }