diff --git a/src/setup/setup.spec.ts b/src/setup/setup.spec.ts index da3ebf048c2..da398b50490 100644 --- a/src/setup/setup.spec.ts +++ b/src/setup/setup.spec.ts @@ -1,453 +1,793 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - +import type { MockInstance } from 'vitest'; import dotenv from 'dotenv'; import fs from 'fs'; -import { main, askAndSetRecaptcha } from './setup'; +import { main, askAndSetRecaptcha, askAndSetLogErrors, getErrorMessage } from './setup'; import { checkEnvFile, modifyEnvFile } from './checkEnvFile/checkEnvFile'; +import { validateRecaptcha } from './validateRecaptcha/validateRecaptcha'; import askAndSetDockerOption from './askAndSetDockerOption/askAndSetDockerOption'; import updateEnvFile from './updateEnvFile/updateEnvFile'; import askAndUpdatePort from './askAndUpdatePort/askAndUpdatePort'; import { askAndUpdateTalawaApiUrl } from './askForDocker/askForDocker'; +import { backupEnvFile } from './backupEnvFile/backupEnvFile'; import inquirer from 'inquirer'; +// Mock all external dependencies vi.mock('./backupEnvFile/backupEnvFile', () => ({ backupEnvFile: vi.fn().mockResolvedValue(undefined), })); -vi.mock('inquirer', () => ({ - default: { - prompt: vi.fn(), - }, -})); -vi.mock('dotenv'); -vi.mock('fs', () => { - const readFile = vi.fn(); - - return { - default: { - promises: { - readFile, - }, - }, - promises: { - readFile, - }, - }; -}); +vi.mock('inquirer'); +vi.mock('dotenv'); +vi.mock('fs'); vi.mock('./checkEnvFile/checkEnvFile'); vi.mock('./validateRecaptcha/validateRecaptcha'); -vi.mock('./askAndSetDockerOption/askAndSetDockerOption', () => ({ - default: vi.fn(), -})); -vi.mock('./updateEnvFile/updateEnvFile', () => ({ - default: vi.fn(), -})); -vi.mock('./askAndUpdatePort/askAndUpdatePort', () => ({ - default: vi.fn(), -})); +vi.mock('./askAndSetDockerOption/askAndSetDockerOption'); +vi.mock('./updateEnvFile/updateEnvFile'); +vi.mock('./askAndUpdatePort/askAndUpdatePort'); vi.mock('./askForDocker/askForDocker'); describe('Talawa Admin Setup', () => { + let processExitSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let consoleLogSpy: MockInstance; + beforeEach(() => { vi.clearAllMocks(); - // default flow: env file check passes vi.mocked(checkEnvFile).mockReturnValue(true); - - // default fs content says NO docker - vi.mocked(fs.promises.readFile).mockResolvedValue('USE_DOCKER=NO'); + vi.mocked(fs.readFileSync).mockReturnValue('USE_DOCKER=NO' as any); vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'NO' }); - - // mock external functions to resolve normally vi.mocked(askAndSetDockerOption).mockResolvedValue(undefined); vi.mocked(askAndUpdatePort).mockResolvedValue(undefined); vi.mocked(askAndUpdateTalawaApiUrl).mockResolvedValue(undefined); vi.mocked(modifyEnvFile).mockImplementation(() => undefined); vi.mocked(updateEnvFile).mockImplementation(() => undefined); + vi.mocked(validateRecaptcha).mockReturnValue(true); + vi.mocked(backupEnvFile).mockResolvedValue(undefined); - // default process.exit spy (we won't throw unless a test needs it) - vi.spyOn(process, 'exit').mockImplementation(() => { - // keep as noop for normal tests - return undefined as never; - }); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((( + code?: number, + ) => { + throw new Error(`process.exit called with code ${code}`); + }) as never); + + consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - vi.spyOn(console, 'log').mockImplementation(() => undefined); + consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => undefined); }); afterEach(() => { vi.clearAllMocks(); - vi.restoreAllMocks(); + vi.resetAllMocks(); + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); }); - it('should call API setup with false when Docker is disabled', async () => { - // Setup environment for NO Docker - vi.mocked(fs.promises.readFile).mockResolvedValue('USE_DOCKER=NO'); - vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'NO' }); + describe('main function', () => { + it('should successfully complete setup with default options', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); + + await main(); + + expect(checkEnvFile).toHaveBeenCalled(); + expect(backupEnvFile).toHaveBeenCalled(); + expect(modifyEnvFile).toHaveBeenCalled(); + expect(askAndSetDockerOption).toHaveBeenCalled(); + expect(askAndUpdatePort).toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalled(); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'NO', + ); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + '', + ); + }); - vi.mocked(inquirer.prompt) - .mockResolvedValueOnce({ shouldUseRecaptcha: false }) - .mockResolvedValueOnce({ shouldLogErrors: false }); + it('should call askAndUpdateTalawaApiUrl when Docker is used and skip port setup', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('USE_DOCKER=YES' as any); + vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'YES' }); - await main(); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); - expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(false); - }); + await main(); - it('should call API setup with true when Docker is enabled', async () => { - // Setup environment for YES Docker - vi.mocked(fs.promises.readFile).mockResolvedValue('USE_DOCKER=YES'); - vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'YES' }); + expect(askAndUpdatePort).not.toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(true); + }); - vi.mocked(inquirer.prompt) - .mockResolvedValueOnce({ shouldUseRecaptcha: false }) - .mockResolvedValueOnce({ shouldLogErrors: false }); + it('should handle error logging setup when user opts in', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: true }); - await main(); + await main(); - expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(true); - }); + expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'YES'); + }); - it('should exit early when checkEnvFile returns false', async () => { - vi.mocked(checkEnvFile).mockReturnValue(false); + it('should handle errors during setup process (and call process.exit(1))', async () => { + const mockError = new Error('Setup failed'); + vi.mocked(askAndSetDockerOption).mockRejectedValueOnce(mockError); - await main(); + await expect(main()).rejects.toThrow('process.exit called with code 1'); - // Should not proceed with setup - expect(modifyEnvFile).not.toHaveBeenCalled(); - expect(askAndSetDockerOption).not.toHaveBeenCalled(); - expect(askAndUpdatePort).not.toHaveBeenCalled(); - expect(askAndUpdateTalawaApiUrl).not.toHaveBeenCalled(); - }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + mockError, + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); - it('should call askAndUpdateTalawaApiUrl when Docker is used and skip port setup', async () => { - vi.mocked(fs.promises.readFile).mockResolvedValue('USE_DOCKER=YES'); - vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'YES' }); + it('should return early when checkEnvFile returns false', async () => { + vi.mocked(checkEnvFile).mockReturnValue(false); + await main(); + expect(modifyEnvFile).not.toHaveBeenCalled(); + expect(askAndSetDockerOption).not.toHaveBeenCalled(); + expect(backupEnvFile).not.toHaveBeenCalled(); + }); - vi.mocked(inquirer.prompt) - .mockResolvedValueOnce({ shouldUseRecaptcha: false }) - .mockResolvedValueOnce({ shouldLogErrors: false }); + it('should display welcome and success messages', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); - await main(); + await main(); - // with docker = YES, askAndUpdatePort should NOT be called - expect(askAndUpdatePort).not.toHaveBeenCalled(); - // askAndUpdateTalawaApiUrl is called (implementation calls it when useDocker true) - expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(true); - }); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Welcome to the Talawa Admin setup! 🚀', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '\nCongratulations! Talawa Admin has been successfully set up! 🥂🎉', + ); + }); - it('should handle error logging setup when user opts in', async () => { - vi.mocked(inquirer.prompt) - .mockResolvedValueOnce({ shouldUseRecaptcha: false }) - .mockResolvedValueOnce({ shouldLogErrors: true }); + it('should display error support message when setup fails', async () => { + const mockError = new Error('Setup failed'); + vi.mocked(askAndSetDockerOption).mockRejectedValueOnce(mockError); - await main(); + await expect(main()).rejects.toThrow('process.exit called with code 1'); - // ALLOW_LOGS should be set to YES when user opts in - expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'YES'); - }); + expect(consoleLogSpy).toHaveBeenCalledWith( + '\nPlease try again or contact support if the issue persists.', + ); + }); + + it('should complete full setup flow with Docker=NO and all options enabled', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('USE_DOCKER=NO' as any); + vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'NO' }); + vi.mocked(validateRecaptcha).mockReturnValue(true); - it('should handle errors during setup process (and call process.exit(1))', async () => { - const mockError = new Error('Setup failed'); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-recaptcha-key' }) + .mockResolvedValueOnce({ shouldLogErrors: true }); - vi.mocked(askAndSetDockerOption).mockRejectedValueOnce(mockError); + await main(); + + expect(askAndUpdatePort).toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(false); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'YES', + ); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + 'test-recaptcha-key', + ); + expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'YES'); + }); + + it('should complete full setup flow with Docker=YES and all options enabled', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('USE_DOCKER=YES' as any); + vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'YES' }); + vi.mocked(validateRecaptcha).mockReturnValue(true); + + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-recaptcha-key' }) + .mockResolvedValueOnce({ shouldLogErrors: true }); + + await main(); + + expect(askAndUpdatePort).not.toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(true); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'YES', + ); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + 'test-recaptcha-key', + ); + expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'YES'); + }); + + it('should handle errors during backupEnvFile', async () => { + const mockError = new Error('Backup failed'); + vi.mocked(backupEnvFile).mockRejectedValueOnce(mockError); + + await expect(main()).rejects.toThrow('process.exit called with code 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + mockError, + ); + }); - // make process.exit throw so we can assert it was called (and break out) - const exitMock = vi - .spyOn(process, 'exit') - .mockImplementationOnce((code) => { - throw new Error(`process.exit called with code ${code}`); + it('should handle errors during modifyEnvFile', async () => { + const mockError = new Error('Modify env failed'); + vi.mocked(modifyEnvFile).mockImplementation(() => { + throw mockError; }); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); + await expect(main()).rejects.toThrow('process.exit called with code 1'); - await expect(main()).rejects.toThrow('process.exit called with code 1'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + mockError, + ); - expect(consoleSpy).toHaveBeenCalledWith('\n❌ Setup failed:', mockError); - expect(exitMock).toHaveBeenCalledWith(1); + // Verify subsequent steps were not executed due to early error + expect(askAndSetDockerOption).not.toHaveBeenCalled(); + }); - consoleSpy.mockRestore(); - exitMock.mockRestore(); - }); + it('should handle errors during askAndUpdatePort', async () => { + const mockError = new Error('Port update failed'); + vi.mocked(askAndUpdatePort).mockRejectedValueOnce(mockError); + + await expect(main()).rejects.toThrow('process.exit called with code 1'); - it('should handle errors during reCAPTCHA setup and propagate a helpful message', async () => { - const mockError = new Error('ReCAPTCHA setup failed'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + mockError, + ); + }); - vi.mocked(inquirer.prompt).mockRejectedValueOnce(mockError); + it('should handle errors during askAndUpdateTalawaApiUrl', async () => { + const mockError = new Error('API URL update failed'); + vi.mocked(askAndUpdateTalawaApiUrl).mockRejectedValueOnce(mockError); - const localConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); + await expect(main()).rejects.toThrow('process.exit called with code 1'); - await expect(askAndSetRecaptcha()).rejects.toThrow( - 'Failed to set up reCAPTCHA: ReCAPTCHA setup failed', - ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + mockError, + ); + }); - expect(localConsoleError).toHaveBeenCalledWith( - 'Error setting up reCAPTCHA:', - mockError, - ); + it('should handle errors during askAndSetRecaptcha in main flow', async () => { + const mockError = new Error('Recaptcha setup failed in main'); + vi.mocked(inquirer.prompt).mockRejectedValueOnce(mockError); - localConsoleError.mockRestore(); - }); + await expect(main()).rejects.toThrow('process.exit called with code 1'); - it('should handle reCAPTCHA setup when user opts in with valid key', async () => { - const mockValidKey = 'valid-key'; - const { validateRecaptcha: mockValidateRecaptcha } = await vi.importMock< - typeof import('./validateRecaptcha/validateRecaptcha') - >('./validateRecaptcha/validateRecaptcha'); - mockValidateRecaptcha.mockReturnValue(true); - - vi.mocked(inquirer.prompt) - .mockResolvedValueOnce({ shouldUseRecaptcha: true }) - .mockResolvedValueOnce({ recaptchaSiteKeyInput: mockValidKey }); - - await askAndSetRecaptcha(); - - expect(updateEnvFile).toHaveBeenCalledWith( - 'REACT_APP_USE_RECAPTCHA', - 'YES', - ); - expect(updateEnvFile).toHaveBeenCalledWith( - 'REACT_APP_RECAPTCHA_SITE_KEY', - mockValidKey, - ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\n❌ Setup failed:', + expect.any(Error), + ); + }); + + it('should handle errors during askAndSetLogErrors in main flow', async () => { + const mockError = new Error('Log errors setup failed in main'); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockRejectedValueOnce(mockError); + + await expect(main()).rejects.toThrow('process.exit called with code 1'); + + expect(consoleErrorSpy).toHaveBeenCalled(); + }); }); - // Helper function to extract validation function from prompt spy - const extractRecaptchaValidationFn = (promptSpy: { - mock: { - calls: unknown[][]; - }; - }): ((input: string) => boolean | string) | undefined => { - const secondCallArgs = promptSpy.mock.calls[1][0]; - const questionArray = Array.isArray(secondCallArgs) - ? secondCallArgs - : [secondCallArgs]; - const recaptchaKeyQuestion = questionArray.find( - (q: { name?: string; validate?: (input: string) => boolean | string }) => - q.name === 'recaptchaSiteKeyInput', - ); - - return recaptchaKeyQuestion?.validate; - }; - - it('should test the validation function for reCAPTCHA site key with valid input', async () => { - const { validateRecaptcha } = await import( - './validateRecaptcha/validateRecaptcha' - ); - - // Mock validateRecaptcha to return true for valid input - vi.mocked(validateRecaptcha).mockReturnValue(true); + describe('askAndSetRecaptcha function', () => { + it('should handle reCAPTCHA setup when user opts in with valid key', async () => { + const mockValidKey = 'valid-key'; + vi.mocked(validateRecaptcha).mockReturnValue(true); - const promptSpy = vi - .spyOn(inquirer, 'prompt') - .mockResolvedValueOnce({ shouldUseRecaptcha: true }) - .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'valid-key' }); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: mockValidKey }); - await askAndSetRecaptcha(); + await askAndSetRecaptcha(); - // Verify prompt was called twice - expect(promptSpy).toHaveBeenCalledTimes(2); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'YES', + ); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + mockValidKey, + ); + }); + + it('should handle reCAPTCHA setup when user opts out', async () => { + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldUseRecaptcha: false, + }); + + await askAndSetRecaptcha(); - // Extract and test the validation function - const capturedValidationFn = extractRecaptchaValidationFn(promptSpy); - expect(capturedValidationFn).toBeDefined(); - if (!capturedValidationFn) { - throw new Error( - 'Validation function for reCAPTCHA site key was not captured', + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'NO', ); - } - const result = capturedValidationFn('valid-key'); - expect(result).toBe(true); - }); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + '', + ); + + // Ensure we didn't prompt for the site key + expect(inquirer.prompt).toHaveBeenCalledTimes(1); + }); + + it('should handle errors during reCAPTCHA setup and propagate a helpful message', async () => { + const mockError = new Error('ReCAPTCHA setup failed'); + vi.mocked(inquirer.prompt).mockRejectedValueOnce(mockError); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: ReCAPTCHA setup failed', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error setting up reCAPTCHA:', + mockError, + ); + }); - it('should test the validation function for reCAPTCHA site key with invalid input', async () => { - const { validateRecaptcha } = await import( - './validateRecaptcha/validateRecaptcha' - ); + it('should pass validation config to inquirer prompt for reCAPTCHA', async () => { + const mockValidKey = 'valid-recaptcha-key'; - // Mock validateRecaptcha to return false for invalid input - vi.mocked(validateRecaptcha).mockReturnValue(false); + vi.mocked(validateRecaptcha).mockReturnValue(true); - const promptSpy = vi - .spyOn(inquirer, 'prompt') - .mockResolvedValueOnce({ shouldUseRecaptcha: true }) - .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'invalid-key' }); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: mockValidKey }); - await askAndSetRecaptcha(); + await askAndSetRecaptcha(); - // Verify prompt was called twice - expect(promptSpy).toHaveBeenCalledTimes(2); + // Verify the second prompt call structure + const secondPromptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(secondPromptCall) + ? secondPromptCall + : [secondPromptCall]; - // Extract and test the validation function - const capturedValidationFn = extractRecaptchaValidationFn(promptSpy); - expect(capturedValidationFn).toBeDefined(); - if (!capturedValidationFn) { - throw new Error( - 'Validation function for reCAPTCHA site key was not captured', + expect(questions[0]).toHaveProperty('validate'); + expect(typeof questions[0].validate).toBe('function'); + + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_USE_RECAPTCHA', + 'YES', ); - } - const result = capturedValidationFn('invalid-key'); - expect(result).toBe('Invalid reCAPTCHA site key. Please try again.'); - }); + expect(updateEnvFile).toHaveBeenCalledWith( + 'REACT_APP_RECAPTCHA_SITE_KEY', + mockValidKey, + ); + }); - it('should rethrow ExitPromptError in askAndSetRecaptcha', async () => { - const exitPromptError = new Error('User cancelled'); - (exitPromptError as { name: string }).name = 'ExitPromptError'; + it('should have validation function that returns error message for invalid key', async () => { + // Call the function to trigger prompt setup + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); - vi.spyOn(inquirer, 'prompt').mockRejectedValueOnce(exitPromptError); + await askAndSetRecaptcha(); - await expect(askAndSetRecaptcha()).rejects.toThrow('User cancelled'); - }); + // Get the prompt config + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; - it('should handle non-Error objects thrown in askAndSetRecaptcha', async () => { - const nonErrorObject = { message: 'Something went wrong' }; + // Explicitly assert validateFn is defined + expect(validateFn).toBeDefined(); - vi.spyOn(inquirer, 'prompt').mockRejectedValueOnce(nonErrorObject); + // Test validation with invalid key + vi.mocked(validateRecaptcha).mockReturnValue(false); + const result = validateFn('invalid-key'); + expect(result).toBe('Invalid reCAPTCHA site key. Please try again.'); + }); - const localConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); + it('should have validation function that returns true for valid key', async () => { + // Call the function to trigger prompt setup + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); - await expect(askAndSetRecaptcha()).rejects.toThrow( - 'Failed to set up reCAPTCHA: [object Object]', - ); + await askAndSetRecaptcha(); - expect(localConsoleError).toHaveBeenCalledWith( - 'Error setting up reCAPTCHA:', - nonErrorObject, - ); + // Get the prompt config + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; - localConsoleError.mockRestore(); - }); + // Explicitly assert validateFn is defined + expect(validateFn).toBeDefined(); + + // Test validation with valid key + vi.mocked(validateRecaptcha).mockReturnValue(true); + const result = validateFn('valid-key'); + expect(result).toBe(true); + }); + + it('should handle errors during the second prompt (site key input)', async () => { + const mockError = new Error('Site key input failed'); + + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockRejectedValueOnce(mockError); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: Site key input failed', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error setting up reCAPTCHA:', + mockError, + ); + }); - it('should handle SIGINT (CTRL+C) during setup and exit with code 130', async () => { - let sigintHandler: (() => void) | undefined; - - // Capture the SIGINT handler when it's registered - const onSpy = vi - .spyOn(process, 'on') - .mockImplementation((event, handler) => { - if (event === 'SIGINT') { - sigintHandler = handler as () => void; - } - return process; + it('should handle updateEnvFile errors gracefully', async () => { + const mockError = new Error('Update env failed'); + vi.mocked(updateEnvFile).mockImplementation(() => { + throw mockError; }); - const exitMock = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit called with code ${code}`); + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldUseRecaptcha: false, + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: Update env failed', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error setting up reCAPTCHA:', + mockError, + ); }); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => undefined); + it('should handle validation function throwing error', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); - // Mock to make main() hang after setting up SIGINT handler - vi.mocked(askAndSetDockerOption).mockImplementationOnce( - () => new Promise(() => {}), // Never resolves - ); + await askAndSetRecaptcha(); - // Start main (it will hang at askAndSetDockerOption) - const mainPromise = main(); + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; - // Wait for SIGINT handler to be registered - await new Promise((resolve) => setTimeout(resolve, 50)); + expect(validateFn).toBeDefined(); - // Verify the handler was captured - expect(sigintHandler).toBeDefined(); + // Test validation when validateRecaptcha throws + vi.mocked(validateRecaptcha).mockImplementation(() => { + throw new Error('Validation error'); + }); - // Call the SIGINT handler directly and expect it to throw - expect(() => sigintHandler?.()).toThrow( - 'process.exit called with code 130', - ); + const result = validateFn('test-key'); + expect(result).toBe('Validation error: Validation error'); + }); - expect(consoleLogSpy).toHaveBeenCalledWith( - '\n\n⚠️ Setup cancelled by user.', - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - 'Configuration may be incomplete. Run setup again to complete.', - ); - expect(exitMock).toHaveBeenCalledWith(130); + it('should handle validation function with non-Error thrown object', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); - // Clean up - onSpy.mockRestore(); - consoleLogSpy.mockRestore(); - exitMock.mockRestore(); + await askAndSetRecaptcha(); + + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; + + expect(validateFn).toBeDefined(); + + // Test validation when validateRecaptcha throws non-Error + vi.mocked(validateRecaptcha).mockImplementation(() => { + throw 'String error'; + }); + + const result = validateFn('test-key'); + expect(result).toBe('Validation error: String error'); + }); + + it('should handle validation function with object thrown', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); + + await askAndSetRecaptcha(); + + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; + + expect(validateFn).toBeDefined(); + + // Test validation when validateRecaptcha throws object + vi.mocked(validateRecaptcha).mockImplementation(() => { + throw { code: 'ERR', message: 'Object error' }; + }); + + const result = validateFn('test-key'); + expect(result).toContain('Validation error:'); + expect(result).toContain('ERR'); + }); + + it('should handle validation function with circular reference object', async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: true }) + .mockResolvedValueOnce({ recaptchaSiteKeyInput: 'test-key' }); + + await askAndSetRecaptcha(); + + const promptCall = vi.mocked(inquirer.prompt).mock.calls[1][0] as any; + const questions = Array.isArray(promptCall) ? promptCall : [promptCall]; + const validateFn = questions[0].validate; + + expect(validateFn).toBeDefined(); + + // Test validation when validateRecaptcha throws circular object + vi.mocked(validateRecaptcha).mockImplementation(() => { + const circular: any = { a: 1 }; + circular.self = circular; + throw circular; + }); + + const result = validateFn('test-key'); + expect(result).toContain('Validation error:'); + }); + + it('should handle error with string type in catch block', async () => { + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw 'String error thrown'; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: String error thrown', + ); + }); + + it('should handle error with object type in catch block', async () => { + const objectError = { code: 'TEST', msg: 'Object error' }; + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw objectError; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA:', + ); + }); + + it('should handle error with circular object in catch block', async () => { + const circular: any = { a: 1 }; + circular.self = circular; + + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw circular; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA:', + ); + }); + + it('should handle error with number type in catch block', async () => { + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw 123; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: 123', + ); + }); + + it('should handle error with null in catch block', async () => { + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw null; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: null', + ); + }); - // mainPromise will never resolve since askAndSetDockerOption hangs - await Promise.race([ - mainPromise, - new Promise((resolve) => setTimeout(resolve, 10)), - ]); + it('should handle error with undefined in catch block', async () => { + vi.mocked(inquirer.prompt).mockImplementation(() => { + throw undefined; + }); + + await expect(askAndSetRecaptcha()).rejects.toThrow( + 'Failed to set up reCAPTCHA: undefined', + ); + }); }); - it('should handle ExitPromptError in main and exit with code 130', async () => { - const exitPromptError = new Error('User cancelled prompt'); - (exitPromptError as { name: string }).name = 'ExitPromptError'; + describe('askAndSetLogErrors function', () => { + it('should handle askAndSetLogErrors when user opts out', async () => { + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldLogErrors: false, + }); + await askAndSetLogErrors(); + expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'NO'); + }); - vi.mocked(askAndSetDockerOption).mockRejectedValueOnce(exitPromptError); + it('should handle askAndSetLogErrors when user opts in', async () => { + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldLogErrors: true, + }); + await askAndSetLogErrors(); + expect(updateEnvFile).toHaveBeenCalledWith('ALLOW_LOGS', 'YES'); + }); - const exitMock = vi - .spyOn(process, 'exit') - .mockImplementationOnce((code) => { - throw new Error(`process.exit called with code ${code}`); + it('should handle errors in askAndSetLogErrors', async () => { + const mockError = new Error('Logging setup failed'); + vi.mocked(inquirer.prompt).mockRejectedValueOnce(mockError); + await expect(askAndSetLogErrors()).rejects.toThrow( + 'Failed to set up logging: Logging setup failed', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error setting up logging:', + mockError, + ); + }); + + it('should handle updateEnvFile errors in askAndSetLogErrors', async () => { + const mockError = new Error('Update env failed'); + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldLogErrors: true, + }); + vi.mocked(updateEnvFile).mockImplementation(() => { + throw mockError; }); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => undefined); + await expect(askAndSetLogErrors()).rejects.toThrow( + 'Failed to set up logging: Update env failed', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error setting up logging:', + mockError, + ); + }); - await expect(main()).rejects.toThrow('process.exit called with code 130'); + it('should pass correct prompt configuration to inquirer', async () => { + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldLogErrors: true, + }); - expect(consoleLogSpy).toHaveBeenCalledWith( - '\n\n⚠️ Setup cancelled by user.', - ); - expect(exitMock).toHaveBeenCalledWith(130); + await askAndSetLogErrors(); - consoleLogSpy.mockRestore(); - exitMock.mockRestore(); + const promptCall = vi.mocked(inquirer.prompt).mock.calls[0][0]; + + expect(promptCall).toMatchObject({ + type: 'confirm', + name: 'shouldLogErrors', + message: + 'Would you like to log Compiletime and Runtime errors in the console?', + default: true, + }); + }); }); - it('should remove SIGINT listener after setup completes successfully', async () => { - const removeListenerSpy = vi.spyOn(process, 'removeListener'); + describe('getErrorMessage function', () => { + it('should handle Error instances', () => { + const error = new Error('Test error message'); + expect(getErrorMessage(error)).toBe('Test error message'); + }); + + it('should handle string errors', () => { + expect(getErrorMessage('String error')).toBe('String error'); + }); + + it('should handle object errors by converting to JSON', () => { + const objError = { code: 500, message: 'Server error' }; + expect(getErrorMessage(objError)).toBe(JSON.stringify(objError)); + }); + + it('should handle numbers by converting to string', () => { + expect(getErrorMessage(123)).toBe('123'); + }); - vi.spyOn(inquirer, 'prompt') - .mockResolvedValueOnce({ shouldUseRecaptcha: false }) - .mockResolvedValueOnce({ shouldLogErrors: false }); + it('should handle null', () => { + expect(getErrorMessage(null)).toBe('null'); + }); - await main(); + it('should handle undefined', () => { + expect(getErrorMessage(undefined)).toBe('undefined'); + }); - expect(removeListenerSpy).toHaveBeenCalledWith( - 'SIGINT', - expect.any(Function), - ); + it('should handle circular references safely', () => { + const circular: any = { a: 1 }; + circular.self = circular; + + const result = getErrorMessage(circular); + expect(result).toContain('[object Object]'); + }); - removeListenerSpy.mockRestore(); + it('should handle arrays', () => { + const arrayError = [1, 2, 3]; + expect(getErrorMessage(arrayError)).toBe('[1,2,3]'); + }); }); - it('should remove SIGINT listener even when an error occurs', async () => { - const removeListenerSpy = vi.spyOn(process, 'removeListener'); - const mockError = new Error('Some error'); + describe('Module execution', () => { + it('should export all required functions', () => { + expect(typeof main).toBe('function'); + expect(typeof askAndSetRecaptcha).toBe('function'); + expect(typeof askAndSetLogErrors).toBe('function'); + expect(typeof getErrorMessage).toBe('function'); + }); - vi.mocked(askAndSetDockerOption).mockRejectedValueOnce(mockError); + it('should not have side effects on import', () => { + // Clear all mocks to reset any calls that might have happened + vi.clearAllMocks(); + + // Assert that setup functions haven't been called before explicit invocation + expect(checkEnvFile).not.toHaveBeenCalled(); + expect(backupEnvFile).not.toHaveBeenCalled(); + expect(modifyEnvFile).not.toHaveBeenCalled(); + expect(askAndSetDockerOption).not.toHaveBeenCalled(); + + // Verify exports are available + expect(main).toBeDefined(); + expect(askAndSetRecaptcha).toBeDefined(); + expect(askAndSetLogErrors).toBeDefined(); + expect(getErrorMessage).toBeDefined(); + }); + }); - const exitMock = vi - .spyOn(process, 'exit') - .mockImplementationOnce((code) => { - throw new Error(`process.exit called with code ${code}`); - }); + describe('Edge cases and integration scenarios', () => { + it('should handle empty env config gracefully', async () => { + vi.mocked(dotenv.parse).mockReturnValue({}); + + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); + + await main(); + + expect(askAndUpdatePort).toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(false); + }); + + it('should handle malformed env file content', async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + 'INVALID_CONTENT_WITHOUT_DOCKER' as any, + ); + vi.mocked(dotenv.parse).mockReturnValue({}); - await expect(main()).rejects.toThrow('process.exit called with code 1'); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); - expect(removeListenerSpy).toHaveBeenCalledWith( - 'SIGINT', - expect.any(Function), - ); + await main(); - removeListenerSpy.mockRestore(); - exitMock.mockRestore(); + expect(askAndUpdatePort).toHaveBeenCalled(); + }); + + it('should handle case-sensitive Docker flag (lowercase "yes")', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('USE_DOCKER=yes' as any); + vi.mocked(dotenv.parse).mockReturnValue({ USE_DOCKER: 'yes' }); + + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ shouldUseRecaptcha: false }) + .mockResolvedValueOnce({ shouldLogErrors: false }); + + await main(); + + // Should treat as false since it is not exactly "YES" + expect(askAndUpdatePort).toHaveBeenCalled(); + expect(askAndUpdateTalawaApiUrl).toHaveBeenCalledWith(false); + }); }); -}); +}); \ No newline at end of file diff --git a/src/setup/setup.ts b/src/setup/setup.ts index 3029d7367a7..bf6438b1578 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -9,6 +9,31 @@ import askAndUpdatePort from './askAndUpdatePort/askAndUpdatePort'; import { askAndUpdateTalawaApiUrl } from './askForDocker/askForDocker'; import { backupEnvFile } from './backupEnvFile/backupEnvFile'; +/** + * Gets a user-friendly error message from any error type + * @param error - The error that occurred + * @returns A string representation of the error + */ +export const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (error === undefined) { + return 'undefined'; + } + if (error === null) { + return 'null'; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + /** * Environment variable value constants */ @@ -29,12 +54,6 @@ export const ENV_KEYS = { BACKEND_WEBSOCKET_URL: 'REACT_APP_BACKEND_WEBSOCKET_URL', } as const; -const isExitPromptError = (error: unknown): boolean => - typeof error === 'object' && - error !== null && - 'name' in error && - (error as { name: string }).name === 'ExitPromptError'; - /** * Prompts user to configure reCAPTCHA settings and updates the .env file. * @@ -50,12 +69,13 @@ const isExitPromptError = (error: unknown): boolean => * ``` * * @returns `Promise` - Resolves when configuration is complete. - * @throws ExitPromptError - If user cancels the prompt. * @throws Error - If user input fails or environment update fails. */ export const askAndSetRecaptcha = async (): Promise => { try { - const { shouldUseRecaptcha } = await inquirer.prompt([ + const { shouldUseRecaptcha } = await inquirer.prompt<{ + shouldUseRecaptcha: boolean; + }>([ { type: 'confirm', name: 'shouldUseRecaptcha', @@ -70,14 +90,24 @@ export const askAndSetRecaptcha = async (): Promise => { ); if (shouldUseRecaptcha) { - const { recaptchaSiteKeyInput } = await inquirer.prompt([ + const { recaptchaSiteKeyInput } = await inquirer.prompt<{ + recaptchaSiteKeyInput: string; + }>([ { type: 'input', name: 'recaptchaSiteKeyInput', message: 'Enter your reCAPTCHA site key:', - validate: (input: string): boolean | string => - validateRecaptcha(input) || - 'Invalid reCAPTCHA site key. Please try again.', + validate: (input: string) => { + try { + const isValid = validateRecaptcha(input); + if (isValid) { + return true; + } + return 'Invalid reCAPTCHA site key. Please try again.'; + } catch (err) { + return `Validation error: ${getErrorMessage(err)}`; + } + }, }, ]); @@ -86,32 +116,47 @@ export const askAndSetRecaptcha = async (): Promise => { updateEnvFile(ENV_KEYS.RECAPTCHA_SITE_KEY, ''); } } catch (error) { - if (isExitPromptError(error)) { - throw error; - } console.error('Error setting up reCAPTCHA:', error); - throw new Error( - `Failed to set up reCAPTCHA: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + throw new Error(`Failed to set up reCAPTCHA: ${getErrorMessage(error)}`); } }; -// Ask and set up logging errors in the console -const askAndSetLogErrors = async (): Promise => { - const { shouldLogErrors } = await inquirer.prompt({ - type: 'confirm', - name: 'shouldLogErrors', - message: - 'Would you like to log Compiletime and Runtime errors in the console?', - default: true, - }); - - updateEnvFile( - ENV_KEYS.ALLOW_LOGS, - shouldLogErrors ? ENV_VALUES.YES : ENV_VALUES.NO, - ); +/** + * Prompts user to configure error logging settings and updates the .env file. + * + * @remarks + * This function handles the interactive setup for error logging configuration: + * - Asks whether to enable compile-time and runtime error logging + * - Updates ALLOW_LOGS in .env + * + * @example + * ```typescript + * await askAndSetLogErrors(); + * ``` + * + * @returns `Promise` - Resolves when configuration is complete. + * @throws Error - If user input fails or environment update fails. + */ +export const askAndSetLogErrors = async (): Promise => { + try { + const { shouldLogErrors } = await inquirer.prompt<{ + shouldLogErrors: boolean; + }>({ + type: 'confirm', + name: 'shouldLogErrors', + message: + 'Would you like to log Compiletime and Runtime errors in the console?', + default: true, + }); + + updateEnvFile( + ENV_KEYS.ALLOW_LOGS, + shouldLogErrors ? ENV_VALUES.YES : ENV_VALUES.NO, + ); + } catch (error) { + console.error('Error setting up logging:', error); + throw new Error(`Failed to set up logging: ${getErrorMessage(error)}`); + } }; /** @@ -143,17 +188,6 @@ const askAndSetLogErrors = async (): Promise => { * @throws Error - if any setup step fails. */ export async function main(): Promise { - // Handle user cancellation (CTRL+C) - const sigintHandler = (): void => { - console.log('\n\n⚠️ Setup cancelled by user.'); - console.log( - 'Configuration may be incomplete. Run setup again to complete.', - ); - process.exit(130); - }; - - process.on('SIGINT', sigintHandler); - try { if (!checkEnvFile()) { return; @@ -186,17 +220,8 @@ export async function main(): Promise { '\nCongratulations! Talawa Admin has been successfully set up! 🥂🎉', ); } catch (error) { - if (isExitPromptError(error)) { - console.log('\n\n⚠️ Setup cancelled by user.'); - process.exit(130); - } - console.error('\n❌ Setup failed:', error); console.log('\nPlease try again or contact support if the issue persists.'); process.exit(1); - } finally { - process.removeListener('SIGINT', sigintHandler); } -} - -main(); +} \ No newline at end of file