);
+
+ expect(screen.queryByText('Save Your Recovery Codes')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/js/components/recovery_codes_panel.test.js b/tests/js/components/recovery_codes_panel.test.js
new file mode 100644
index 00000000..6f1b8392
--- /dev/null
+++ b/tests/js/components/recovery_codes_panel.test.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import {render, screen, fireEvent} from '@testing-library/react';
+import RecoveryCodesPanel from '../../../resources/js/components/recovery_codes_panel';
+import {regenerateRecoveryCodes} from '../../../resources/js/profile/actions';
+
+jest.mock('../../../resources/js/profile/actions');
+jest.mock('sweetalert2', () => jest.fn());
+
+// data-testid on a MUI TextField lands on the wrapper , not the .
+const getPasswordInput = () => screen.getByTestId('recovery-codes-current-password').querySelector('input');
+
+describe('RecoveryCodesPanel', () => {
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ jest.clearAllMocks();
+ });
+
+ it('shows the remaining/total count', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining');
+ });
+
+ it('shows a dismissable low-code warning when remaining is below the threshold', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('low-code-warning')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('dismiss'));
+ expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument();
+ });
+
+ it('does not show the low-code warning when there are enough codes', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument();
+ });
+
+ it('respects a custom lowCodeThreshold instead of the default of 3', () => {
+ // 4 remaining would NOT trigger the default threshold (3), but does with a custom threshold of 5.
+ render(
+
+ );
+ expect(screen.getByTestId('low-code-warning')).toBeInTheDocument();
+ });
+
+ it('respects a custom lower threshold (2 remaining is not below a threshold of 2)', () => {
+ // With the default threshold (3) this would show the warning; a custom
+ // threshold of 2 must be honored instead of the hardcoded default.
+ render(
+
+ );
+ expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument();
+ });
+
+ it('opens the modal immediately when initialCodes is provided', () => {
+ const codes = ['AAAA-1111', 'BBBB-2222'];
+ render(
+
+ );
+ expect(screen.getByText('AAAA-1111')).toBeInTheDocument();
+ });
+
+ it('regenerates codes after confirming the current password and opens the modal', async () => {
+ const newCodes = ['NEW1-CODE', 'NEW2-CODE'];
+ regenerateRecoveryCodes.mockResolvedValue({response: {recovery_codes: newCodes}});
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Regenerate Codes'));
+ fireEvent.change(getPasswordInput(), {target: {value: 'my-password'}});
+ fireEvent.click(screen.getByTestId('confirm-regenerate-button'));
+
+ expect(regenerateRecoveryCodes).toHaveBeenCalledWith('my-password');
+ expect(await screen.findByText('NEW1-CODE')).toBeInTheDocument();
+ expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining');
+ });
+
+ it('shows an error and keeps the existing count when the password is wrong', async () => {
+ regenerateRecoveryCodes.mockRejectedValue({
+ status: 412,
+ response: {body: {errors: ['current_password is not correct.']}},
+ });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Regenerate Codes'));
+ fireEvent.change(getPasswordInput(), {target: {value: 'wrong'}});
+ fireEvent.click(screen.getByTestId('confirm-regenerate-button'));
+
+ expect(regenerateRecoveryCodes).toHaveBeenCalledWith('wrong');
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining');
+ });
+});
diff --git a/tests/js/components/two_factor_section.test.js b/tests/js/components/two_factor_section.test.js
new file mode 100644
index 00000000..e5e9dfef
--- /dev/null
+++ b/tests/js/components/two_factor_section.test.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import {render, screen, fireEvent} from '@testing-library/react';
+import TwoFactorSection from '../../../resources/js/components/two_factor_section';
+import {enableTwoFactor} from '../../../resources/js/profile/actions';
+
+jest.mock('../../../resources/js/profile/actions');
+jest.mock('sweetalert2', () => jest.fn());
+
+describe('TwoFactorSection', () => {
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ jest.clearAllMocks();
+ });
+
+ it('shows the enable button when 2FA is not enabled', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('enable-two-factor-button')).toBeInTheDocument();
+ expect(screen.queryByTestId('recovery-codes-count')).not.toBeInTheDocument();
+ });
+
+ it('shows the recovery codes panel when 2FA is already enabled', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('enable-two-factor-button')).not.toBeInTheDocument();
+ expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining');
+ });
+
+ it('enables 2FA and shows the enrollment codes in the modal', async () => {
+ const codes = ['AAAA-1111', 'BBBB-2222'];
+ enableTwoFactor.mockResolvedValue({response: {recovery_codes: codes}});
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByTestId('enable-two-factor-button'));
+
+ expect(enableTwoFactor).toHaveBeenCalledWith('email_otp');
+ expect(await screen.findByText('AAAA-1111')).toBeInTheDocument();
+ expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining');
+ });
+});
diff --git a/tests/js/setup.js b/tests/js/setup.js
index 7b0828bf..dfc57086 100644
--- a/tests/js/setup.js
+++ b/tests/js/setup.js
@@ -1 +1,12 @@
import '@testing-library/jest-dom';
+import {TextEncoder, TextDecoder} from 'util';
+
+// jsdom does not provide these globals; superagent (via base_actions.js) needs
+// them transitively (through @paralleldrive/cuid2), even when the module ends
+// up auto-mocked by jest.mock() — Jest still evaluates the real module once.
+if (typeof global.TextEncoder === 'undefined') {
+ global.TextEncoder = TextEncoder;
+}
+if (typeof global.TextDecoder === 'undefined') {
+ global.TextDecoder = TextDecoder;
+}
diff --git a/yarn.lock b/yarn.lock
index 5c8cb8e6..0b5e712a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1792,7 +1792,7 @@
lz-string "^1.5.0"
pretty-format "^27.0.2"
-"@testing-library/jest-dom@^5":
+"@testing-library/jest-dom@^5.16.5":
version "5.17.0"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c"
integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==
@@ -1807,7 +1807,7 @@
lodash "^4.17.15"
redent "^3.0.0"
-"@testing-library/react@^12":
+"@testing-library/react@^12.1.5":
version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==