import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import axios from 'axios';
import imageCompression from 'browser-image-compression';
import SuggestNewPointButton from '../../../src/components/Map/components/SuggestNewPointButton';
import { LocationProvider } from '../../../src/components/Map/context/LocationContext';
import AppProviders from '../../utils/providers';
import {
mockGeolocationSuccess,
mockGeolocationError,
mockGeolocationUnsupported,
mockGeolocationWithNullPosition,
} from '../../utils/geolocationMocks';
import {
clickSuggestButton,
openDialog,
submitForm,
fillTextField,
} from '../../utils/dialogHelpers';
import { ERROR_MESSAGES, FILE_SIZES, SIMPLE_SCHEMA, FULL_SCHEMA } from '../../utils/testConstants';
import httpService from '../../../src/services/http/httpService';
import toast from '../../../src/utils/toast';
const renderWithProvider = component =>
render(
{component}
,
);
jest.mock('axios');
jest.mock('../../../src/services/http/httpService', () => ({
__esModule: true,
default: {
getCategoriesData: jest.fn(),
getLocationSchema: jest.fn(),
},
}));
jest.mock('../../../src/utils/toast', () => ({
__esModule: true,
default: { success: jest.fn(), error: jest.fn() },
}));
jest.mock('browser-image-compression');
// Mock CSRF token meta tag and location schema
beforeEach(() => {
const metaTag = document.createElement('meta');
metaTag.setAttribute('name', 'csrf-token');
metaTag.setAttribute('content', 'test-csrf-token');
document.head.appendChild(metaTag);
httpService.getLocationSchema.mockResolvedValue(FULL_SCHEMA);
// Mock categories data matching httpService.getCategoriesData()'s real
// { categories: [{ categoryKey, categoryName, options }] } shape.
httpService.getCategoriesData.mockResolvedValue({
categories: [
{
categoryKey: 'accessible_by',
categoryName: 'Accessible by',
options: [
['bikes', 'Bikes'],
['cars', 'Cars'],
['pedestrians', 'Pedestrians'],
],
},
{
categoryKey: 'type_of_place',
categoryName: 'Type of place',
options: [
['big bridge', 'Big bridge'],
['small bridge', 'Small bridge'],
],
},
],
defaultChecked: {},
});
});
afterEach(() => {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
metaTag.remove();
}
jest.clearAllMocks();
});
const mockUploadingFileWithSizeInMB = (sizeInMB, mimeType = 'image/jpeg') => {
const file = {
name: 'large-file.jpg',
size: sizeInMB * 1024 * 1024,
type: mimeType,
};
fireEvent.change(screen.getByTestId('photo-of-point'), {
target: { files: [file] },
});
};
describe('SuggestNewPointButton', () => {
it('shows disabled state when geolocation is not supported', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockGeolocationUnsupported();
renderWithProvider();
// Button should show disabled state via aria-label tooltip
await waitFor(() => {
const button = screen.getByTestId('suggest-new-point');
expect(button).toHaveAttribute(
'aria-label',
'Location services are disabled. Please enable them to use this feature.',
);
});
// Clicking should not open dialog when geolocation is unsupported
clickSuggestButton();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
it('shows disabled state when location services are not enabled', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockGeolocationError();
renderWithProvider();
// Button should show disabled state via aria-label tooltip
await waitFor(() => {
const button = screen.getByTestId('suggest-new-point');
expect(button).toHaveAttribute(
'aria-label',
'Location services are disabled. Please enable them to use this feature.',
);
});
// Clicking should not open dialog when geolocation fails
clickSuggestButton();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
it('shows disabled tooltip and does not open dialog when geolocation is denied', async () => {
mockGeolocationError();
renderWithProvider();
// Button should show disabled state via tooltip (aria-label)
const button = screen.getByTestId('suggest-new-point');
expect(button).toHaveAttribute(
'aria-label',
'Location services are disabled. Please enable them to use this feature.',
);
clickSuggestButton();
// Dialog should not open when geolocation is denied
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('opens new point suggestion box when location services are enabled', async () => {
mockGeolocationSuccess();
renderWithProvider();
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('rejects a photo with an unsupported format without attempting compression', async () => {
mockGeolocationSuccess();
renderWithProvider();
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Small enough to fit under the size limit - format alone must trigger rejection.
mockUploadingFileWithSizeInMB(FILE_SIZES.VALID_TEST_MB, 'image/png');
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
ERROR_MESSAGES.UNSUPPORTED_PHOTO_FORMAT,
);
});
expect(imageCompression).not.toHaveBeenCalled();
});
it('displays error message when a file cannot be processed as a photo', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockGeolocationSuccess();
imageCompression.mockRejectedValue(new Error('Failed to load image for compression'));
renderWithProvider();
URL.createObjectURL = jest.fn(() => 'blob:http://test-url/');
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
mockUploadingFileWithSizeInMB(FILE_SIZES.OVER_LIMIT_MB);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
ERROR_MESSAGES.PHOTO_PROCESSING_FAILED,
);
});
consoleErrorSpy.mockRestore();
});
it('rejects a compressed photo that still exceeds the size limit', async () => {
mockGeolocationSuccess();
const stillTooLarge = new File(['x'], 'large-file.jpg', { type: 'image/jpeg' });
Object.defineProperty(stillTooLarge, 'size', {
value: FILE_SIZES.OVER_LIMIT_MB * 1024 * 1024,
});
imageCompression.mockResolvedValue(stillTooLarge);
renderWithProvider();
URL.createObjectURL = jest.fn(() => 'blob:http://test-url/');
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
mockUploadingFileWithSizeInMB(FILE_SIZES.OVER_LIMIT_MB);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.FILE_TOO_LARGE);
});
});
it('compresses an oversized photo and accepts it when it fits under the limit', async () => {
mockGeolocationSuccess();
const compressedFile = new File(['compressed'], 'large-file.jpg', {
type: 'image/jpeg',
});
Object.defineProperty(compressedFile, 'size', { value: 1024 * 1024 });
imageCompression.mockResolvedValue(compressedFile);
renderWithProvider();
URL.createObjectURL = jest.fn(() => 'blob:http://test-url/');
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
mockUploadingFileWithSizeInMB(FILE_SIZES.OVER_LIMIT_MB);
await waitFor(() => {
expect(imageCompression).toHaveBeenCalled();
// Compression succeeding is not silent - the user must be told their photo
// was altered, even though the dialog isn't blocked from proceeding.
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.PHOTO_COMPRESSED);
});
});
it('shows a heads-up and disables the upload button while compression is in flight', async () => {
mockGeolocationSuccess();
let resolveCompression;
imageCompression.mockImplementation(
() =>
new Promise(resolve => {
resolveCompression = resolve;
}),
);
renderWithProvider();
URL.createObjectURL = jest.fn(() => 'blob:http://test-url/');
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
mockUploadingFileWithSizeInMB(FILE_SIZES.OVER_LIMIT_MB);
// Compression hasn't resolved yet - the user should already see the heads-up
// and a disabled/spinning upload button, not silence.
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
ERROR_MESSAGES.PHOTO_WILL_BE_COMPRESSED,
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.getByTestId('photo-of-point')).toBeDisabled();
});
const compressedFile = new File(['compressed'], 'large-file.jpg', { type: 'image/jpeg' });
Object.defineProperty(compressedFile, 'size', { value: 1024 * 1024 });
resolveCompression(compressedFile);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.PHOTO_COMPRESSED);
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getByTestId('photo-of-point')).not.toBeDisabled();
});
});
it('clears the error banner when a valid photo replaces a rejected one', async () => {
mockGeolocationSuccess();
renderWithProvider();
URL.createObjectURL = jest.fn(() => 'blob:http://test-url/');
clickSuggestButton();
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Right format but oversized, and compression (mocked below) still can't get
// it under the limit.
const stillTooLarge = new File(['x'], 'large-file.jpg', { type: 'image/jpeg' });
Object.defineProperty(stillTooLarge, 'size', {
value: FILE_SIZES.OVER_LIMIT_MB * 1024 * 1024,
});
imageCompression.mockResolvedValue(stillTooLarge);
mockUploadingFileWithSizeInMB(FILE_SIZES.OVER_LIMIT_MB);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.FILE_TOO_LARGE);
});
const validFile = new File(['ok'], 'valid.jpg', { type: 'image/jpeg' });
Object.defineProperty(validFile, 'size', { value: FILE_SIZES.VALID_TEST_MB * 1024 * 1024 });
fireEvent.change(screen.getByTestId('photo-of-point'), {
target: { files: [validFile] },
});
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
it('handles file dialog cancellation without crashing', async () => {
mockGeolocationSuccess();
renderWithProvider();
await openDialog();
const fileInput = screen.getByTestId('photo-of-point');
fireEvent.change(fileInput, {
target: { files: [] },
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('displays validation error when user position is not available', async () => {
axios.post.mockResolvedValue({});
mockGeolocationWithNullPosition();
renderWithProvider();
await openDialog();
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
ERROR_MESSAGES.LOCATION_NOT_AVAILABLE,
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(axios.post).not.toHaveBeenCalled();
});
});
it('displays validation error when required fields are empty', async () => {
axios.post.mockResolvedValue({});
mockGeolocationSuccess();
renderWithProvider();
await openDialog();
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.REQUIRED_FIELDS);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(axios.post).not.toHaveBeenCalled();
});
});
it('scrolls the dialog content back to top when a notice appears', async () => {
// jsdom doesn't implement Element.scrollTo, so it must be mocked to observe the call.
const scrollTo = jest.fn();
HTMLElement.prototype.scrollTo = scrollTo;
axios.post.mockResolvedValue({});
mockGeolocationSuccess();
renderWithProvider();
await openDialog();
// Regression: the dialog's Paper (not DialogContent) is what actually scrolls
// once a photo preview and all the dynamic fields push content past viewport
// height. Without an explicit scroll, an alert appearing at the top can render
// entirely off-screen above wherever the user was already scrolled to -
// indistinguishable from "submit does nothing".
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.REQUIRED_FIELDS);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
delete HTMLElement.prototype.scrollTo;
});
it('clears the error banner when the dialog is reopened after a validation failure', async () => {
axios.post.mockResolvedValue({});
mockGeolocationSuccess();
renderWithProvider();
await openDialog();
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.REQUIRED_FIELDS);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
await openDialog();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('keeps dialog open on submission error', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
axios.post.mockRejectedValue(new Error('Network error'));
mockGeolocationSuccess();
httpService.getLocationSchema.mockResolvedValue(SIMPLE_SCHEMA);
renderWithProvider();
await openDialog();
fillTextField(/name/i, 'Test Location');
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(ERROR_MESSAGES.SUBMISSION_ERROR);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(axios.post).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error suggesting new point:',
expect.any(Error),
);
});
consoleErrorSpy.mockRestore();
});
it('surfaces the backend-provided error message on submission failure', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const backendMessage = 'Invalid photo. Allowed formats: jpeg, jpg. Max size: 5MiB.';
axios.post.mockRejectedValue({ response: { data: { message: backendMessage } } });
mockGeolocationSuccess();
httpService.getLocationSchema.mockResolvedValue(SIMPLE_SCHEMA);
renderWithProvider();
await openDialog();
fillTextField(/name/i, 'Test Location');
submitForm();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(backendMessage);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
consoleErrorSpy.mockRestore();
});
it('shows a spinner and disables both buttons while submitting', async () => {
let resolvePost;
axios.post.mockImplementation(
() =>
new Promise(resolve => {
resolvePost = resolve;
}),
);
mockGeolocationSuccess();
httpService.getLocationSchema.mockResolvedValue(SIMPLE_SCHEMA);
renderWithProvider();
await openDialog();
fillTextField(/name/i, 'Test Location');
submitForm();
// The backend can be slow (e.g. a notifier plugin sending an email runs
// synchronously before responding) - the button must show it's working,
// not just sit there looking unresponsive.
await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
});
resolvePost({ data: { message: 'Success' } });
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('closes dialog and resets form on successful submission', async () => {
axios.post.mockResolvedValue({ data: { message: 'Success' } });
mockGeolocationSuccess();
httpService.getLocationSchema.mockResolvedValue(SIMPLE_SCHEMA);
renderWithProvider();
await openDialog();
fillTextField(/name/i, 'Test Location');
submitForm();
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
expect.stringMatching(ERROR_MESSAGES.SUBMISSION_SUCCESS),
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(axios.post).toHaveBeenCalledTimes(1);
});
});
it('builds category options from the category definitions', async () => {
mockGeolocationSuccess();
renderWithProvider();
await openDialog();
fireEvent.mouseDown(screen.getByRole('combobox', { name: /accessible by/i }));
// The schema says accessible_by is obligatory but no longer carries its values:
// both the values and their labels come from the category definitions, which
// are now the only place either is reported.
const options = await screen.findAllByRole('option');
expect(options.map(option => option.textContent)).toEqual(['Bikes', 'Cars', 'Pedestrians']);
});
it('offers a retry instead of the form when the schema fails to load', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockGeolocationSuccess();
httpService.getLocationSchema.mockRejectedValue(new Error('schema unavailable'));
renderWithProvider();
await openDialog();
// With no schema there are no fields to fill, so a submitted form could only be
// rejected by the backend: the failure is stated instead of offering one.
expect(screen.getByText('Failed to load the suggestion form.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /submit/i })).not.toBeInTheDocument();
httpService.getLocationSchema.mockResolvedValue(FULL_SCHEMA);
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(),
);
consoleErrorSpy.mockRestore();
});
});