diff --git a/package.json b/package.json index f7d689d3..b7167bba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@helsenorge/refero", - "version": "14.0.1", + "version": "14.0.2-beta02", "engines": { "node": "^18.0.0", "npm": ">=9.0.0" diff --git a/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx b/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx new file mode 100644 index 00000000..57249ede --- /dev/null +++ b/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; + +import { Matcher, render, screen } from '@testing-library/react'; + +import userEvent from '@testing-library/user-event'; + +import '@testing-library/jest-dom'; + +import { + MimeType_For_Test_Util as MIME_TYPES_TEST, + createMockAttachmentProps, + createMockFile, + createMockQuestionnaireItem, + createMockQuestionnaireItemWithEmptyValue, +} from '../mockUtil'; +import { Resources } from '../../../../util/resources'; +import { convertBytesToMBString, convertMBToBytes } from '../attachmentUtil'; +import constants from '../../../../constants'; +import { AttachmentComponent } from '../attachment'; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated but included for compatibility + removeListener: jest.fn(), // Deprecated but included for compatibility + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + +const mockFileTooLarge = 'Filstørrelsen må være mindre enn {0} MB'; +const wrongFileTypeMsg = 'Feil filtype'; +const mockFileName = 'testFile.txt'; +const defaulMockSize = 3; +const qItemMockName = 'qItem'; +const PLAIN_TEXT_3_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(defaulMockSize)); +const PLAIN_TEXT_4_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(4)); +const PLAIN_TEXT_5_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(5)); +const JPEG_5_MB = createMockFile(mockFileName, MIME_TYPES_TEST.JPG, convertMBToBytes(5)); +const PLAIN_TEXT_6_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(6)); +const PLAIN_TEXT_30_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(30)); + +const mockResources: Partial = { + validationFileMax: mockFileTooLarge, + validationFileType: wrongFileTypeMsg, +}; + +const expectReplacedFileSizeError = (number: any) => { + const resourceStringWithNumber = mockFileTooLarge.replace('{0}', number); + expect(screen.getByText(resourceStringWithNumber)).toBeInTheDocument(); +}; + +async function uploadMockFile(mockFile: File | File[], label = 'Last opp fil') { + const input = screen.getByLabelText(label); + await userEvent.upload(input, mockFile); +} + +export const expectNotToFindByText = (text: Matcher) => { + expect(screen.queryByText(text)).toBeNull(); +}; + +function expectNoFileErrors() { + expect(screen.queryByText(wrongFileTypeMsg)).toBe(null); + expect(screen.queryByText(mockFileTooLarge)).toBe(null); +} + +describe('', () => { + describe('File Type validation', () => { + it('When uploading a file - Show error if mime type is NOT among valid types', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, true); + const validTypes = [MIME_TYPES_TEST.PNG, MIME_TYPES_TEST.JPG, MIME_TYPES_TEST.PDF]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expect(screen.getByText(wrongFileTypeMsg)).toBeInTheDocument(); + }); + + it('When uploading a file - Do NOT show error file type error message when valid mime', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PNG, MIME_TYPES_TEST.JPG, MIME_TYPES_TEST.PDF, MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expectNotToFindByText(wrongFileTypeMsg); + }); + }); + + describe('File Size validation - Questionnaire Extension', () => { + it('When uploading a file - Show resource size error if size > max rule in qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + + await uploadMockFile(PLAIN_TEXT_6_MB); + + screen.debug(undefined, 6000000); + expectReplacedFileSizeError(5); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Do NOT show resource size error if size <= max rule in qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_4_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - Do NOT show resource size error if file size excactly max rule from qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - And not set Questionnaire item max rule will be read as null and should be skipped', async () => { + const qItem = createMockQuestionnaireItemWithEmptyValue('test', null); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + }); + + it('When uploading a file - And not set Questionnaire item max rule with undefined value should be skipped', async () => { + const qItem = createMockQuestionnaireItemWithEmptyValue('test', undefined); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + }); + }); + + describe('File Size validation - Max Setttings From Props', () => { + it('When uploading a file - Show resource size error if filesize > Props Max', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(5), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_6_MB); + expectReplacedFileSizeError(5); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Do NOT show size error message - when file size excatly == props max value', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_4_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - Do NOT show resource size error if size == excactly props max value', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(5), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectNoFileErrors(); + }); + }); + + describe('File validation - Prioritiy of rules', () => { + it('When uploading a file - File type errors should have priority over other errors', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(JPEG_5_MB); + expect(screen.getByText(wrongFileTypeMsg)).toBeInTheDocument(); + }); + + it('When uploading a file - Questionniare Item Max Rule has priority over props if both set', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expectReplacedFileSizeError(2); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - And questionnaire max rule is not set, use props max value if set', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Refero constant should be fallback if neither qItem rule or props', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_30_MB); + expectReplacedFileSizeError(convertBytesToMBString(constants.MAX_FILE_SIZE)); + expectNotToFindByText(wrongFileTypeMsg); + }); + }); +}); diff --git a/src/components/formcomponents/attachment/attachment.tsx b/src/components/formcomponents/attachment/attachment.tsx index 3af1be36..3e26113e 100644 --- a/src/components/formcomponents/attachment/attachment.tsx +++ b/src/components/formcomponents/attachment/attachment.tsx @@ -15,18 +15,18 @@ import { TextMessage } from '../../../types/text-message'; import { UploadedFile } from '@helsenorge/file-upload/components/dropzone'; import { ValidationProps } from '@helsenorge/form/components/form/validation'; +import AttachmentHtml from './attachmenthtml'; import { NewValueAction, newAttachmentAsync, removeAttachmentAsync } from '../../../actions/newValue'; import { GlobalState } from '../../../reducers'; import { getValidationTextExtension, getMaxOccursExtensionValue, getMinOccursExtensionValue } from '../../../util/extension'; import { isRequired, getId, isReadOnly, isRepeat, getSublabelText } from '../../../util/index'; import { mapStateToProps, mergeProps, mapDispatchToProps } from '../../../util/map-props'; -import { Resources } from '../../../util/resources'; import { Path } from '../../../util/refero-core'; +import { Resources } from '../../../util/resources'; import withCommonFunctions from '../../with-common-functions'; import Label from '../label'; import SubLabel from '../sublabel'; import TextView from '../textview'; -import AttachmentHtml from './attachmenthtml'; export interface Props { dispatch?: ThunkDispatch; @@ -58,7 +58,7 @@ export interface Props { onRenderMarkdown?: (item: QuestionnaireItem, markdown: string) => string; } -class AttachmentComponent extends React.Component { +export class AttachmentComponent extends React.Component { onUpload = (files: File[], cb: (success: boolean, errormessage: TextMessage | null, uploadedFile?: UploadedFile) => void): void => { const { uploadAttachment, path, item, onAnswerChange } = this.props; if (uploadAttachment) { diff --git a/src/components/formcomponents/attachment/attachmentUtil.ts b/src/components/formcomponents/attachment/attachmentUtil.ts new file mode 100644 index 00000000..31777f3f --- /dev/null +++ b/src/components/formcomponents/attachment/attachmentUtil.ts @@ -0,0 +1,38 @@ +export function convertMBToBytes(mb: number): number { + if (typeof mb !== 'number' || isNaN(mb)) { + throw new Error('Input must be a valid number.'); + } + if (mb < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(mb)) { + throw new Error('Input must be a finite number.'); + } + return Math.round(mb * 1024 * 1024); +} + +export function convertBytesToMBString(bytes: number, precision: number = 0): string { + if (typeof bytes !== 'number' || isNaN(bytes)) { + throw new Error('Input must be a valid number.'); + } + if (bytes < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(bytes)) { + throw new Error('Input must be a finite number.'); + } + return (bytes / 1024 / 1024).toFixed(precision); +} + +export function convertBytesToMB(bytes: number): number { + if (typeof bytes !== 'number' || isNaN(bytes)) { + throw new Error('Input must be a valid number.'); + } + if (bytes < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(bytes)) { + throw new Error('Input must be a finite number.'); + } + return bytes / 1024 / 1024; +} diff --git a/src/components/formcomponents/attachment/attachmenthtml.tsx b/src/components/formcomponents/attachment/attachmenthtml.tsx index 401028d1..32148226 100644 --- a/src/components/formcomponents/attachment/attachmenthtml.tsx +++ b/src/components/formcomponents/attachment/attachmenthtml.tsx @@ -10,8 +10,9 @@ import { UploadedFile } from '@helsenorge/file-upload/components/dropzone'; import { sizeIsValid, mimeTypeIsValid } from '@helsenorge/file-upload/components/dropzone/validation'; import Validation, { ValidationProps } from '@helsenorge/form/components/form/validation'; +import { convertBytesToMBString, convertMBToBytes } from './attachmentUtil'; import constants, { VALID_FILE_TYPES } from '../../../constants'; -import { getValidationTextExtension } from '../../../util/extension'; +import { getMaxSizeExtensionValue, getValidationTextExtension } from '../../../util/extension'; import { Resources } from '../../../util/resources'; interface Props { @@ -65,7 +66,8 @@ const attachmentHtml: React.SFC = ({ children, ...other }) => { - const maxFilesize = attachmentMaxFileSize ? attachmentMaxFileSize : constants.MAX_FILE_SIZE; + const getMaxValueBytes = getAttachmentMaxSizeBytesToUse(attachmentMaxFileSize, item); + const getMaxValueMBToReplace = convertBytesToMBString(getMaxValueBytes); const validFileTypes = attachmentValidTypes ? attachmentValidTypes : VALID_FILE_TYPES; const deleteText = resources ? resources.deleteAttachmentText : undefined; @@ -81,13 +83,13 @@ const attachmentHtml: React.SFC = ({ onOpenFile={onOpen} uploadButtonText={uploadButtonText} uploadedFiles={uploadedFiles} - maxFileSize={maxFilesize} + maxFileSize={getMaxValueBytes} validMimeTypes={validFileTypes} dontShowHardcodedText={!!deleteText} deleteText={deleteText} supportedFileFormatsText={resources ? resources.supportedFileFormats : undefined} errorMessage={(file: File): string => { - return getErrorMessage(validFileTypes, maxFilesize, item, errorText, file, resources); + return getErrorMessage(validFileTypes, getMaxValueBytes, getMaxValueMBToReplace, item, errorText, file, resources); }} isRequired={isRequired} wrapperClasses="page_refero__input" @@ -106,9 +108,23 @@ const attachmentHtml: React.SFC = ({ ); }; +export function getAttachmentMaxSizeBytesToUse(defaultMaxProps: number | undefined, item: QuestionnaireItem): number { + if (item) { + const questionnaireMaxRuleSizeMB = getMaxSizeExtensionValue(item); + if (questionnaireMaxRuleSizeMB !== undefined) { + return convertMBToBytes(questionnaireMaxRuleSizeMB); + } + } + if (defaultMaxProps !== undefined) { + return defaultMaxProps; + } + return constants.MAX_FILE_SIZE; +} + function getErrorMessage( validFileTypes: Array, maxFileSize: number, + maxFileSizeMBStringToReplace: string, item: QuestionnaireItem, genericErrorText?: string, file?: File, @@ -118,7 +134,7 @@ function getErrorMessage( if (!mimeTypeIsValid(file, validFileTypes)) { return resources.validationFileType; } else if (!sizeIsValid(file, maxFileSize)) { - return resources.validationFileMax; + return resources.validationFileMax.replace('{0}', maxFileSizeMBStringToReplace); } } diff --git a/src/components/formcomponents/attachment/mockUtil.tsx b/src/components/formcomponents/attachment/mockUtil.tsx new file mode 100644 index 00000000..9df523c2 --- /dev/null +++ b/src/components/formcomponents/attachment/mockUtil.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; + +import { Questionnaire, QuestionnaireItem, QuestionnaireResponseItem } from '../../../types/fhir'; + +import { Props } from './attachment'; +import { Resources } from '../../../util/resources'; + +const mockQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: '', +}; + +const mockQuestionnaireResponseMock: QuestionnaireResponseItem = { + linkId: '', +}; + +/** Mock Testing Enum */ +export const MimeType_For_Test_Util = { + PlainText: 'text/plain', + HTML: 'text/html', + CSV: 'text/csv', + JPG: 'image/jpeg', + PNG: 'image/png', + GIF: 'image/gif', + PDF: 'application/pdf', + JSON: 'application/json', +}; + +/** Mock Testing Util method */ +export function createMockFile(fileName: string, mimeType: string, size: number): File { + // Initialize content with the specified size (the content itself doesn't matter for the mock) + const fileContent = new Array(size).fill('a').join(''); + const blob = new Blob([fileContent], { type: mimeType }); + const lastModifiedDate = new Date(); + const mockFile = new File([blob], fileName, { + type: mimeType, + lastModified: lastModifiedDate.getTime(), + }); + // Emulate the actual size (the size of the content may differ from the 'size' parameter due to encoding) + Object.defineProperty(mockFile, 'size', { + value: size, + writable: false, + }); + return mockFile; +} + +/** Mock Testing Util method */ +export function createMockAttachmentProps( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + item: QuestionnaireItem | any, + partialResources: Partial, + attachmentMaxFileSize?: number, + attachmentErrorMessage?: string, + attachmentValidTypes?: string[] +): Props { + // Create a base mock with required and default properties. + const mockProps: Partial = { + dispatch: jest.fn(), + path: [], + item, // Required parameter + questionnaire: mockQuestionnaire, + responseItem: mockQuestionnaireResponseMock, + answer: [], + resources: partialResources as Resources, + renderDeleteButton: jest.fn(() => ), + repeatButton: , + renderHelpButton: jest.fn(() => ), + renderHelpElement: jest.fn(() =>
{'Help content'}
), + onAnswerChange: jest.fn(), + // ... other props with their mock implementations + }; + if (attachmentMaxFileSize !== undefined) { + mockProps.attachmentMaxFileSize = attachmentMaxFileSize; + } + if (attachmentErrorMessage !== undefined) { + mockProps.attachmentErrorMessage = attachmentErrorMessage; + } + if (attachmentValidTypes !== undefined) { + mockProps.attachmentValidTypes = attachmentValidTypes; + } + return mockProps as Props; +} + +/** Mock Testing Util method */ +export function createMockQuestionnaireItem(text: string, valueDecimal: number | undefined, includeExtension: boolean): QuestionnaireItem { + const questionnaireItem: QuestionnaireItem = { + linkId: '4c71df6e-d743-46ba-d81f-f62777ffddb4', + type: 'attachment', + text: text, + }; + if (includeExtension) { + questionnaireItem.extension = [ + { + url: 'http://hl7.org/fhir/StructureDefinition/maxSize', + valueDecimal: valueDecimal, + }, + ]; + } + return questionnaireItem; +} + +/** Mock Testing Util method */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createMockQuestionnaireItemWithEmptyValue(text: string, valueDecimalEmpty: null | undefined) { + const questionnaireItem = { + linkId: '4c71df6e-d743-46ba-d81f-f62777ffddb4', + type: 'attachment', + text: text, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/maxSize', + valueDecimal: valueDecimalEmpty, + }, + ], + }; + return questionnaireItem; +} diff --git a/src/constants/extensions.ts b/src/constants/extensions.ts index 7ba61a4a..dde5089e 100644 --- a/src/constants/extensions.ts +++ b/src/constants/extensions.ts @@ -27,6 +27,8 @@ export default { NAVIGATOR: 'http://helsenorge.no/fhir/StructureDefinition/sdf-questionnaire-navgiator-state', SUBLABEL: 'http://helsenorge.no/fhir/StructureDefinition/sdf-sublabel', + MAX_SIZE_URL: 'http://hl7.org/fhir/StructureDefinition/maxSize', + HYPERLINK: 'http://helsenorge.no/fhir/StructureDefinition/sdf-hyperlink-target', VALUESET_LABEL: 'http://hl7.org/fhir/StructureDefinition/valueset-label', diff --git a/src/util/extension.ts b/src/util/extension.ts index 6e9ba7d3..c2345b8e 100644 --- a/src/util/extension.ts +++ b/src/util/extension.ts @@ -114,6 +114,17 @@ export function getMaxValueExtensionValue(item: QuestionnaireItem): number | und return undefined; } +export function getMaxSizeExtensionValue(item: QuestionnaireItem): number | undefined { + const maxValue = getExtension(ExtensionConstants.MAX_SIZE_URL, item); + if (maxValue && maxValue.valueDecimal !== null && maxValue.valueDecimal !== undefined) { + return Number(maxValue.valueDecimal); + } + if (maxValue && maxValue.valueInteger !== null && maxValue.valueInteger !== undefined) { + return Number(maxValue.valueInteger); + } + return undefined; +} + export function getMinValueExtensionValue(item: QuestionnaireItem): number | undefined { const minValue = getExtension(ExtensionConstants.MIN_VALUE_URL, item); if (minValue && minValue.valueDecimal !== null && minValue.valueDecimal !== undefined) {