fix(events): guard create-event submit against re-entrant submissions
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.
What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.
Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.
Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.
Refs testplan REPORT.md #6 (Part 7.03).
This commit is contained in:
@@ -99,6 +99,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
// Re-entrancy guard for the create submit. The Button's
|
||||||
|
// `disabled={createMutation.isPending}` covers the ordinary double-click, but
|
||||||
|
// not a submission that never touches the button (implicit form submission,
|
||||||
|
// a programmatic requestSubmit) — those raced two POSTs onto the same slug,
|
||||||
|
// one of which 500'd on `events_slug_unique` (QA 7.03).
|
||||||
|
const isSubmittingRef = useRef(false);
|
||||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||||
// const [showPreview, setShowPreview] = useState(false);
|
// const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
@@ -396,6 +402,9 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
isSubmittingRef.current = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
@@ -462,6 +471,10 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isSubmittingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -518,6 +531,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
customer_account_ids: formData.customer_accounts.map((c) => c.id),
|
customer_account_ids: formData.customer_accounts.map((c) => c.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isSubmittingRef.current = true;
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* "Create event" could fire two real POSTs racing the same slug — one 500'd on
|
||||||
|
* `events_slug_unique` (QA 7.03).
|
||||||
|
*
|
||||||
|
* The Button already carried `disabled={createMutation.isPending}`, which
|
||||||
|
* covers the ordinary double-click. `handleSubmit` itself had no re-entrancy
|
||||||
|
* guard though, so any submit that does not go through the button (implicit
|
||||||
|
* form submission, a programmatic `requestSubmit`) still raced a second POST.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
|
||||||
|
|
||||||
|
const createEvent = vi.fn();
|
||||||
|
vi.mock('../../../services/events.service', () => ({
|
||||||
|
eventsService: { createEvent: (...args: unknown[]) => createEvent(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../services/categories.service', () => ({
|
||||||
|
categoriesService: { getCategories: vi.fn(async () => []) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/settings.service', () => ({
|
||||||
|
settingsService: { getAllSettings: vi.fn(async () => ({})) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/cssTemplates.service', () => ({
|
||||||
|
cssTemplatesService: { getEnabledTemplates: vi.fn(async () => []) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/eventTypes.service', () => ({
|
||||||
|
eventTypesService: { getEventTypes: vi.fn(async () => []) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/userManagement.service', () => ({
|
||||||
|
userManagementService: { getUsers: vi.fn(async () => []) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Every "is this field required" flag off, so the only thing validateForm
|
||||||
|
// needs is the event name.
|
||||||
|
vi.mock('../../../hooks/usePublicSettings', () => ({
|
||||||
|
PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'],
|
||||||
|
usePublicSettings: () => ({
|
||||||
|
data: {
|
||||||
|
event_require_customer_name: false,
|
||||||
|
event_require_customer_email: false,
|
||||||
|
event_require_admin_email: false,
|
||||||
|
event_require_event_date: false,
|
||||||
|
event_require_expiration: false,
|
||||||
|
event_default_require_password: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/AdminAuthContext', () => ({
|
||||||
|
useAdminAuth: () => ({ user: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/FeatureFlagsContext', () => ({
|
||||||
|
useFeatureFlags: () => ({ flags: {}, isLoading: false }),
|
||||||
|
useFeatureEnabled: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Heavy children not involved in the submit path.
|
||||||
|
vi.mock('../../../components/admin', async () => {
|
||||||
|
const actual = await vi.importActual<any>('../../../components/admin');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ThemeCustomizerEnhanced: () => null,
|
||||||
|
GalleryPreview: () => null,
|
||||||
|
WelcomeMessageEditor: () => null,
|
||||||
|
FeedbackSettings: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../../components/admin/CustomerAccountPicker', () => ({
|
||||||
|
CustomerAccountPicker: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CreateEventPage } from '../CreateEventPage';
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CreateEventPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateEventPage double-submit guard (QA 7.03)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createEvent.mockReset();
|
||||||
|
// Never settles — keeps the mutation in flight for the whole test.
|
||||||
|
createEvent.mockImplementation(() => new Promise(() => {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires exactly one POST when the form is submitted twice in a row', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), {
|
||||||
|
target: { value: 'ZZTEST double submit' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = screen.getByRole('button', { name: 'events.createEvent' }).closest('form')!;
|
||||||
|
fireEvent.submit(form);
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => expect(createEvent).toHaveBeenCalled());
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the submit button while the request is in flight', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('events.eventNamePlaceholder'), {
|
||||||
|
target: { value: 'ZZTEST in flight' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = screen.getByRole('button', { name: 'events.createEvent' }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(submit);
|
||||||
|
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user