cadr-cli 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
  2. package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
  3. package/dist/analysis/analysis.orchestrator.test.js +177 -0
  4. package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
  5. package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
  6. package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
  7. package/dist/analysis/strategies/git-strategy.test.js +147 -0
  8. package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
  9. package/dist/commands/analyze.test.d.ts +2 -0
  10. package/dist/commands/analyze.test.d.ts.map +1 -0
  11. package/dist/commands/analyze.test.js +70 -0
  12. package/dist/commands/analyze.test.js.map +1 -0
  13. package/dist/commands/init.test.js +128 -2
  14. package/dist/commands/init.test.js.map +1 -1
  15. package/dist/config.test.js +167 -0
  16. package/dist/config.test.js.map +1 -1
  17. package/dist/git/git.errors.test.d.ts +2 -0
  18. package/dist/git/git.errors.test.d.ts.map +1 -0
  19. package/dist/git/git.errors.test.js +34 -0
  20. package/dist/git/git.errors.test.js.map +1 -0
  21. package/dist/git/git.operations.test.d.ts +2 -0
  22. package/dist/git/git.operations.test.d.ts.map +1 -0
  23. package/dist/git/git.operations.test.js +164 -0
  24. package/dist/git/git.operations.test.js.map +1 -0
  25. package/dist/llm/llm.test.d.ts +2 -0
  26. package/dist/llm/llm.test.d.ts.map +1 -0
  27. package/dist/llm/llm.test.js +224 -0
  28. package/dist/llm/llm.test.js.map +1 -0
  29. package/dist/llm/response-parser.test.d.ts +2 -0
  30. package/dist/llm/response-parser.test.d.ts.map +1 -0
  31. package/dist/llm/response-parser.test.js +134 -0
  32. package/dist/llm/response-parser.test.js.map +1 -0
  33. package/dist/presenters/console-presenter.test.d.ts +2 -0
  34. package/dist/presenters/console-presenter.test.d.ts.map +1 -0
  35. package/dist/presenters/console-presenter.test.js +227 -0
  36. package/dist/presenters/console-presenter.test.js.map +1 -0
  37. package/dist/version.test.d.ts +1 -2
  38. package/dist/version.test.d.ts.map +1 -1
  39. package/dist/version.test.js +29 -16
  40. package/dist/version.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/analysis/analysis.orchestrator.test.ts +237 -0
  43. package/src/analysis/strategies/git-strategy.test.ts +210 -0
  44. package/src/commands/analyze.test.ts +91 -0
  45. package/src/commands/init.test.ts +200 -5
  46. package/src/config.test.ts +232 -2
  47. package/src/git/git.errors.test.ts +43 -0
  48. package/src/git/git.operations.test.ts +222 -0
  49. package/src/llm/llm.test.ts +315 -0
  50. package/src/llm/response-parser.test.ts +170 -0
  51. package/src/presenters/console-presenter.test.ts +259 -0
  52. package/src/version.test.ts +30 -16
@@ -0,0 +1,222 @@
1
+ const mockExecAsync = jest.fn();
2
+ jest.mock('child_process', () => ({ exec: jest.fn() }));
3
+ jest.mock('util', () => ({
4
+ ...jest.requireActual('util'),
5
+ promisify: jest.fn(() => mockExecAsync),
6
+ }));
7
+
8
+ import { GitError } from './git.errors';
9
+ import {
10
+ getStagedFiles,
11
+ getStagedDiff,
12
+ getAllChanges,
13
+ getAllDiff,
14
+ getChangedFiles,
15
+ getDiff,
16
+ } from './git.operations';
17
+
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ });
21
+
22
+ afterEach(() => {
23
+ jest.restoreAllMocks();
24
+ });
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // handleGitError (tested indirectly through any git command call)
28
+ // ---------------------------------------------------------------------------
29
+ describe('handleGitError', () => {
30
+ it('throws GitError with code NOT_GIT_REPO when exec fails with code 128', async () => {
31
+ const error = Object.assign(new Error('fatal: not a git repository'), { code: 128 });
32
+ mockExecAsync.mockRejectedValue(error);
33
+
34
+ const promise = getStagedFiles();
35
+ await expect(promise).rejects.toThrow(GitError);
36
+ await expect(promise).rejects.toMatchObject({ code: 'NOT_GIT_REPO' });
37
+ });
38
+
39
+ it('throws GitError with code GIT_NOT_FOUND when exec fails with code 127', async () => {
40
+ const error = Object.assign(new Error('git: command not found'), { code: 127 });
41
+ mockExecAsync.mockRejectedValue(error);
42
+
43
+ await expect(getStagedFiles()).rejects.toThrow(GitError);
44
+ await expect(getStagedFiles()).rejects.toMatchObject({ code: 'GIT_NOT_FOUND' });
45
+ });
46
+
47
+ it('throws GitError with code GIT_ERROR when exec fails with other code', async () => {
48
+ const error = Object.assign(new Error('unknown error'), { code: 1 });
49
+ mockExecAsync.mockRejectedValue(error);
50
+
51
+ await expect(getStagedFiles()).rejects.toThrow(GitError);
52
+ await expect(getStagedFiles()).rejects.toMatchObject({ code: 'GIT_ERROR' });
53
+ });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // getStagedFiles
58
+ // ---------------------------------------------------------------------------
59
+ describe('getStagedFiles', () => {
60
+ it('returns array of filenames from stdout', async () => {
61
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'file1.ts\nfile2.ts\nfile3.ts\n' });
62
+
63
+ const result = await getStagedFiles();
64
+
65
+ expect(result).toEqual(['file1.ts', 'file2.ts', 'file3.ts']);
66
+ });
67
+
68
+ it('returns empty array when stdout is empty', async () => {
69
+ mockExecAsync.mockResolvedValueOnce({ stdout: '' });
70
+
71
+ const result = await getStagedFiles();
72
+
73
+ expect(result).toEqual([]);
74
+ });
75
+
76
+ it('filters blank lines from stdout', async () => {
77
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'file1.ts\n\nfile2.ts\n\n' });
78
+
79
+ const result = await getStagedFiles();
80
+
81
+ expect(result).toEqual(['file1.ts', 'file2.ts']);
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // getStagedDiff
87
+ // ---------------------------------------------------------------------------
88
+ describe('getStagedDiff', () => {
89
+ it('returns raw diff string from stdout', async () => {
90
+ const diff = 'diff --git a/file.ts b/file.ts\n+added line\n';
91
+ mockExecAsync.mockResolvedValueOnce({ stdout: diff });
92
+
93
+ const result = await getStagedDiff();
94
+
95
+ expect(result).toBe(diff);
96
+ });
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // getAllChanges
101
+ // ---------------------------------------------------------------------------
102
+ describe('getAllChanges', () => {
103
+ it('returns array of filenames', async () => {
104
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'a.ts\nb.ts\n' });
105
+
106
+ const result = await getAllChanges();
107
+
108
+ expect(result).toEqual(['a.ts', 'b.ts']);
109
+ });
110
+
111
+ it('runs git diff HEAD --name-only command', async () => {
112
+ mockExecAsync.mockResolvedValueOnce({ stdout: '' });
113
+
114
+ await getAllChanges();
115
+
116
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // getAllDiff
122
+ // ---------------------------------------------------------------------------
123
+ describe('getAllDiff', () => {
124
+ it('returns raw diff string', async () => {
125
+ const diff = 'diff --git a/x.ts b/x.ts\n-removed\n+added\n';
126
+ mockExecAsync.mockResolvedValueOnce({ stdout: diff });
127
+
128
+ const result = await getAllDiff();
129
+
130
+ expect(result).toBe(diff);
131
+ });
132
+
133
+ it('runs git diff HEAD --unified=1 command', async () => {
134
+ mockExecAsync.mockResolvedValueOnce({ stdout: '' });
135
+
136
+ await getAllDiff();
137
+
138
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // getChangedFiles
144
+ // ---------------------------------------------------------------------------
145
+ describe('getChangedFiles', () => {
146
+ it('delegates to getStagedFiles when mode is staged', async () => {
147
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'staged.ts\n' });
148
+
149
+ const result = await getChangedFiles({ mode: 'staged' });
150
+
151
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff --cached --name-only');
152
+ expect(result).toEqual(['staged.ts']);
153
+ });
154
+
155
+ it('delegates to getAllChanges when mode is all', async () => {
156
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'all.ts\n' });
157
+
158
+ const result = await getChangedFiles({ mode: 'all' });
159
+
160
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
161
+ expect(result).toEqual(['all.ts']);
162
+ });
163
+
164
+ it('delegates to getAllChanges when mode is branch-diff (fallback)', async () => {
165
+ mockExecAsync.mockResolvedValueOnce({ stdout: 'branch.ts\n' });
166
+
167
+ const result = await getChangedFiles({ mode: 'branch-diff' });
168
+
169
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
170
+ expect(result).toEqual(['branch.ts']);
171
+ });
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // getDiff
176
+ // ---------------------------------------------------------------------------
177
+ describe('getDiff', () => {
178
+ it('delegates to getStagedDiff when mode is staged', async () => {
179
+ const diff = 'staged diff content';
180
+ mockExecAsync.mockResolvedValueOnce({ stdout: diff });
181
+
182
+ const result = await getDiff({ mode: 'staged' });
183
+
184
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff --cached --unified=1');
185
+ expect(result).toBe(diff);
186
+ });
187
+
188
+ it('delegates to getAllDiff when mode is all', async () => {
189
+ const diff = 'all diff content';
190
+ mockExecAsync.mockResolvedValueOnce({ stdout: diff });
191
+
192
+ const result = await getDiff({ mode: 'all' });
193
+
194
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
195
+ expect(result).toBe(diff);
196
+ });
197
+
198
+ it('delegates to getAllDiff when mode is branch-diff (fallback)', async () => {
199
+ const diff = 'branch diff content';
200
+ mockExecAsync.mockResolvedValueOnce({ stdout: diff });
201
+
202
+ const result = await getDiff({ mode: 'branch-diff' });
203
+
204
+ expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
205
+ expect(result).toBe(diff);
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // parseFileList (tested indirectly)
211
+ // ---------------------------------------------------------------------------
212
+ describe('parseFileList (indirect)', () => {
213
+ it('multi-line stdout with trailing newline returns only non-empty trimmed entries', async () => {
214
+ mockExecAsync.mockResolvedValueOnce({
215
+ stdout: ' file1.ts \nfile2.ts\n \n file3.ts\n',
216
+ });
217
+
218
+ const result = await getStagedFiles();
219
+
220
+ expect(result).toEqual(['file1.ts', 'file2.ts', 'file3.ts']);
221
+ });
222
+ });
@@ -0,0 +1,315 @@
1
+ jest.mock('../providers', () => ({
2
+ getProvider: jest.fn(),
3
+ }));
4
+
5
+ jest.mock('../logger', () => ({
6
+ loggerInstance: {
7
+ info: jest.fn(),
8
+ warn: jest.fn(),
9
+ error: jest.fn(),
10
+ debug: jest.fn(),
11
+ },
12
+ }));
13
+
14
+ import { getProvider } from '../providers';
15
+ import { loggerInstance as logger } from '../logger';
16
+ import { analyzeChanges, generateADRContent } from './llm';
17
+ import type { AnalysisConfig } from '../config';
18
+ import type { LLMProvider } from '../providers/types';
19
+
20
+ const mockedGetProvider = getProvider as jest.MockedFunction<typeof getProvider>;
21
+ const mockedLogger = logger as jest.Mocked<typeof logger>;
22
+
23
+ const config: AnalysisConfig = {
24
+ provider: 'openai',
25
+ analysis_model: 'gpt-4',
26
+ api_key_env: 'TEST_API_KEY',
27
+ timeout_seconds: 15,
28
+ };
29
+
30
+ const baseAnalysisRequest = {
31
+ file_paths: ['src/index.ts'],
32
+ diff_content: 'diff --git a/src/index.ts',
33
+ repository_context: 'test repo',
34
+ analysis_prompt: 'Analyze these changes',
35
+ };
36
+
37
+ const baseGenerationRequest = {
38
+ file_paths: ['src/index.ts'],
39
+ diff_content: 'diff --git a/src/index.ts',
40
+ reason: 'significant architectural change',
41
+ generation_prompt: 'Generate ADR for these changes',
42
+ };
43
+
44
+ function createMockProvider(analyzeFn: jest.Mock): LLMProvider {
45
+ return {
46
+ name: 'openai',
47
+ analyze: analyzeFn,
48
+ };
49
+ }
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ process.env.TEST_API_KEY = 'test-key';
54
+ });
55
+
56
+ afterEach(() => {
57
+ delete process.env.TEST_API_KEY;
58
+ jest.restoreAllMocks();
59
+ });
60
+
61
+ describe('analyzeChanges', () => {
62
+ it('should return error when API key is not set', async () => {
63
+ delete process.env.TEST_API_KEY;
64
+
65
+ const response = await analyzeChanges(config, baseAnalysisRequest);
66
+
67
+ expect(response.result).toBeNull();
68
+ expect(response.error).toContain('TEST_API_KEY');
69
+ });
70
+
71
+ it('should return successful result when provider returns clean JSON', async () => {
72
+ const mockAnalyze = jest.fn().mockResolvedValue(
73
+ JSON.stringify({ is_significant: true, reason: 'big' })
74
+ );
75
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
76
+
77
+ const response = await analyzeChanges(config, baseAnalysisRequest);
78
+
79
+ expect(response.result).not.toBeNull();
80
+ expect(response.result!.is_significant).toBe(true);
81
+ expect(response.result!.reason).toBe('big');
82
+ expect(response.result!.timestamp).toBeDefined();
83
+ expect(response.error).toBeUndefined();
84
+ });
85
+
86
+ it('should parse JSON wrapped in a code block', async () => {
87
+ const mockAnalyze = jest.fn().mockResolvedValue(
88
+ '```json\n{"is_significant": true, "reason": "refactored"}\n```'
89
+ );
90
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
91
+
92
+ const response = await analyzeChanges(config, baseAnalysisRequest);
93
+
94
+ expect(response.result).not.toBeNull();
95
+ expect(response.result!.is_significant).toBe(true);
96
+ expect(response.result!.reason).toBe('refactored');
97
+ });
98
+
99
+ it('should return error when provider returns undefined', async () => {
100
+ const mockAnalyze = jest.fn().mockResolvedValue(undefined);
101
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
102
+
103
+ const response = await analyzeChanges(config, baseAnalysisRequest);
104
+
105
+ expect(response.result).toBeNull();
106
+ expect(response.error).toBe('No response content from LLM');
107
+ });
108
+
109
+ it('should return error when provider returns invalid JSON', async () => {
110
+ const mockAnalyze = jest.fn().mockResolvedValue('not valid json at all');
111
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
112
+
113
+ const response = await analyzeChanges(config, baseAnalysisRequest);
114
+
115
+ expect(response.result).toBeNull();
116
+ expect(response.error).toContain('Failed to parse LLM response as JSON');
117
+ });
118
+
119
+ it('should return error when is_significant is not a boolean', async () => {
120
+ const mockAnalyze = jest.fn().mockResolvedValue(
121
+ JSON.stringify({ is_significant: 'yes', reason: 'something' })
122
+ );
123
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
124
+
125
+ const response = await analyzeChanges(config, baseAnalysisRequest);
126
+
127
+ expect(response.result).toBeNull();
128
+ expect(response.error).toContain('Invalid response format');
129
+ });
130
+
131
+ it('should return error when is_significant is true but reason is empty', async () => {
132
+ const mockAnalyze = jest.fn().mockResolvedValue(
133
+ JSON.stringify({ is_significant: true, reason: '' })
134
+ );
135
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
136
+
137
+ const response = await analyzeChanges(config, baseAnalysisRequest);
138
+
139
+ expect(response.result).toBeNull();
140
+ expect(response.error).toContain('no reason');
141
+ });
142
+
143
+ it('should include confidence when value is within valid range', async () => {
144
+ const mockAnalyze = jest.fn().mockResolvedValue(
145
+ JSON.stringify({ is_significant: true, reason: 'big change', confidence: 0.85 })
146
+ );
147
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
148
+
149
+ const response = await analyzeChanges(config, baseAnalysisRequest);
150
+
151
+ expect(response.result).not.toBeNull();
152
+ expect(response.result!.confidence).toBe(0.85);
153
+ });
154
+
155
+ it('should not include confidence when value is out of range', async () => {
156
+ const mockAnalyze = jest.fn().mockResolvedValue(
157
+ JSON.stringify({ is_significant: true, reason: 'big change', confidence: 1.5 })
158
+ );
159
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
160
+
161
+ const response = await analyzeChanges(config, baseAnalysisRequest);
162
+
163
+ expect(response.result).not.toBeNull();
164
+ expect(response.result!.confidence).toBeUndefined();
165
+ });
166
+
167
+ it('should log warning when estimated tokens exceed 7000', async () => {
168
+ const longPrompt = 'x'.repeat(28004);
169
+ const mockAnalyze = jest.fn().mockResolvedValue(
170
+ JSON.stringify({ is_significant: false, reason: 'minor' })
171
+ );
172
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
173
+
174
+ const request = { ...baseAnalysisRequest, analysis_prompt: longPrompt };
175
+ await analyzeChanges(config, request);
176
+
177
+ expect(mockedLogger.warn).toHaveBeenCalledWith(
178
+ 'High token count detected',
179
+ expect.objectContaining({ estimated_tokens: expect.any(Number) })
180
+ );
181
+ });
182
+
183
+ it('should return auth error when provider throws with status 401', async () => {
184
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' });
185
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
186
+
187
+ const response = await analyzeChanges(config, baseAnalysisRequest);
188
+
189
+ expect(response.result).toBeNull();
190
+ expect(response.error).toContain('Invalid API key');
191
+ });
192
+
193
+ it('should return diff-too-large error when provider throws status 400 with context length message', async () => {
194
+ const mockAnalyze = jest.fn().mockRejectedValue({
195
+ status: 400,
196
+ message: 'maximum context length exceeded with 50000 tokens',
197
+ });
198
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
199
+
200
+ const response = await analyzeChanges(config, baseAnalysisRequest);
201
+
202
+ expect(response.result).toBeNull();
203
+ expect(response.error).toContain('Diff too large');
204
+ });
205
+
206
+ it('should return rate limit error when provider throws with status 429', async () => {
207
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 429, message: 'Too many requests' });
208
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
209
+
210
+ const response = await analyzeChanges(config, baseAnalysisRequest);
211
+
212
+ expect(response.result).toBeNull();
213
+ expect(response.error).toContain('Rate limit exceeded');
214
+ });
215
+
216
+ it('should return timeout error when provider throws with code ETIMEDOUT', async () => {
217
+ const mockAnalyze = jest.fn().mockRejectedValue({ code: 'ETIMEDOUT', message: 'connection timed out' });
218
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
219
+
220
+ const response = await analyzeChanges(config, baseAnalysisRequest);
221
+
222
+ expect(response.result).toBeNull();
223
+ expect(response.error).toContain('timeout');
224
+ expect(response.error).toContain('15s');
225
+ });
226
+
227
+ it('should return timeout error when provider throws with timeout in message', async () => {
228
+ const mockAnalyze = jest.fn().mockRejectedValue({ message: 'Request timeout after 15000ms' });
229
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
230
+
231
+ const response = await analyzeChanges(config, baseAnalysisRequest);
232
+
233
+ expect(response.result).toBeNull();
234
+ expect(response.error).toContain('timeout');
235
+ });
236
+
237
+ it('should return network error when provider throws with code ENOTFOUND', async () => {
238
+ const mockAnalyze = jest.fn().mockRejectedValue({ code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' });
239
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
240
+
241
+ const response = await analyzeChanges(config, baseAnalysisRequest);
242
+
243
+ expect(response.result).toBeNull();
244
+ expect(response.error).toContain('Network error');
245
+ });
246
+
247
+ it('should return generic API error for unknown errors', async () => {
248
+ const mockAnalyze = jest.fn().mockRejectedValue({ message: 'Something unexpected' });
249
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
250
+
251
+ const response = await analyzeChanges(config, baseAnalysisRequest);
252
+
253
+ expect(response.result).toBeNull();
254
+ expect(response.error).toBe('API error: Something unexpected');
255
+ });
256
+ });
257
+
258
+ describe('generateADRContent', () => {
259
+ it('should return error when API key is not set', async () => {
260
+ delete process.env.TEST_API_KEY;
261
+
262
+ const response = await generateADRContent(config, baseGenerationRequest);
263
+
264
+ expect(response.result).toBeNull();
265
+ expect(response.error).toContain('TEST_API_KEY');
266
+ });
267
+
268
+ it('should extract title from markdown h1 and return cleaned content', async () => {
269
+ const markdown = '# Adopt New Database\n\n## Context\nWe need a new DB.';
270
+ const mockAnalyze = jest.fn().mockResolvedValue(markdown);
271
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
272
+
273
+ const response = await generateADRContent(config, baseGenerationRequest);
274
+
275
+ expect(response.result).not.toBeNull();
276
+ expect(response.result!.title).toBe('Adopt New Database');
277
+ expect(response.result!.content).toBe(markdown);
278
+ expect(response.result!.timestamp).toBeDefined();
279
+ expect(response.error).toBeUndefined();
280
+ });
281
+
282
+ it('should strip code block wrapper from markdown response', async () => {
283
+ const inner = '# My Decision\n\n## Context\nSome context.';
284
+ const wrapped = '```markdown\n' + inner + '\n```';
285
+ const mockAnalyze = jest.fn().mockResolvedValue(wrapped);
286
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
287
+
288
+ const response = await generateADRContent(config, baseGenerationRequest);
289
+
290
+ expect(response.result).not.toBeNull();
291
+ expect(response.result!.title).toBe('My Decision');
292
+ expect(response.result!.content).toBe(inner);
293
+ });
294
+
295
+ it('should use Untitled Decision when markdown has no h1', async () => {
296
+ const markdown = '## Context\nSome context without a title.';
297
+ const mockAnalyze = jest.fn().mockResolvedValue(markdown);
298
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
299
+
300
+ const response = await generateADRContent(config, baseGenerationRequest);
301
+
302
+ expect(response.result).not.toBeNull();
303
+ expect(response.result!.title).toBe('Untitled Decision');
304
+ });
305
+
306
+ it('should return auth error when provider throws with status 401', async () => {
307
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' });
308
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
309
+
310
+ const response = await generateADRContent(config, baseGenerationRequest);
311
+
312
+ expect(response.result).toBeNull();
313
+ expect(response.error).toContain('Invalid API key');
314
+ });
315
+ });