|
1 | 1 | import {OrganizationFixture} from 'sentry-fixture/organization'; |
2 | 2 |
|
3 | 3 | import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; |
4 | | -import {renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary'; |
| 4 | +import { |
| 5 | + renderGlobalModal, |
| 6 | + screen, |
| 7 | + userEvent, |
| 8 | + waitFor, |
| 9 | +} from 'sentry-test/reactTestingLibrary'; |
5 | 10 |
|
6 | 11 | import triggerChangeBalanceModal from 'admin/components/changeBalanceAction'; |
7 | 12 |
|
@@ -107,4 +112,88 @@ describe('BalanceChangeAction', () => { |
107 | 112 | }) |
108 | 113 | ); |
109 | 114 | }); |
| 115 | + |
| 116 | + it('prevents double submission', async () => { |
| 117 | + const updateMock = MockApiClient.addMockResponse({ |
| 118 | + url: `/_admin/customers/${organization.slug}/balance-changes/`, |
| 119 | + method: 'POST', |
| 120 | + body: OrganizationFixture(), |
| 121 | + }); |
| 122 | + |
| 123 | + triggerChangeBalanceModal({subscription, ...modalProps}); |
| 124 | + |
| 125 | + renderGlobalModal(); |
| 126 | + await userEvent.type(screen.getByRole('spinbutton', {name: 'Credit Amount'}), '10'); |
| 127 | + |
| 128 | + const submitButton = screen.getByRole('button', {name: 'Submit'}); |
| 129 | + |
| 130 | + // Rapidly click submit button multiple times |
| 131 | + await userEvent.click(submitButton); |
| 132 | + await userEvent.click(submitButton); |
| 133 | + await userEvent.click(submitButton); |
| 134 | + |
| 135 | + // Should only call API once (double-clicks prevented) |
| 136 | + expect(updateMock).toHaveBeenCalledTimes(1); |
| 137 | + }); |
| 138 | + |
| 139 | + it('disables form fields during submission', async () => { |
| 140 | + // Mock with delay to keep isSubmitting true during test |
| 141 | + MockApiClient.addMockResponse({ |
| 142 | + url: `/_admin/customers/${organization.slug}/balance-changes/`, |
| 143 | + method: 'POST', |
| 144 | + body: OrganizationFixture(), |
| 145 | + asyncDelay: 100, |
| 146 | + }); |
| 147 | + |
| 148 | + triggerChangeBalanceModal({subscription, ...modalProps}); |
| 149 | + |
| 150 | + renderGlobalModal(); |
| 151 | + const creditInput = screen.getByRole('spinbutton', {name: 'Credit Amount'}); |
| 152 | + await userEvent.type(creditInput, '10'); |
| 153 | + |
| 154 | + const submitButton = screen.getByRole('button', {name: 'Submit'}); |
| 155 | + await userEvent.click(submitButton); |
| 156 | + |
| 157 | + // During submission, button should show "Submitting...", be disabled, and fields should be disabled |
| 158 | + expect(submitButton).toHaveTextContent('Submitting...'); |
| 159 | + expect(submitButton).toBeDisabled(); |
| 160 | + expect(creditInput).toBeDisabled(); |
| 161 | + expect(screen.getByTestId('url-field')).toBeDisabled(); |
| 162 | + expect(screen.getByTestId('notes-field')).toBeDisabled(); |
| 163 | + }); |
| 164 | + |
| 165 | + it('re-enables form after error', async () => { |
| 166 | + MockApiClient.addMockResponse({ |
| 167 | + url: `/_admin/customers/${organization.slug}/balance-changes/`, |
| 168 | + method: 'POST', |
| 169 | + statusCode: 400, |
| 170 | + body: {detail: 'Invalid amount'}, |
| 171 | + asyncDelay: 10, |
| 172 | + }); |
| 173 | + |
| 174 | + triggerChangeBalanceModal({subscription, ...modalProps}); |
| 175 | + renderGlobalModal(); |
| 176 | + |
| 177 | + // Pre-grab stable references to fields using findBy to wait for modal content |
| 178 | + const creditInput = await screen.findByRole('spinbutton', {name: 'Credit Amount'}); |
| 179 | + const urlField = await screen.findByTestId('url-field'); |
| 180 | + const notesField = await screen.findByTestId('notes-field'); |
| 181 | + const submitButton = screen.getByRole('button', {name: /submit/i}); |
| 182 | + |
| 183 | + await userEvent.type(creditInput, '10'); |
| 184 | + await waitFor(() => expect(creditInput).toHaveValue(10)); |
| 185 | + |
| 186 | + await userEvent.click(submitButton); |
| 187 | + |
| 188 | + // Wait for form to be re-enabled after error |
| 189 | + // Don't rely on error message text as the Form component shows different messages |
| 190 | + // depending on error response structure. All fields are controlled by isSubmitting |
| 191 | + // state, so if one is enabled, all should be enabled. |
| 192 | + await waitFor(() => expect(creditInput).toBeEnabled()); |
| 193 | + |
| 194 | + // Verify all fields and submit button are re-enabled |
| 195 | + expect(urlField).toBeEnabled(); |
| 196 | + expect(notesField).toBeEnabled(); |
| 197 | + expect(submitButton).toBeEnabled(); |
| 198 | + }); |
110 | 199 | }); |
0 commit comments