diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe3986..39750d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,79 +253,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} diff --git a/tests/unit/commands/generate.test.ts b/tests/unit/commands/generate.test.ts index e53ee7e..289ccb1 100644 --- a/tests/unit/commands/generate.test.ts +++ b/tests/unit/commands/generate.test.ts @@ -612,5 +612,703 @@ describe('commands/generate.ts', () => { mockExit.mockRestore(); }); + + it('should exit if no cover letter templates found', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'cover-letter' }); + + expect(p.cancel).toHaveBeenCalledWith( + 'No cover letter templates found. Add one with "downfolio template add"' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should exit if invalid OpenAI model provided', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'invalid-model' as any, + }); + + expect(p.cancel).toHaveBeenCalledWith( + 'Model "invalid-model" is not a valid OpenAI model.' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should exit if invalid Anthropic model provided', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'anthropic', + model: 'gpt-4o-mini' as any, + }); + + expect(p.cancel).toHaveBeenCalledWith( + 'Model "gpt-4o-mini" is not a valid Anthropic model.' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should prompt for OpenAI model if not provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue('gpt-4o' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + }); + + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which OpenAI model?', + }) + ); + }); + + it('should prompt for Anthropic model if not provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + vi.mocked(config.getDefaultModel).mockReturnValue('claude-sonnet-4-5'); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue('claude-haiku-4-5' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'anthropic' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + }); + + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which Anthropic model?', + }) + ); + }); + + it('should convert to PDF format', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(pandoc.convertMarkdownToFormats).toHaveBeenCalledWith( + 'Customized resume', + 'resume.md', + expect.any(String), + ['pdf'] + ); + }); + + it('should convert to multiple formats (docx and pdf)', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['docx', 'pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(pandoc.convertMarkdownToFormats).toHaveBeenCalledWith( + 'Customized resume', + 'resume.md', + expect.any(String), + ['docx', 'pdf'] + ); + }); + + it('should skip directory creation if output directory already exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it('should default output name to job name when empty string provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.text).mockResolvedValue('' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.outro).toHaveBeenCalledWith( + expect.stringContaining('test_job') + ); + }); + + it('should exit if document type selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if resume template selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'resume' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if cover letter template selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'cover-letter' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if format selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.multiselect).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if provider selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if OpenAI model selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if Anthropic model selection is cancelled', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if output name is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.text).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should generate documents with Anthropic provider', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument) + .mockResolvedValueOnce({ content: 'Customized resume', provider: 'anthropic' } as any) + .mockResolvedValueOnce({ content: 'Customized cover letter', provider: 'anthropic' } as any); + + await generateCommand({ + job: 'test_job', + type: 'both', + resumeTemplate: 'base_resume', + coverLetterTemplate: 'base_cover_letter', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + model: 'claude-sonnet-4-5', + }); + + expect(ai.customizeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'anthropic', + model: 'claude-sonnet-4-5', + }) + ); + expect(p.outro).toHaveBeenCalled(); + }); + + it('should display spinner messages in correct order', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['docx'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls[0][0]).toBe('Reading job description...'); + expect(messageCalls[1][0]).toBe('AI customizing resume...'); + expect(messageCalls[2][0]).toBe('Converting resume to Word/PDF...'); + }); + + it('should call readTemplateFile with correct parameters for resume', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(files.readTemplateFile).toHaveBeenCalledWith('base_resume', 'resume'); + }); + + it('should call readTemplateFile with correct parameters for cover letter', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + coverLetterTemplate: 'base_cover_letter', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(files.readTemplateFile).toHaveBeenCalledWith('base_cover_letter', 'cover-letter'); + }); + + it('should exit if Anthropic API key not found when provider specified', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'anthropic', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Anthropic API key not found') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should display correct spinner message when converting cover letter to Word/PDF', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + coverLetterTemplate: 'base_cover_letter', + format: ['docx'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls.some(call => call[0] === 'Converting cover letter to Word/PDF...')).toBe(true); + }); + + it('should generate both documents with docx format and show conversion messages', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument) + .mockResolvedValueOnce({ content: 'Customized resume', provider: 'openai' } as any) + .mockResolvedValueOnce({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'both', + resumeTemplate: 'base_resume', + coverLetterTemplate: 'base_cover_letter', + format: ['docx', 'pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls.some(call => call[0] === 'Converting resume to Word/PDF...')).toBe(true); + expect(messageCalls.some(call => call[0] === 'Converting cover letter to Word/PDF...')).toBe(true); + }); + + it('should handle generation failure when resume template becomes undefined', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + + // Mock select to return undefined (simulating an edge case) + let selectCallCount = 0; + vi.mocked(p.select).mockImplementation(async () => { + selectCallCount++; + return undefined as any; + }); + vi.mocked(p.isCancel).mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Generation failed') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should handle generation failure when cover letter template becomes undefined', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + + // Mock select to return undefined (simulating an edge case) + vi.mocked(p.select).mockImplementation(async () => { + return undefined as any; + }); + vi.mocked(p.isCancel).mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Generation failed') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); }); });