diff --git a/.circleci/config.yml b/.circleci/config.yml index 119209ab2..3efd1edbc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -138,37 +138,47 @@ jobs: paths: - front/dist + lint-front: + executor: node-executor + working_directory: ~/ + resource_class: medium+ + steps: + - attach_workspace: + at: ~/ + - run: + name: Lint and type-check front app + command: | + pnpm check-types + test-front: executor: node-executor - working_directory: ~/front + parallelism: 4 + working_directory: ~/ resource_class: medium+ steps: - attach_workspace: at: ~/ - run: - name: Test front app and create reports + name: Install Playwright deps command: | - DEBUG_PRINT_LIMIT=10000 pnpm test:coverage --test-timeout=40000 + npx playwright install --with-deps chromium - run: - name: Test shared-components and create reports + name: Test front app and create reports + environment: + FRONT_AUTH0_DOMAIN: boxtribute-dev.eu.auth0.com + FRONT_AUTH0_CLIENT_ID: ni9ZdcoIv3HU10kyc4t1qxOMxjVyxcbS + FRONT_USE_MSW: true command: | - DEBUG_PRINT_LIMIT=10000 pnpm -C ../shared-components test:coverage --test-timeout=40000 - - store_test_results: - path: coverage/junit.xml - - store_test_results: - path: ../shared-components/coverage/junit.xml - - store_artifacts: - path: coverage - destination: front-coverage - - store_artifacts: - path: ../shared-components/coverage - destination: shared-components-coverage - - codecov/upload: - flags: frontend - file: coverage/coverage-final.json - - codecov/upload: - flags: sharedComponents - file: ../shared-components/coverage/coverage-final.json + SHARD="$((${CIRCLE_NODE_INDEX}+1))"; ls tests && pnpm test -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL} + # TODO: add coverage, report, record video on fail + # - store_test_results: + # path: coverage/junit.xml + # - store_artifacts: + # path: coverage + # destination: front-coverage + # - codecov/upload: + # flags: frontend + # file: coverage/coverage-final.json - slack/notify-on-failure: only_for_branches: master,production @@ -440,6 +450,10 @@ workflows: context: STAGING requires: - install-node-packages + - lint-front: + context: STAGING + requires: + - install-node-packages - test-front: context: STAGING requires: diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 290370d90..9e94eae7b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -83,15 +83,5 @@ module.exports = { tsx: "never", }, ], - }, - overrides: [ - { - files: [ - "**/?(__)tests?(__)/**/*.[jt]s?(x)", - "**/?(*.)+(spec|test).[jt]s?(x)", - "**/mocks/**/*.[jt]s?(x)", - ], - extends: ["plugin:testing-library/react"], - }, - ], + } }; diff --git a/.gitignore b/.gitignore index 34ccf32a2..e3e491e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ docs/graphql-api/public .VSCodeCounter *.old.* __pycache__/* +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +state.json diff --git a/.vscode/settings.json b/.vscode/settings.json index cecdf019e..a564c7e2f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "editor.formatOnPaste": false, // required "editor.formatOnType": false, // required "editor.formatOnSave": true, + "prettier.configPath": ".prettierrc", // eslint "eslint.workingDirectories": ["./", "./front", "./shared-components", "./statviz"], @@ -29,5 +30,6 @@ }, "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true + "typescript.enablePromptUseWorkspaceTsdk": true, + "git.enableCommitSigning": true } diff --git a/back/README.md b/back/README.md index 83dfa16c0..5c5f24868 100755 --- a/back/README.md +++ b/back/README.md @@ -378,7 +378,7 @@ to simulate a god user with ID 8 (for a regular user, set something like `id=1, > [!IMPORTANT] > To keep the front-end side up-to-date with the GraphQL schema, make sure that the pre-commit command for `*.graphql` files (`id: generate-graphql-ts-types`) is running properly. > -> It should generate both `schema.graphql` (the introspected unified schema) and `graphql-env.d.ts` (the generated types to be ìnferred and consumed in the FE with `gql.tada`) inside `/graphql/generated/`. +> It should generate `schema.graphql` (the introspected unified schema), `graphql-env.d.ts` (the generated types to be ìnferred and consumed in the FE with `gql.tada`), `types.ts` (base generated types for msw handlers and mocks) and lastly `mocks.ts` (base mocks to make fixtures and use in tests) inside `/graphql/generated/`. ## Project structure diff --git a/docker-compose.yml b/docker-compose.yml index 066b9feea..00d49ec91 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: NODE_ENV: development HUSKY: 0 FRONT_ENVIRONMENT: ${ENVIRONMENT:-development} + FRONT_USE_MSW: ${USE_MSW} FRONT_SENTRY_ENVIRONMENT: ${ENVIRONMENT:-development} FRONT_SENTRY_FE_DSN: ${SENTRY_FE_DSN:-} FRONT_SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-} diff --git a/docs/adr/adr_frontend_tests.md b/docs/adr/adr_frontend_tests.md new file mode 100644 index 000000000..9410daffb --- /dev/null +++ b/docs/adr/adr_frontend_tests.md @@ -0,0 +1,79 @@ +# ADR: Use Playwright for Application, Integration and Component Testing + +Author: [Felipe](https://github.com/fhenrich33) + +Reviewed by: [Roanna K](https://github.com/aerinsol) + +## Status + +Proposed, Implementing + +## Context + +The current testing setup for the Boxtribute front-end project uses React Testing Library and Vitest for unit and integration tests. While these tools are effective for testing individual components and their interactions, the tests usually aren't fully 1-1 representative of what the user will experience. E2E and more involved integration testing are crucial for ensuring that the application works correctly from the user's perspective, covering the entire workflow from start to finish. + +Additionally, we use Mock Service Worker (MSW) to mock API requests during tests (and sometimes development) to avoid hitting the real development server and database during tests. This allows us to test the application in isolation and ensure consistent and fast test results. + +## Decision Drivers + +1. **Comprehensive Testing**: We need a testing framework that can simulate real user interactions, test the application as a whole, and handle integration tests with mocked API requests. +2. **Cross-Browser Testing**: The ability to test the application across different browsers to ensure compatibility. +3. **Ease of Use**: The testing framework should be easy to set up and use, with good documentation and community support. +4. **Performance**: The framework should be fast and efficient, minimizing the impact on the development workflow. +5. **Integration with CI/CD**: The framework should integrate well with our existing CI/CD pipeline. + +## Considered Options + +1. **React Testing Library + Vitest** + + - Pros: + - Well-suited for unit and integration tests. + - Good community support and documentation. + - Easy to set up and use for isolated testing. + - Cons: + - Limited to testing individual components and their interactions. + - Does not provide comprehensive application testing capabilities (in comparison to Playwright). + - Runs against a mocked DOM structure (jsdom) instead of a real browser, which leads to flakiness and unreliability. + - No built-in support for cross-browser testing. + +2. **Playwright** + - Pros: + - Comprehensive application, integration, and component testing capabilities. + - Tests against the real DOM, as even in headless mode the tests run inside a real browser. + - Supports cross-browser testing (Chromium, Firefox, WebKit). + - Easy to set up and use with excellent documentation. The API for test specs is very similar to React Testing Library. + - Ability to generate tests by using the app. See https://playwright.dev/docs/codegen-intro. + - Can still run isolated component tests. See https://playwright.dev/docs/test-components. + - Can run visual diffing. See https://playwright.dev/docs/test-snapshots. + - Fast and efficient, with parallel test execution. Might not match React Testing Library speed but gets close while testing in a real environment. + - Integrates well with CI/CD pipelines in headless mode. + - Cons: + - Additional learning curve for advanced use cases. + - Extra setup may be required for advanced use cases. + - Slightly slower than React Testing Library for sequential test runs (alleviated by parallel runs). + +## Decision + +We have decided to adopt Playwright for application, integration, and component testing in the Boxtribute front-end project. Playwright provides comprehensive testing capabilities, supports cross-browser testing, and integrates well with our existing CI/CD pipeline. Additionally, Playwright can be used with Mock Service Worker (MSW) to mock API requests, allowing us to test the application in isolation, while faithfully mimicking the real server since the mocks infer the data types from the GraphQL Schema. Vitest will still be used for individual TypeScript functions and modules. + +## Consequences + +- **Positive**: + + - Improved test coverage with comprehensive application, integration, and component tests. + - Ability to test the application across different browsers. + - Fast testing with parallel test execution, while being more faithful to how a user will experience the app. + - Better integration with our CI/CD pipeline. Less prone to flakiness. + - Consistent test results by mocking API requests with MSW based on our GraphQL Schema. + +- **Negative**: + - Additional learning curve for developers. + - Potential need for additional setup for advanced use cases. + - Heavier Development and CI/CD setup, as we install real browsers as dependencies. + +## References + +- [Playwright Documentation](https://playwright.dev/docs/intro) +- [React Testing Library Documentation](https://testing-library.com/docs/react-testing-library/intro) +- [Vitest Documentation](https://vitest.dev/) +- [Mock Service Worker Documentation](https://mswjs.io/docs/) diff --git a/example.env b/example.env index a5f3b82b4..8c58da8f2 100644 --- a/example.env +++ b/example.env @@ -18,3 +18,5 @@ TEST_AUTH0_MANAGEMENT_API_CLIENT_ID=ZBDcEypTHMwn23ScgeaMqwzMxO5epguH TEST_AUTH0_MANAGEMENT_API_CLIENT_SECRET= ENVIRONMENT=development + +USE_MSW=false diff --git a/front/README.md b/front/README.md index e873ed9b4..00b3a4b3e 100644 --- a/front/README.md +++ b/front/README.md @@ -21,7 +21,7 @@ Following the [general set-up steps](../README.md), here a few steps that make y ### Install node and pnpm -For almost all features of our development set-up you should also have [node](https://nodejs.org/en/download/) installed on your computer. You will need it to run front-end tests and the formatters and linters in your IDE (e.g. VSCode). +For almost all features of Boxtribute's development set-up you should also have [node](https://nodejs.org/en/download/) installed on your computer. You will need it to run front-end tests and the formatters and linters in your IDE (e.g. VSCode). We recommend you to install node through a [version control like nvm](https://github.com/nvm-sh/nvm). It provides you with much more clarity which version you are running and makes it easy to switch versions of node. @@ -61,7 +61,7 @@ docker compose exec front pnpm format:write ## Note about pnpm and Docker -We are using docker to spin up our dev environment. The front folder is in sync with the front Docker container. Therefore, the hot-reloading of the node development server should function. +We are using docker to spin up Boxtribute's dev environment. The front folder is in sync with the front Docker container. Therefore, the hot-reloading of the node development server should function. When you wish to add a dependency, e.g. when you make a change to your local `package.json`, you will need to rebuild the docker container and relaunch. @@ -84,35 +84,104 @@ Afterwards: ## Testing -Testing is done with React Testing Library and Jest. +Testing is done with [Playwright](https://playwright.dev/) for application, integration, and component testing. -Test files are located in the same directory as the files they are testing. For example, `EditBox.test.js` and `EditBox.tsx` are both located in `front/src/views/EditBox`. +[Vitest](https://vitest.dev/) is used for standalone TypeScript functions and modules. -For integration tests, we mock the Apollo client with a `MockedProvider` component instead of the `ApolloProvider` component that is used to handle real data. More information on mocking the Apollo client can be found [here](https://www.apollographql.com/docs/react/development-testing/testing/). +We use [Mock Service Worker](https://mswjs.io/) (MSW for short) to mock data in a way that faithfully mimics the real server. It's also very useful to mock GraphQL Schema bits that aren't implemented in the production backend yet to build new features on top of it. -To eliminate repetitive code, a custom renderer was built in `front/src/tests/test-utils.js`. It allows developers to render a component in a test environment where chakra, Apollo and Routes are wrapped around it. The utility also exports the entire react testing library, so you should import from this utility instead of `@testing-library/react`. See `EditBox.test.js` for examples of the custom renderer's use. +Application test specs are inside the `/tests` folder. Other tests are colocated with the files that are the subject of what is being tested. -Tests and test coverage can be run with the following command: +See Playwright's docs to see how to make the most of its API to help you build test cases e.g. [recording tests by using the app](https://playwright.dev/docs/codegen-intro). -```sh -# run tests -docker compose exec front pnpm test +### Fixtures and mock data + +Mock data is located inside `tests/fixtures.ts`, and is usually retrieved from the development backend calls to speed up testing and development since fixtures for that data were already written. As long as both the front-end and backend match the GraphQL Schema, we can be sure that the mocked data will match real data consumed in the app. You can add more fixtures by hand or by using the real API results that you can get from the Network tab inside Devtools in the browser. + +To label the fixture/mock data, follow the convention of placing it as `userName: { graphqlOperationName: { modifierEGbaseId: data } }`. Then use it inside the MSW handler at `mswHandlers.ts` (also to export the handler at the bottom of the file). + +> [!IMPORTANT] +> You must place this entry on your `.env` file for the tests to run against MSW and mock data in `tests/fixtures.ts`. +> +> `USE_MSW=true` +> +> You might need to bring down the Docker containers, rebuild, and start again to pick up the environment variable changes. -# or locally +### Running tests + +Tests and test coverage can be run with the following commands: + +```sh +# Run tests locally in headless mode (CLI only) pnpm test +# Run tests in UI mode (opens a Chromium browser to visually run and debug tests) +pnpm test:ui + # test coverage +pnpm test:coverage + +# run tests inside Docker +docker compose exec front pnpm test + +# test coverage inside Docker docker compose exec front pnpm test:coverage +``` + +Here, is a list of best practices you should follow when writing front-end tests: + +- [Write tests that simulate user behavior rather than single components](https://kentcdodds.com/blog/write-fewer-longer-tests) +- [Use the right queries according to their priorization](https://testing-library.com/docs/queries/about#priority) +- [Maybe use this Browser extension to find the best query](https://chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano) + +Testing is done with [Playwright](https://playwright.dev/) for application, integration, and component testing. -# or locally +[Vitest](https://vitest.dev/) is used for standalone TypeScript functions and modules. + +We use [Mock Service Worker](https://mswjs.io/) (MSW for short) to mock data in a way that faithfully mimics the real server. It's also very useful to mock GraphQL Schema bits that aren't implemented in the production backend yet to build new features on top of it. + +Application test specs are inside the `/tests` folder. Other tests are colocated with the files that are the subject of what is being tested. + +See Playwright's docs to see how to make the most of it's API to help you build test cases e.g. [recording tests by using the app](https://playwright.dev/docs/codegen-intro). + +### Fixtures and mock data + +Mock data is located inside `tests/fixtures.ts`, and usually retrieved from the development backend calls to speed up testing and development since fixtures for that data were already written. As long as both frontend and backend matches the GraphQL Schema, we can be sure that the mocked data will match real data consumed in the app. You can add more fixtures by hand or by using the real API results that you can get from the Network tab inside Devtools in the browser. + +To label the fixture/mock data, follow the convention of placing it as `userName: { graphqlOperationName: { modifierEGbaseId: data } }`. Then use it inside the MSW handler at `mswHandlers.ts` (also to export the handler at the bottom of the file). + +> [!IMPORTANT] +> You must place this entry on your `.env` file in order for the tests to run against MSW and mock data in `tests/fixtures.ts`. +> +> `USE_MSW=true` +> +> You might need to bring down the Docker containers, rebuild and start again to pick up the enviroment variable changes. + +### Running tests + +Tests and test coverage can be run with the following commands: + +```sh +# Run tests locally in headless mode (CLI only) +pnpm test + +# Run tests in UI mode (opens a Chromium browser to visually run and debug tests) +pnpm test:ui + +# test coverage pnpm test:coverage + +# run tests inside Docker +docker compose exec front pnpm test + +# test coverage inside Docker +docker compose exec front pnpm test:coverage ``` -Here, a list of best practices you should follow when writing front-end tests with React Testing Library: +Here, a list of best practices you should follow when writing front-end tests: -- [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) - [Write tests that simulate user behavior rather than single components](https://kentcdodds.com/blog/write-fewer-longer-tests) -- [Use the right queries in React Testing Library according to their priorization](https://testing-library.com/docs/queries/about#priority) +- [Use the right queries according to their priorization](https://testing-library.com/docs/queries/about#priority) - [Maybe use this Browser extension to find the best query](https://chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano) ## Mobile functional testing @@ -214,18 +283,18 @@ The folder structure is as follows: ## Apollo -Apollo is our client to send GraphQL queries and mutation to the back-end. It can also be used as a local storage for global states. Here, some articles you might want to check out: +Apollo is Boxtribute's client to send GraphQL queries and mutation to the backend. It can also be used as a local storage for global states. Here, some articles you might want to check out: - [Apollo Client for State Management](https://www.apollographql.com/blog/apollo-client/caching/dispatch-this-using-apollo-client-3-as-a-state-management-solution/) - [When to use refetchQueries](https://www.apollographql.com/blog/apollo-client/caching/when-to-use-refetch-queries/) ## Types and GraphQL -As our front-end uses TypeScript to statically type our codebase and has a GraphQL schema as our source of truth for almost all of our data, we should make the most of this by inferring types as much as possible from the schema. +As Boxtribute's front-end uses TypeScript to statically type the codebase and has a GraphQL schema as the source of truth for almost all of the data, we should make the most of this by inferring types as much as possible from the schema. -And we do that by using [gql.tada](https://gql-tada.0no.co/), which automagically infer types from a unified schema generated from introspection of our API. +And we do that by using [gql.tada](https://gql-tada.0no.co/), which automagically infer types from a unified schema generated from introspection of Boxtribute's API. -See how it's generated by checking out the root `package.json` command `graphql-gen` and by taking a look at the end of the [GraphQL API section](../back/README.md#graphql-api) in the back-end README. +See how it's generated by checking out the root `package.json` command `graphql-gen` and by taking a look at the end of the [GraphQL API section](../back/README.md#graphql-api) in the backend README. ### Convention for creating new GraphQL Fragments, Mutations, Queries, and Types diff --git a/front/browser.ts b/front/browser.ts new file mode 100644 index 000000000..2b5e64761 --- /dev/null +++ b/front/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "../tests/mswHandlers"; + +export const worker = setupWorker(...handlers); diff --git a/front/index.html b/front/index.html index 3d8d056ae..8b115c60c 100644 --- a/front/index.html +++ b/front/index.html @@ -65,7 +65,7 @@ font-family: "Open Sans"; font-style: normal; font-weight: 800; - src: url("/fonts/open-sans-latin800-normal.woff2") format("woff2"); + src: url("/fonts/open-sans-latin-800-normal.woff2") format("woff2"); } @font-face { font-family: "Open Sans"; diff --git a/front/package.json b/front/package.json index 3df8c9fa5..bbda6b912 100644 --- a/front/package.json +++ b/front/package.json @@ -19,29 +19,15 @@ "victory": "^37.3.6" }, "devDependencies": { - "@chakra-ui/storybook-addon": "^5.2.5", "@sentry/types": "^8.50.0", - "@storybook/addon-actions": "^8.5.0", - "@storybook/addon-essentials": "^8.5.0", - "@storybook/addon-interactions": "^8.5.0", - "@storybook/addon-links": "^8.5.0", - "@storybook/node-logger": "^8.5.0", - "@storybook/react": "^8.5.0", - "@storybook/react-vite": "^8.5.0", - "@storybook/test": "^8.5.0", "@types/react-big-calendar": "^1.16.1", "@types/react-table": "^7.7.20", - "msw": "^2.7.0", - "mutationobserver-shim": "^0.3.7", - "storybook": "^8.5.0" + "mutationobserver-shim": "^0.3.7" }, "scripts": { - "build": "tsc && vite build", + "build": "vite build", "dev": "vite", "preview": "vite preview", - "test": "TZ=UTC vitest", - "test:coverage": "TZ=UTC vitest run --coverage", - "upload:test-report": "./node_modules/.bin/codecov", "tsc:check": "tsc --noEmit", "tsc:precommit": "tsc-files --noEmit", "lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --ignore-path ../.eslintignore", @@ -51,11 +37,6 @@ "format:check:all": "pnpm format:check \"src/**/*.{js,jsx,json,scss,md,ts,tsx}\" ", "format:check": "prettier --check --ignore-path ../.eslintignore", "format:write:all": "pnpm format:write \"src/**/*.{js,jsx,json,scss,md,ts,tsx}\"", - "format:write": "prettier --write --ignore-path ../.eslintignore", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" - }, - "msw": { - "workerDirectory": "public" + "format:write": "prettier --write --ignore-path ../.eslintignore" } } diff --git a/front/public/mockServiceWorker.js b/front/public/mockServiceWorker.js index 51d85eeeb..ec47a9a50 100644 --- a/front/public/mockServiceWorker.js +++ b/front/public/mockServiceWorker.js @@ -2,13 +2,15 @@ /* tslint:disable */ /** - * Mock Service Worker (1.3.2). + * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const PACKAGE_VERSION = '2.7.0' +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() self.addEventListener('install', function () { @@ -47,7 +49,10 @@ self.addEventListener('message', async function (event) { case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { type: 'INTEGRITY_CHECK_RESPONSE', - payload: INTEGRITY_CHECKSUM, + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, }) break } @@ -57,7 +62,12 @@ self.addEventListener('message', async function (event) { sendToClient(client, { type: 'MOCKING_ENABLED', - payload: true, + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, }) break } @@ -86,12 +96,6 @@ self.addEventListener('message', async function (event) { self.addEventListener('fetch', function (event) { const { request } = event - const accept = request.headers.get('accept') || '' - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return - } // Bypass navigation requests. if (request.mode === 'navigate') { @@ -112,29 +116,8 @@ self.addEventListener('fetch', function (event) { } // Generate unique request ID. - const requestId = Math.random().toString(16).slice(2) - - event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ) - return - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ) - }), - ) + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) }) async function handleRequest(event, requestId) { @@ -146,21 +129,24 @@ async function handleRequest(event, requestId) { // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { ;(async function () { - const clonedResponse = response.clone() - sendToClient(client, { - type: 'RESPONSE', - payload: { - requestId, - type: clonedResponse.type, - ok: clonedResponse.ok, - status: clonedResponse.status, - statusText: clonedResponse.statusText, - body: - clonedResponse.body === null ? null : await clonedResponse.text(), - headers: Object.fromEntries(clonedResponse.headers.entries()), - redirected: clonedResponse.redirected, + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, }, - }) + [responseClone.body], + ) })() } @@ -174,6 +160,10 @@ async function handleRequest(event, requestId) { async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) + if (activeClientIds.has(event.clientId)) { + return client + } + if (client?.frameType === 'top-level') { return client } @@ -196,20 +186,34 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId) { const { request } = event - const clonedRequest = request.clone() + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() function passthrough() { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const headers = Object.fromEntries(clonedRequest.headers.entries()) + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) - // Remove MSW-specific request headers so the bypassed requests - // comply with the server's CORS preflight check. - // Operate with the headers as an object because request "Headers" - // are immutable. - delete headers['x-msw-bypass'] + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } - return fetch(clonedRequest, { headers }) + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. @@ -225,57 +229,46 @@ async function getResponse(event, client, requestId) { return passthrough() } - // Bypass requests with the explicit bypass header. - // Such requests can be issued by "ctx.fetch()". - if (request.headers.get('x-msw-bypass') === 'true') { - return passthrough() - } - // Notify the client that a request has been intercepted. - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.text(), - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, }, - }) + [requestBuffer], + ) switch (clientMessage.type) { case 'MOCK_RESPONSE': { return respondWithMock(clientMessage.data) } - case 'MOCK_NOT_FOUND': { + case 'PASSTHROUGH': { return passthrough() } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.data - const networkError = new Error(message) - networkError.name = name - - // Rejecting a "respondWith" promise emulates a network error. - throw networkError - } } return passthrough() } -function sendToClient(client, message) { +function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -287,17 +280,28 @@ function sendToClient(client, message) { resolve(event.data) } - client.postMessage(message, [channel.port2]) + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) }) } -function sleep(timeMs) { - return new Promise((resolve) => { - setTimeout(resolve, timeMs) +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, }) -} -async function respondWithMock(response) { - await sleep(response.delay) - return new Response(response.body, response) + return mockedResponse } diff --git a/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.test.tsx b/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.test.tsx deleted file mode 100644 index f4dc0f254..000000000 --- a/front/src/components/BoxReconciliationOverlay/BoxReconciliationOverlay.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { vi, beforeEach, it, expect, describe } from "vitest"; -import { screen, render, waitFor } from "tests/test-utils"; -import { useAuth0 } from "@auth0/auth0-react"; -import { BoxReconciliationOverlay } from "components/BoxReconciliationOverlay/BoxReconciliationOverlay"; -import { mockAuthenticatedUser } from "mocks/hooks"; -import { generateMockShipment } from "mocks/shipments"; -import { organisation1 } from "mocks/organisations"; -import { cache, boxReconciliationOverlayVar, IBoxReconciliationOverlayVar } from "queries/cache"; -import { generateMockLocationWithBase } from "mocks/locations"; -import { products } from "mocks/products"; -import { tag1, tag2 } from "mocks/tags"; -import { userEvent } from "@testing-library/user-event"; -import { SHIPMENT_BY_ID_WITH_PRODUCTS_AND_LOCATIONS_QUERY } from "queries/queries"; -import { UPDATE_SHIPMENT_WHEN_RECEIVING } from "queries/mutations"; -import { mockedCreateToast, mockedTriggerError } from "tests/setupTests"; -import { FakeGraphQLError, FakeGraphQLNetworkError } from "mocks/functions"; - -vi.mock("@auth0/auth0-react"); -// @ts-ignore -window.scrollTo = vi.fn(); - -// .mocked() is a nice helper function from jest for typescript support -// https://jestjs.io/docs/mock-function-api/#typescript-usage -const mockedUseAuth0 = vi.mocked(useAuth0); - -beforeEach(() => { - mockAuthenticatedUser(mockedUseAuth0, "dev_volunteer@boxaid.org"); -}); - -const queryShipmentDetailForBoxReconciliation = { - request: { - query: SHIPMENT_BY_ID_WITH_PRODUCTS_AND_LOCATIONS_QUERY, - variables: { - shipmentId: "1", - baseId: "1", - }, - }, - result: { - data: { - base: { - locations: [generateMockLocationWithBase({})], - products, - tags: [tag1, tag2], - }, - shipment: generateMockShipment({ state: "Receiving" }), - }, - }, -}; - -const failedQueryShipmentDetailForBoxReconciliation = { - request: { - query: SHIPMENT_BY_ID_WITH_PRODUCTS_AND_LOCATIONS_QUERY, - variables: { - shipmentId: "1", - baseId: "1", - }, - }, - result: { - errors: [new FakeGraphQLError()], - }, -}; - -// Test case 4.7.2 - -it("4.7.2 - Query for shipment, box, available products, sizes and locations returns an error ", async () => { - boxReconciliationOverlayVar({ - isOpen: true, - labelIdentifier: "123", - shipmentId: "1", - } as IBoxReconciliationOverlayVar); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [failedQueryShipmentDetailForBoxReconciliation], - cache, - globalPreferences: { - dispatch: vi.fn(), - globalPreferences: { - organisation: { id: organisation1.id, name: organisation1.name }, - availableBases: organisation1.bases, - selectedBase: organisation1.bases[0], - }, - }, - }); - - // toast shown - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/Could not fetch data! Please try reloading the page/i), - }), - ), - ); -}); - -const mockUpdateShipmentWhenReceivingMutation = ({ - networkError = false, - graphQlError = false, - shipmentId = "1", - lostBoxLabelIdentifiers = ["123"], -}) => ({ - request: { - query: UPDATE_SHIPMENT_WHEN_RECEIVING, - variables: { - id: shipmentId, - lostBoxLabelIdentifiers, - }, - }, - result: networkError - ? undefined - : { - data: graphQlError - ? null - : { - updateShipmentWhenReceiving: generateMockShipment({ state: "Receiving" }), - }, - errors: graphQlError ? [new FakeGraphQLError()] : undefined, - }, - error: networkError ? new FakeGraphQLNetworkError() : undefined, -}); - -const noDeliveryTests = [ - { - name: "4.7.3.1 - Mark as Lost Mutation fails due to GraphQL error", - mocks: [ - queryShipmentDetailForBoxReconciliation, - mockUpdateShipmentWhenReceivingMutation({ graphQlError: true }), - ], - toast: { isError: true, message: /Could not change state of the box./i }, - }, - { - name: "4.7.3.2 - Mark as Lost Mutation fails due to Network error", - mocks: [ - queryShipmentDetailForBoxReconciliation, - mockUpdateShipmentWhenReceivingMutation({ networkError: true }), - ], - toast: { isError: true, message: /Could not change state of the box./i }, - }, - { - name: "4.7.3.3 - Mark as Lost Mutation is succesfull", - mocks: [queryShipmentDetailForBoxReconciliation, mockUpdateShipmentWhenReceivingMutation({})], - toast: { isError: false, message: /Box marked as undelivered/i }, - }, -]; - -describe("No Delivery Tests", () => { - noDeliveryTests.forEach(({ name, mocks, toast }) => { - it( - name, - async () => { - const user = userEvent.setup(); - boxReconciliationOverlayVar({ - isOpen: true, - labelIdentifier: "123", - shipmentId: "1", - } as IBoxReconciliationOverlayVar); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks, - cache, - globalPreferences: { - dispatch: vi.fn(), - globalPreferences: { - organisation: { id: organisation1.id, name: organisation1.name }, - availableBases: organisation1.bases, - selectedBase: organisation1.bases[0], - }, - }, - }); - - // BoxReconciliation is visible - expect(await screen.findByText(/box 123/i)).toBeInTheDocument(); - - // Click trashIcon Button - const noDeliveryIconButton = screen.getByTestId("NoDeliveryIcon"); - expect(noDeliveryIconButton).toBeInTheDocument(); - await user.click(noDeliveryIconButton); - - // AYS is open - expect(await screen.findByText(/box not delivered\?/i)).toBeInTheDocument(); - const noButton = screen.getByRole("button", { name: /nevermind/i }); - expect(noButton).toBeInTheDocument(); - await user.click(noButton); - - // BoxReconciliation is visible - expect(await screen.findByText(/box 123/i)).toBeInTheDocument(); - - // 4.7.3 - Click NoDelivery Button - const matchProductButton = await screen.findByRole("button", { - name: /1\. match products/i, - }); - expect(matchProductButton).toBeInTheDocument(); - await user.click(matchProductButton); - const noDeliveryButton = screen.getByTestId("NoDeliveryButton"); - expect(noDeliveryButton).toBeInTheDocument(); - await user.click(noDeliveryButton); - - // AYS is open - expect(await screen.findByText(/box not delivered\?/i)).toBeInTheDocument(); - const yesButton = screen.getByTestId("AYSRightButton"); - expect(yesButton).toBeInTheDocument(); - await user.click(yesButton); - - // toast shown - await waitFor( - () => - expect(toast.isError ? mockedTriggerError : mockedCreateToast).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(toast.message), - }), - ), - { timeout: 5000 }, - ); - }, - 40000, - ); - }); -}); - -// Test case 4.7.1 - -it("4.7.1 - Query for shipment, box, available products, sizes and locations is loading ", async () => { - const user = userEvent.setup(); - boxReconciliationOverlayVar({ - isOpen: true, - labelIdentifier: "123", - shipmentId: "1", - } as IBoxReconciliationOverlayVar); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryShipmentDetailForBoxReconciliation], - cache, - globalPreferences: { - dispatch: vi.fn(), - globalPreferences: { - organisation: { id: organisation1.id, name: organisation1.name }, - availableBases: organisation1.bases, - selectedBase: organisation1.bases[0], - }, - }, - }); - - expect((await screen.findAllByText(/box 123/i)).length).toBeGreaterThanOrEqual(1); - - expect(screen.getAllByText(/1\. match products/i)).toHaveLength(1); - expect(screen.getAllByText(/2\. receive location/i)).toHaveLength(1); - const matchProductButton = screen.getByRole("button", { - name: /1\. match products/i, - }); - await user.click(matchProductButton); - - expect((await screen.findAllByText(/Long Sleeves/i)).length).toBeGreaterThanOrEqual(1); - expect((await screen.findAllByText(/sender product & gender/i)).length).toBeGreaterThanOrEqual(1); - const selectProductControlInput = screen.getByText(/save product as\.\.\./i); - // check if source product renders correctly - expect(screen.getByText(/Long Sleeves \(Women\)/i)).toBeInTheDocument(); - await user.click(selectProductControlInput); - [/Winter Jackets \(Men\)/, /Long Sleeves \(Women\)/].forEach(async (option) => { - expect(await screen.findByRole("option", { name: option })).toBeInTheDocument(); - }); - - const receiveLocationButton = screen.getByRole("button", { - name: /2\. receive location/i, - }); - await user.click(receiveLocationButton); - - expect((await screen.findAllByText(/select location/i)).length).toBeGreaterThanOrEqual(1); - - const selectLocationControlInput = screen.getByText(/select location/i); - await user.click(selectLocationControlInput); - expect(await screen.findByRole("option", { name: /WH Men/i })).toBeInTheDocument(); -}, 20000); diff --git a/front/src/components/HeaderMenu/HeaderMenuContainer.test.tsx b/front/src/components/HeaderMenu/HeaderMenuContainer.test.tsx deleted file mode 100644 index 0c5a5bd99..000000000 --- a/front/src/components/HeaderMenu/HeaderMenuContainer.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { vi, it, expect } from "vitest"; -import { screen, render } from "tests/test-utils"; -import { useAuth0 } from "@auth0/auth0-react"; -import HeaderMenuContainer from "components/HeaderMenu/HeaderMenuContainer"; -import { QrReaderScanner } from "components/QrReader/components/QrReaderScanner"; -import { mockAuthenticatedUser } from "mocks/hooks"; -import { mockImplementationOfQrReader } from "mocks/components"; - -vi.mock("@auth0/auth0-react"); -vi.mock("components/QrReader/components/QrReaderScanner"); -const mockedUseAuth0 = vi.mocked(useAuth0); -const mockedQrReader = vi.mocked(QrReaderScanner); - -it("1.3.1 - Menus are available to the user depending on ABPs - Nothing", async () => { - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - mockAuthenticatedUser(mockedUseAuth0, "dev_volunteer@boxaid.org"); - - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - }); - - expect(screen.queryByRole("button", { name: /Statistics/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Aid Inventory/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Coordinator Admin/i })).not.toBeInTheDocument(); -}, 10000); - -it("1.3.2 - Menus are available to the user depending on ABPs - Aid Inventory", async () => { - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - mockAuthenticatedUser( - mockedUseAuth0, - "dev_volunteer@boxaid.org", - ["view_inventory", "view_shipments", "view_beneficiary_graph", "create_label"], - "3", - ); - - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - }); - - expect(screen.getByRole("button", { name: /Statistics/i })).toBeInTheDocument(); - - expect(screen.getByRole("button", { name: /Aid Inventory/i })).toBeInTheDocument(); - expect(screen.getByText(/Print Box Labels/i)).toBeInTheDocument(); - expect(screen.queryByText(/Manage Boxes/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Classic Manage Boxes/i)).not.toBeInTheDocument(); - - expect(screen.queryByRole("button", { name: /Coordinator Admin/i })).not.toBeInTheDocument(); -}, 10000); - -it("1.3.3 - Menus are available to the user depending on ABPs - Aid Inventory w/ submenus Stock Planning, Manage Boxes", async () => { - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - mockAuthenticatedUser( - mockedUseAuth0, - "dev_volunteer@boxaid.org", - ["create_label", "manage_inventory"], - "0", - ); - - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - }); - - expect(screen.queryByRole("button", { name: /Statistics/i })).not.toBeInTheDocument(); - - expect(screen.getByRole("button", { name: /Aid Inventory/i })).toBeInTheDocument(); - expect(screen.getByText(/Print Box Labels/i)).toBeInTheDocument(); - expect(screen.getByText(/Classic Manage Boxes/i)).toBeInTheDocument(); - - expect(screen.queryByRole("button", { name: /Coordinator Admin/i })).not.toBeInTheDocument(); -}, 10000); - -it("1.3.4 - Menus available to the user depending on ABPs - Coordinator Admin", async () => { - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - mockAuthenticatedUser( - mockedUseAuth0, - "dev_volunteer@boxaid.org", - ["view_inventory", "view_shipments", "view_beneficiary_graph", "manage_volunteers"], - "3", - ); - - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - }); - - expect(screen.getByRole("button", { name: /Statistics/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Aid Inventory/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Coordinator Admin/i })).toBeInTheDocument(); -}, 10000); - -it("1.3.5 - Menus available to the user depending on ABPs - Coordinator Admin w/ submenus Manage Products, Edit Warehouses", async () => { - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - mockAuthenticatedUser( - mockedUseAuth0, - "dev_volunteer@boxaid.org", - [ - "view_inventory", - "view_shipments", - "view_beneficiary_graph", - "manage_volunteers", - "manage_products", - "manage_warehouses", - ], - "3", - ); - - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - }); - - expect(screen.getByRole("button", { name: /Statistics/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Aid Inventory/i })).toBeInTheDocument(); - - expect(screen.getByRole("button", { name: /Coordinator Admin/i })).toBeInTheDocument(); - expect(screen.getByText(/Manage Products/i)).toBeInTheDocument(); - expect(screen.getByText(/Edit Warehouses/i)).toBeInTheDocument(); -}, 10000); diff --git a/front/src/components/QrReader/components/QrReader.tsx b/front/src/components/QrReader/components/QrReader.tsx index 69a02637c..45d73078c 100644 --- a/front/src/components/QrReader/components/QrReader.tsx +++ b/front/src/components/QrReader/components/QrReader.tsx @@ -38,9 +38,11 @@ function QrReader({ // Did the QrReaderScanner catch a QrCode? --> call onScan with text value const onResult = useCallback( - (multiScan: boolean, qrReaderResult: Result | undefined | null) => { - if (qrReaderResult) { + (multiScan: boolean, qrReaderResult: Result | string | undefined | null) => { + if (qrReaderResult && qrReaderResult instanceof Result) { onScan(qrReaderResult.getText(), multiScan); + } else if (qrReaderResult && typeof qrReaderResult === "string") { + onScan(qrReaderResult, multiScan); } }, [onScan], diff --git a/front/src/components/QrReader/components/QrReaderScanner.tsx b/front/src/components/QrReader/components/QrReaderScanner.tsx index 7269a574f..7709c68e3 100644 --- a/front/src/components/QrReader/components/QrReaderScanner.tsx +++ b/front/src/components/QrReader/components/QrReaderScanner.tsx @@ -1,4 +1,4 @@ -import { MutableRefObject, useEffect, useRef } from "react"; +import { ElementRef, MutableRefObject, useEffect, useRef } from "react"; import { BrowserQRCodeReader, IScannerControls } from "@zxing/browser"; import { Result } from "@zxing/library"; import { styles } from "./QrReaderScannerStyles"; @@ -10,9 +10,9 @@ export type OnResultFunction = ( */ multiScan: boolean, /** - * The QR values extracted by Zxing + * The QR values extracted by Zxing or mocked through search params */ - result?: Result | undefined | null, + result?: Result | string | undefined | null, /** * The name of the exceptions thrown while reading the QR */ @@ -44,8 +44,11 @@ export function QrReaderScanner({ onResult, scanPeriod: delayBetweenScanAttempts = 500, }: QrReaderScannerProps) { + // this is to avoid infinite rendering loops when using qr code from url for testing + const scanFromURLSearchParam = useRef(false); // this ref is needed to pass/preview the video stream coming from BrowserQrCodeReader to the the user - const previewVideoRef: MutableRefObject = useRef(null); + const previewVideoRef: MutableRefObject = + useRef>(null); // this ref is to store the controls for the BrowerQRCodeReader. We only need it to tell it to stop scanning at certain points. const controlsRef: MutableRefObject = useRef(null); // this ref is to store the BrowerQRCodeReader. We need a reference with useRef to ensure that multiple scanning processes are started by the different renders. @@ -58,6 +61,23 @@ export function QrReaderScanner({ zoom, }; + /** + * Mock QR code in URL Search Params for testing purposes. + * + * Multiple `qr` search params means multiscan. + */ + const qrCodeParams = new URLSearchParams(window.location.search).getAll("qr"); + + if (!scanFromURLSearchParam.current && qrCodeParams.length) { + scanFromURLSearchParam.current = true; + + // Convert to format readed by QrResolver Hook. e.g. barcode=foobar + const qrCodeConvertedToBarcode = qrCodeParams.map((qrCode) => `barcode=${qrCode}`); + for (const qrCode of qrCodeConvertedToBarcode) { + onResult(qrCodeParams.length > 1, qrCode); + } + } + if (previewVideoRef.current == null) { console.error("QR Reader: Video Element not (yet) available"); return; diff --git a/front/src/components/QrReaderOverlay/QrReaderOverlay.test.tsx b/front/src/components/QrReaderOverlay/QrReaderOverlay.test.tsx deleted file mode 100644 index 5da8bdf3e..000000000 --- a/front/src/components/QrReaderOverlay/QrReaderOverlay.test.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import { vi, beforeEach, it, expect } from "vitest"; -import { userEvent } from "@testing-library/user-event"; -import { screen, render, waitFor } from "tests/test-utils"; -import HeaderMenuContainer from "components/HeaderMenu/HeaderMenuContainer"; -import { useAuth0 } from "@auth0/auth0-react"; -import { QrReaderScanner } from "components/QrReader/components/QrReaderScanner"; -import { mockAuthenticatedUser } from "mocks/hooks"; -import { mockImplementationOfQrReader } from "mocks/components"; -import { - BOX_DETAILS_BY_LABEL_IDENTIFIER_QUERY, - GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, -} from "queries/queries"; -import { generateMockBox } from "mocks/boxes"; -import { mockedTriggerError } from "tests/setupTests"; -import { FakeGraphQLError } from "mocks/functions"; - -vi.mock("@auth0/auth0-react"); -vi.mock("components/QrReader/components/QrReaderScanner"); -const mockedUseAuth0 = vi.mocked(useAuth0); -const mockedQrReader = vi.mocked(QrReaderScanner); - -beforeEach(() => { - mockAuthenticatedUser(mockedUseAuth0, "dev_volunteer@boxaid.org"); -}); - -const queryFindNoBoxAssociated = { - request: { - query: BOX_DETAILS_BY_LABEL_IDENTIFIER_QUERY, - variables: { - labelIdentifier: "123456", - }, - }, - result: { - data: { - box: null, - }, - errors: [new FakeGraphQLError("BAD_USER_INPUT")], - }, -}; - -it("3.4.1.2 - Mobile: Enter invalid box identifier and click on Find button", async () => { - const user = userEvent.setup(); - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - // mock scanning a QR code - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryFindNoBoxAssociated], - additionalRoute: "/bases/1/boxes/123456", - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Find Box - const findBoxButton = await screen.findByRole("button", { name: /find/i }); - await user.type(screen.getByRole("textbox"), "123456"); - await user.click(findBoxButton); - - // error message appears - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/A box with this label number doesn't exist/i), - }), - ), - ); - // QrOverlay stays open - expect(screen.getByRole("button", { name: /find/i })).toBeInTheDocument(); -}, 30000); - -const queryFindBox = { - request: { - query: BOX_DETAILS_BY_LABEL_IDENTIFIER_QUERY, - variables: { - labelIdentifier: "123456", - }, - }, - result: { - data: { - box: generateMockBox({ labelIdentifier: "123456" }), - }, - }, -}; - -it("3.4.1.3 - Mobile: Enter valid box identifier and click on Find button", async () => { - const user = userEvent.setup(); - mockImplementationOfQrReader(mockedQrReader, "BoxAssociatedWithQrCode"); - // mock scanning a QR code - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryFindBox], - additionalRoute: "/bases/1/boxes/123456", - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Find Box - const findBoxButton = await screen.findByRole("button", { name: /find/i }); - await user.type(screen.getByRole("textbox"), "123456"); - await user.click(findBoxButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - expect(await screen.findByRole("heading", { name: "/bases/1/boxes/123456" })).toBeInTheDocument(); -}, 20000); - -const queryFindBoxFromOtherOrg = { - request: { - query: BOX_DETAILS_BY_LABEL_IDENTIFIER_QUERY, - variables: { - labelIdentifier: "123456", - }, - }, - result: { - data: { - box: null, - }, - errors: [new FakeGraphQLError("FORBIDDEN")], - }, -}; - -it("3.4.1.4 - Mobile: Enter valid box identifier from unauthorized bases and click on Find button", async () => { - const user = userEvent.setup(); - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - // mock scanning a QR code - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryFindBoxFromOtherOrg], - additionalRoute: "/bases/1/boxes/123456", - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Find Box - const findBoxButton = await screen.findByRole("button", { name: /find/i }); - await user.type(screen.getByRole("textbox"), "123456"); - await user.click(findBoxButton); - - // error message appears - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/You don't have permission to access this box/i), - }), - ), - ); - // QrOverlay stays open - expect(screen.getByRole("button", { name: /find/i })).toBeInTheDocument(); -}, 10000); - -const queryNoBoxAssociatedWithQrCode = { - request: { - query: GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, - variables: { - qrCode: "NoBoxAssociatedWithQrCode", - }, - }, - result: { - data: { - qrCode: { - __typename: "QrCode", - code: "NoBoxAssociatedWithQrCode", - box: null, - }, - }, - }, -}; - -it("3.4.2.1 - Mobile: User scans QR code of same org without previously associated box", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode"); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryNoBoxAssociatedWithQrCode], - additionalRoute: "/bases/1/boxes/create/NoBoxAssociatedWithQrCode", - mediaQueryReturnValue: false, - }); - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - const scanButton = await screen.findByTestId("ReturnScannedQr"); - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(scanButton); - expect( - await screen.findByRole("heading", { name: "/bases/1/boxes/create/NoBoxAssociatedWithQrCode" }), - ).toBeInTheDocument(); -}, 20000); - -const queryBoxAssociatedWithQrCode = { - request: { - query: GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, - variables: { - qrCode: "BoxAssociatedWithQrCode", - }, - }, - result: { - data: { - qrCode: { - __typename: "QrCode", - code: "BoxAssociatedWithQrCode", - box: generateMockBox({}), - }, - }, - }, -}; - -it("3.4.2.2 - Mobile: user scans QR code of same org with associated box", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "BoxAssociatedWithQrCode"); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryBoxAssociatedWithQrCode], - additionalRoute: "/bases/1/boxes/123", - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(screen.getByTestId("ReturnScannedQr")); - expect(await screen.findByRole("heading", { name: "/bases/1/boxes/123" })).toBeInTheDocument(); -}, 20000); - -const queryBoxFromOtherOrganisation = { - request: { - query: GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, - variables: { - qrCode: "BoxFromOtherOrganisation", - }, - }, - result: { - data: { - qrCode: { - __typename: "QrCode", - code: "BoxFromOtherOrganisation", - box: { - __typename: "UnauthorizedForBaseError", - baseName: "Base Foo", - organisationName: "BoxAid", - }, - }, - }, - errors: undefined, - }, -}; - -it("3.4.2.3 - Mobile: user scans QR code of different org with associated box", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "BoxFromOtherOrganisation"); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryBoxFromOtherOrganisation], - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(screen.getByTestId("ReturnScannedQr")); - - // error message appears - expect( - await screen.findByText(/This box it at base Base Foo, which belongs to organization BoxAid./), - ).toBeInTheDocument(); - // QrOverlay stays open - expect(screen.getByTestId("ReturnScannedQr")).toBeInTheDocument(); -}, 10000); - -it("3.4.2.5a - Mobile: User scans non Boxtribute QR code", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "NonBoxtributeQr", false); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryBoxFromOtherOrganisation], - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(screen.getByTestId("ReturnScannedQr")); - - // error message appears - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/This is not a Boxtribute QR code/i), - }), - ), - ); - // QrOverlay stays open - expect(screen.getByTestId("ReturnScannedQr")).toBeInTheDocument(); -}, 15000); - -const queryHashNotInDb = { - request: { - query: GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, - variables: { - qrCode: "HashNotInDb", - }, - }, - result: { - data: { - qrCode: { - __typename: "ResourceDoesNotExistError", - resourceName: "qr", - }, - }, - }, -}; - -it("3.4.2.5b - Mobile: User scans non Boxtribute QR code", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "HashNotInDb"); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryHashNotInDb], - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(screen.getByTestId("ReturnScannedQr")); - - // error message appears - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/This is not a Boxtribute QR code/i), - }), - ), - ); - - // QrOverlay stays open - expect(screen.getByTestId("ReturnScannedQr")).toBeInTheDocument(); -}, 20000); - -const queryInternalServerError = { - request: { - query: GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE, - variables: { - qrCode: "InternalServerError", - }, - }, - result: { - data: null, - errors: [new FakeGraphQLError("INTERNAL_SERVER_ERROR")], - }, -}; - -it("3.4.2.5c - Internal Server Error", async () => { - const user = userEvent.setup(); - // mock scanning a QR code - mockImplementationOfQrReader(mockedQrReader, "InternalServerError"); - render(, { - routePath: "/bases/:baseId", - initialUrl: "/bases/1", - mocks: [queryInternalServerError], - mediaQueryReturnValue: false, - }); - - // Open the menu - const menuButton = await screen.findByTestId("menu-button"); - await user.click(menuButton); - - // 3.4.1.1 - Open QROverlay - const qrButton = await screen.findByTestId("qr-code-button"); - await user.click(qrButton); - - // Click a button to trigger the event of scanning a QR-Code in mockImplementationOfQrReader - await user.click(screen.getByTestId("ReturnScannedQr")); - - // error message appears - await waitFor(() => - expect(mockedTriggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/QR code lookup failed/i), - }), - ), - ); - // QrOverlay stays open - expect(screen.getByTestId("ReturnScannedQr")).toBeInTheDocument(); -}, 20000); diff --git a/front/src/index.tsx b/front/src/index.tsx index 26981f552..499836077 100644 --- a/front/src/index.tsx +++ b/front/src/index.tsx @@ -16,6 +16,7 @@ import App from "./App"; import { theme } from "./utils/theme"; import { captureConsoleIntegration } from "@sentry/react"; import React from "react"; +import { worker } from "../browser"; const ProtectedApp = withAuthenticationRequired(() => ( @@ -48,6 +49,8 @@ if (sentryDsn) { }); } +if (import.meta.env.FRONT_USE_MSW === "true") worker.start(); + const root = createRoot(document.getElementById("root")!); root.render( diff --git a/front/src/mocks/bases.ts b/front/src/mocks/bases.ts deleted file mode 100644 index d40cca9e2..000000000 --- a/front/src/mocks/bases.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const base1 = { - __typename: "Base", - id: "1", - name: "Lesvos", - organisation: { - id: "1", - name: "BoxAid", - __typename: "Organisation", - }, -}; - -export const base2 = { - __typename: "Base", - id: "2", - name: "Thessaloniki", - organisation: { - id: "2", - name: "BoxCare", - __typename: "Organisation", - }, -}; diff --git a/front/src/mocks/boxes.ts b/front/src/mocks/boxes.ts deleted file mode 100644 index f98b84cdd..000000000 --- a/front/src/mocks/boxes.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { product1, productBasic1 } from "./products"; -import { size1, size2 } from "./sizeRanges"; -import { location1, generateMockLocationWithBase } from "./locations"; -import { tag1, tag2 } from "./tags"; -import { history1, history2 } from "./histories"; - -export const box123 = { - labelIdentifier: "123", - state: "InStock", - product: product1, - size: size1, - shipmentDetail: null, - location: location1, - numberOfItems: 62, - tags: [tag1], - comment: "Test", - history: null, - createdOn: "2023-11-09T17:24:29+00:00", - lastModifiedOn: "2023-11-19T10:24:29+00:00", - __typename: "Box", -}; - -export const generateMockBox = ({ - id = "1", - labelIdentifier = "123", - state = "InStock", - numberOfItems = 31, - location = generateMockLocationWithBase({}), - comment = "Good Comment", - product = productBasic1, - shipmentDetail = null as any, - size = size2, - tags = [tag2], - histories = [history1, history2], -}) => ({ - id, - labelIdentifier, - state, - product, - size, - shipmentDetail, - location, - numberOfItems, - tags, - comment, - history: histories, - createdOn: "2023-11-09T17:24:29+00:00", - lastModifiedOn: "2023-11-19T10:24:29+00:00", - distributionEvent: null, - deletedOn: null, - __typename: "Box", -}); - -const unauthorizedForBaseErrorBox = { - __typename: "UnauthorizedForBaseError", - baseName: "Base Foo", - organisationName: "BoxAid", -}; - -const insufficientPermissionErrorBox = { - __typename: "InsufficientPermissionError", - name: "Base Bar", -}; - -/** - * Generate box data based on ownership: Organization, Base and Permissions. - * - * Check `GET_BOX_LABEL_IDENTIFIER_BY_QR_CODE` query for reference. - */ -export const handleBoxGeneration = ({ - labelIdentifier = "123", - state = "InStock", - isBoxAssociated = true, - isBoxSameBase = true, - isBoxSameOrg = true -}) => { - if (isBoxAssociated && isBoxSameOrg && isBoxSameBase) - return generateMockBox({ labelIdentifier, state }); - - if (isBoxAssociated && !isBoxSameOrg) - return unauthorizedForBaseErrorBox; - - if (isBoxAssociated && !isBoxSameBase) - return insufficientPermissionErrorBox; - - // Box not associated with the QR code or no permission and authorization will end up here. - return null; -} diff --git a/front/src/mocks/components.tsx b/front/src/mocks/components.tsx deleted file mode 100644 index 8df6fc29f..000000000 --- a/front/src/mocks/components.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Result } from "@zxing/library"; - -/** - * Mocking the QrReader component in components/QrReader/QrReader by overriding the implemention of the component with a button. - * To mock the QrReader component you have to - * - add a mock of the path for imports by adding `vi.mock("components/QrReader/QrReader")` at the top of your test file. - * - mock the acctual component by adding `const mockedQrReader = vi.mocked(QrReader);` in your testfile - * - to override the implementation of the component you need to pass the mockedQrReader above into the function below. - * - * If you then call this function, e.g. `mockImplementationOfQrReader(mockedQrReader, "NoBoxAssociatedWithQrCode");` in a test a - * button is added to the DOM instead of the QrReader component. By clicking this button, you can mock firing the onResult Event - * of the QrReader. - * @param mockedQrReader - The mocked QrReader component whose implementation needs to be overwritten. - * @param hash - md5 hash of Boxtribute boxes - * @param isBoxtributeQr - to test non Boxtribute qr-code - * @returns An component including a button that fires the onResult event of the QrReader component when it is clicked. - */ -export function mockImplementationOfQrReader( - mockedQrReader: any, - hash: string, - isBoxtributeQr: boolean = true, - multiScan: boolean = false, -) { - mockedQrReader.mockImplementation((props) => ( -