diff --git a/lib/local-constructs/clamav-scanning/package-lock.json b/lib/local-constructs/clamav-scanning/package-lock.json index e63172bef4..8bb24ffc4a 100644 --- a/lib/local-constructs/clamav-scanning/package-lock.json +++ b/lib/local-constructs/clamav-scanning/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.679.0", "@types/mime-types": "^2.1.4", - "file-type": "^19.4.1", + "file-type": "^19.6.0", "mime-types": "^2.1.35", "pino": "^9.4.0" }, @@ -2167,13 +2167,13 @@ } }, "node_modules/file-type": { - "version": "19.4.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.4.1.tgz", - "integrity": "sha512-RuWzwF2L9tCHS76KR/Mdh+DwJZcFCzrhrPXpOw6MlEfl/o31fjpTikzcKlYuyeV7e7ftdCGVJTNOCzkYD/aLbw==", + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.6.0.tgz", + "integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==", "license": "MIT", "dependencies": { "get-stream": "^9.0.1", - "strtok3": "^8.1.0", + "strtok3": "^9.0.1", "token-types": "^6.0.0", "uint8array-extras": "^1.3.0" }, @@ -2259,9 +2259,9 @@ } }, "node_modules/peek-readable": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.2.0.tgz", - "integrity": "sha512-U94a+eXHzct7vAd19GH3UQ2dH4Satbng0MyYTMaQatL0pvYYL5CTPR25HBhKtecl+4bfu1/i3vC6k0hydO5Vcw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.3.1.tgz", + "integrity": "sha512-GVlENSDW6KHaXcd9zkZltB7tCLosKB/4Hg0fqBJkAoBgYG2Tn1xtMgXtSUuMU9AK/gCm/tTdT8mgAeF4YNeeqw==", "license": "MIT", "engines": { "node": ">=14.16" @@ -2407,13 +2407,13 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-8.1.0.tgz", - "integrity": "sha512-ExzDvHYPj6F6QkSNe/JxSlBxTh3OrI6wrAIz53ulxo1c4hBJ1bT9C/JrAthEKHWG9riVH3Xzg7B03Oxty6S2Lw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.0.1.tgz", + "integrity": "sha512-ERPW+XkvX9W2A+ov07iy+ZFJpVdik04GhDA4eVogiG9hpC97Kem2iucyzhFxbFRvQ5o2UckFtKZdp1hkGvnrEw==", "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.1.4" + "peek-readable": "^5.3.1" }, "engines": { "node": ">=16" diff --git a/lib/local-constructs/clamav-scanning/package.json b/lib/local-constructs/clamav-scanning/package.json index 18311a8717..a0d6bb6841 100644 --- a/lib/local-constructs/clamav-scanning/package.json +++ b/lib/local-constructs/clamav-scanning/package.json @@ -21,7 +21,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.679.0", "@types/mime-types": "^2.1.4", - "file-type": "^19.4.1", + "file-type": "^19.6.0", "mime-types": "^2.1.35", "pino": "^9.4.0" } diff --git a/lib/stacks/email.ts b/lib/stacks/email.ts index 70deb019b2..a5ca78c1b7 100644 --- a/lib/stacks/email.ts +++ b/lib/stacks/email.ts @@ -222,7 +222,7 @@ export class Email extends cdk.NestedStack { alarm.addAlarmAction(new cdk.aws_cloudwatch_actions.SnsAction(alarmTopic)); - new CfnEventSourceMapping(this, "SinkEmailTrigger", { + new CfnEventSourceMapping(this, "SinkSESTrigger", { batchSize: 1, enabled: true, selfManagedEventSource: { diff --git a/react-app/src/components/Inputs/button.test.tsx b/react-app/src/components/Inputs/button.test.tsx new file mode 100644 index 0000000000..8dfb4d8db5 --- /dev/null +++ b/react-app/src/components/Inputs/button.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Button } from './button'; + +describe('Button Component', () => { + it('renders children correctly', () => { + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('applies default variant and size classes', () => { + render(); + const button = screen.getByText('Click Me'); + expect(button).toHaveClass('bg-primary text-slate-50'); + }); + + it('applies the correct variant and size classes when props are set', () => { + render(); + const button = screen.getByText('Delete'); + expect(button).toHaveClass('bg-destructive'); + expect(button).toHaveClass('h-11 px-8'); + }); + + it('shows a loading spinner when loading prop is true', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('disables the button when the disabled prop is true', () => { + render(); + const button = screen.getByText('Disabled'); + expect(button).toBeDisabled(); + }); +}); + diff --git a/react-app/src/components/Inputs/checkbox.test.tsx b/react-app/src/components/Inputs/checkbox.test.tsx new file mode 100644 index 0000000000..b966c22f10 --- /dev/null +++ b/react-app/src/components/Inputs/checkbox.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Checkbox, CheckboxGroup } from './checkbox'; +import React from 'react'; + +describe('Checkbox Component', () => { + it('renders correctly with a label', () => { + render(); + expect(screen.getByLabelText('Accept Terms')).toBeInTheDocument(); + }); + + it('renders correctly with a styledLabel', () => { + render(Styled Label} />); + expect(screen.getByText('Styled Label')).toBeInTheDocument(); + }); + + it('displays description if provided', () => { + render(); + expect(screen.getByText('Please accept terms and conditions')).toBeInTheDocument(); + }); + + it('applies custom class names', () => { + render(); + const checkbox = screen.getByLabelText('Terms'); + expect(checkbox).toHaveClass('custom-class'); + }); + + it('toggles checked state when clicked', () => { + const handleChange = vi.fn(); + render(); + const checkbox = screen.getByLabelText('Terms'); + + // Simulate checking the checkbox + fireEvent.click(checkbox); + expect(handleChange).toHaveBeenCalledWith(true); + + // Simulate unchecking the checkbox + fireEvent.click(checkbox); + expect(handleChange).toHaveBeenCalledWith(false); + }); +}); + +describe('CheckboxGroup Component', () => { + it('renders all options correctly', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, + ]; + render( {}} options={options} />); + + options.forEach((opt) => { + expect(screen.getByLabelText(opt.label)).toBeInTheDocument(); + }); + }); + + it('checks the correct checkboxes based on initial value', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + ]; + render( {}} options={options} />); + + expect(screen.getByLabelText('Option 1')).toBeChecked(); + expect(screen.getByLabelText('Option 2')).not.toBeChecked(); + }); + + it('calls onChange with the correct values when a checkbox is toggled', () => { + const options = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + ]; + const handleChange = vi.fn(); + + // State wrapper for CheckboxGroup to handle the "value" prop directly + const CheckboxGroupWrapper = () => { + const [value, setValue] = React.useState([]); + return ( + { + setValue(newValue); // Update internal state + handleChange(newValue); // Call the spy + }} + options={options} + /> + ); + }; + + // Render the wrapper component + render(); + + // 1. Check "Option 1" + fireEvent.click(screen.getByLabelText('Option 1')); + expect(handleChange).toHaveBeenNthCalledWith(1, ['opt1']); + + // 2. Log output and then Check "Option 2" + fireEvent.click(screen.getByLabelText('Option 2')); + expect(handleChange).toHaveBeenNthCalledWith(2, ['opt1', 'opt2']); // Expect both checked + + // 3. Uncheck "Option 1" + fireEvent.click(screen.getByLabelText('Option 1')); + expect(handleChange).toHaveBeenNthCalledWith(3, ['opt2']); + }); +}); diff --git a/react-app/src/components/Inputs/switch.test.tsx b/react-app/src/components/Inputs/switch.test.tsx new file mode 100644 index 0000000000..ae4c1a8bfc --- /dev/null +++ b/react-app/src/components/Inputs/switch.test.tsx @@ -0,0 +1,23 @@ +import { Switch } from "./switch"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +describe("Switch", () => { + it("should render", () => { + render(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("should call onChange and onCheckedChange when state changes", () => { + const onChange = vi.fn(); + const onCheckedChange = vi.fn(); + + render(); + + const switchElement = screen.getByRole("switch"); + fireEvent.click(switchElement); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onCheckedChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/react-app/src/components/Inputs/switch.tsx b/react-app/src/components/Inputs/switch.tsx index 98271445c5..4cdcabb759 100644 --- a/react-app/src/components/Inputs/switch.tsx +++ b/react-app/src/components/Inputs/switch.tsx @@ -1,4 +1,3 @@ - import * as React from "react"; import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "@/utils"; @@ -11,7 +10,7 @@ const Switch = React.forwardRef< { @@ -22,7 +21,7 @@ const Switch = React.forwardRef< > diff --git a/react-app/src/components/Inputs/upload.test.tsx b/react-app/src/components/Inputs/upload.test.tsx new file mode 100644 index 0000000000..3692468ce5 --- /dev/null +++ b/react-app/src/components/Inputs/upload.test.tsx @@ -0,0 +1,120 @@ +import { Upload } from "./upload"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock global fetch +global.fetch = vi.fn(); + +// Mock AWS Amplify API +vi.mock("aws-amplify", () => ({ + API: { + post: vi.fn(), + }, +})); + +const defaultProps = { + dataTestId: "upload-component", + files: [], + setFiles: vi.fn(), + setErrorMessage: vi.fn(), +}; + +describe("Upload", () => { + const testIdSuffix = "upload"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders correctly with initial props", () => { + render(); + expect(screen.getByTestId(`${defaultProps.dataTestId}-${testIdSuffix}`)).toBeInTheDocument(); + expect(screen.queryByText("Uploading...")).not.toBeInTheDocument(); + }); + + it("uploads files correctly", async () => { + render(); + + const dropzone = screen.getByRole("presentation"); + const file = new File(["file contents"], "file.pdf", { type: "application/pdf" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect(defaultProps.setFiles).toHaveBeenCalledWith([ + { + bucket: "hello", + key: "world", + filename: "file.pdf", + title: "file", + uploadDate: expect.any(Number), // Since it's a timestamp + }, + ]); + }); + }); + + it("displays an error for unsupported file types", async () => { + render(); + + const dropzone = screen.getByRole("presentation"); + const file = new File(["file contents"], "file.exe", { type: "application/x-msdownload" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect( + screen.getByText("Selected file(s) is too large or of a disallowed file type."), + ).toBeInTheDocument(); + }); + }); + + it("does not display the dropzone when uploading", async () => { + render(); + + const dropzone = screen.getByTestId("upload-component-upload"); + const file = new File(["file contents"], "file.pdf", { type: "application/pdf" }); + + Object.defineProperty(dropzone, "files", { + value: [file], + writable: false, + }); + + fireEvent.drop(dropzone); + + await waitFor(() => { + expect(screen.getByTestId("upload-component-upload")).not.toBeVisible(); + }); + }); + + it("handles file removal on event", () => { + const mockSetFiles = vi.fn(); + const files = [ + { filename: "file-1.txt" }, + { filename: "file-to-remove.txt" }, + { filename: "file-2.txt" }, + ]; + + // Render the component with necessary props + render(); + + // Simulate the event (e.g., a click on the remove button) + const removeButton = screen.getByTestId("upload-component-remove-file-file-to-remove.txt"); // Ensure your component uses this testId + fireEvent.click(removeButton); + + // Assert that setFiles was called with the updated files array + expect(mockSetFiles).toHaveBeenCalledWith([ + { filename: "file-1.txt" }, + { filename: "file-2.txt" }, + ]); + }); +}); diff --git a/react-app/src/components/Inputs/upload.tsx b/react-app/src/components/Inputs/upload.tsx index 641a3c22a5..b61eedc62e 100644 --- a/react-app/src/components/Inputs/upload.tsx +++ b/react-app/src/components/Inputs/upload.tsx @@ -8,11 +8,7 @@ import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import { LoadingSpinner } from "@/components/LoadingSpinner"; // Import your LoadingSpinner component import { attachmentSchema } from "shared-types"; -import { - extractBucketAndKeyFromUrl, - getPresignedUrl, - uploadToS3, -} from "./upload.utilities"; +import { extractBucketAndKeyFromUrl, getPresignedUrl, uploadToS3 } from "./upload.utilities"; type Attachment = z.infer; @@ -23,12 +19,30 @@ type UploadProps = { dataTestId?: string; }; -export const Upload = ({ - maxFiles, - files, - setFiles, - dataTestId, -}: UploadProps) => { +/** + * Upload component for handling file uploads with drag-and-drop functionality. + * + * @param {Object} props - The properties object. + * @param {number} props.maxFiles - The maximum number of files that can be uploaded. + * @param {Attachment[]} props.files - The current list of uploaded files. + * @param {Function} props.setFiles - Function to update the list of uploaded files. + * @param {string} props.dataTestId - The data-testid attribute for testing purposes. + * + * @returns {JSX.Element} The rendered Upload component. + * + * @component + * @example + * const [files, setFiles] = useState([]); + * return ( + * + * ); + */ +export const Upload = ({ maxFiles, files, setFiles, dataTestId }: UploadProps) => { const [isUploading, setIsUploading] = useState(false); // New state for tracking upload status const [errorMessage, setErrorMessage] = useState(null); const uniqueId = uuidv4(); @@ -36,9 +50,7 @@ export const Upload = ({ const onDrop = useCallback( async (acceptedFiles: File[], fileRejections: FileRejection[]) => { if (fileRejections.length > 0) { - setErrorMessage( - "Selected file(s) is too large or of a disallowed file type.", - ); + setErrorMessage("Selected file(s) is too large or of a disallowed file type."); } else { setErrorMessage(null); setIsUploading(true); // Set uploading to true @@ -110,6 +122,7 @@ export const Upload = ({ }} variant="ghost" className="p-0 h-0" + data-testid={`${dataTestId}-remove-file-${file.filename}`} > @@ -130,9 +143,7 @@ export const Upload = ({ >

Drag file here or{" "} - - choose from folder - + choose from folder