@meltstudio/meltctl 4.27.0 → 4.28.1

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 (38) hide show
  1. package/dist/commands/audit.js +30 -5
  2. package/dist/commands/audit.test.d.ts +1 -0
  3. package/dist/commands/audit.test.js +250 -0
  4. package/dist/commands/coins.test.d.ts +1 -0
  5. package/dist/commands/coins.test.js +133 -0
  6. package/dist/commands/feedback.test.d.ts +1 -0
  7. package/dist/commands/feedback.test.js +242 -0
  8. package/dist/commands/init.js +21 -4
  9. package/dist/commands/init.test.d.ts +1 -0
  10. package/dist/commands/init.test.js +476 -0
  11. package/dist/commands/login.test.d.ts +1 -0
  12. package/dist/commands/login.test.js +174 -0
  13. package/dist/commands/logout.test.d.ts +1 -0
  14. package/dist/commands/logout.test.js +59 -0
  15. package/dist/commands/plan.test.d.ts +1 -0
  16. package/dist/commands/plan.test.js +283 -0
  17. package/dist/commands/standup.test.d.ts +1 -0
  18. package/dist/commands/standup.test.js +252 -0
  19. package/dist/commands/templates.test.d.ts +1 -0
  20. package/dist/commands/templates.test.js +89 -0
  21. package/dist/commands/version.test.d.ts +1 -0
  22. package/dist/commands/version.test.js +86 -0
  23. package/dist/index.js +1 -1
  24. package/dist/utils/api.test.d.ts +1 -0
  25. package/dist/utils/api.test.js +96 -0
  26. package/dist/utils/auth.test.d.ts +1 -0
  27. package/dist/utils/auth.test.js +165 -0
  28. package/dist/utils/banner.test.d.ts +1 -0
  29. package/dist/utils/banner.test.js +34 -0
  30. package/dist/utils/git.test.d.ts +1 -0
  31. package/dist/utils/git.test.js +184 -0
  32. package/dist/utils/package-manager.test.d.ts +1 -0
  33. package/dist/utils/package-manager.test.js +76 -0
  34. package/dist/utils/templates.test.d.ts +1 -0
  35. package/dist/utils/templates.test.js +50 -0
  36. package/dist/utils/version-check.test.d.ts +1 -0
  37. package/dist/utils/version-check.test.js +135 -0
  38. package/package.json +2 -1
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('../utils/auth.js', () => ({
3
+ getStoredAuth: vi.fn(),
4
+ clearAuth: vi.fn(),
5
+ }));
6
+ import { getStoredAuth, clearAuth } from '../utils/auth.js';
7
+ import { logoutCommand } from './logout.js';
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ vi.spyOn(console, 'log').mockImplementation(() => { });
11
+ vi.spyOn(console, 'error').mockImplementation(() => { });
12
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
13
+ throw new Error(`process.exit(${code})`);
14
+ });
15
+ });
16
+ describe('logoutCommand', () => {
17
+ it('clears auth and shows logged-out message with email when session exists', async () => {
18
+ ;
19
+ getStoredAuth.mockResolvedValue({
20
+ token: 'jwt-token',
21
+ email: 'dev@meltstudio.co',
22
+ expiresAt: '2026-04-01T00:00:00Z',
23
+ });
24
+ clearAuth.mockResolvedValue(undefined);
25
+ await logoutCommand();
26
+ expect(clearAuth).toHaveBeenCalled();
27
+ const logCalls = console.log.mock.calls.map((c) => c[0]);
28
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Logged out'))).toBe(true);
29
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('dev@meltstudio.co'))).toBe(true);
30
+ });
31
+ it('clears auth and shows "no active session" when no session exists', async () => {
32
+ ;
33
+ getStoredAuth.mockResolvedValue(undefined);
34
+ clearAuth.mockResolvedValue(undefined);
35
+ await logoutCommand();
36
+ expect(clearAuth).toHaveBeenCalled();
37
+ const logCalls = console.log.mock.calls.map((c) => c[0]);
38
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('No active session'))).toBe(true);
39
+ });
40
+ it('always calls clearAuth regardless of session state', async () => {
41
+ ;
42
+ getStoredAuth.mockResolvedValue(undefined);
43
+ clearAuth.mockResolvedValue(undefined);
44
+ await logoutCommand();
45
+ expect(clearAuth).toHaveBeenCalledTimes(1);
46
+ });
47
+ it('calls getStoredAuth before clearAuth', async () => {
48
+ const callOrder = [];
49
+ getStoredAuth.mockImplementation(async () => {
50
+ callOrder.push('getStoredAuth');
51
+ return { token: 'tok', email: 'a@b.co', expiresAt: '2026-04-01T00:00:00Z' };
52
+ });
53
+ clearAuth.mockImplementation(async () => {
54
+ callOrder.push('clearAuth');
55
+ });
56
+ await logoutCommand();
57
+ expect(callOrder).toEqual(['getStoredAuth', 'clearAuth']);
58
+ });
59
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('fs-extra', () => ({
3
+ default: {
4
+ pathExists: vi.fn(),
5
+ readFile: vi.fn(),
6
+ },
7
+ }));
8
+ vi.mock('../utils/api.js', () => ({
9
+ getToken: vi.fn(),
10
+ tokenFetch: vi.fn(),
11
+ }));
12
+ vi.mock('../utils/git.js', () => ({
13
+ getGitBranch: vi.fn(),
14
+ getGitCommit: vi.fn(),
15
+ getGitRepository: vi.fn(),
16
+ getProjectName: vi.fn(),
17
+ extractTicketId: vi.fn(),
18
+ findMdFiles: vi.fn(),
19
+ }));
20
+ import fs from 'fs-extra';
21
+ import { getToken, tokenFetch } from '../utils/api.js';
22
+ import { getGitBranch, getGitCommit, getGitRepository, getProjectName, extractTicketId, findMdFiles, } from '../utils/git.js';
23
+ import { planSubmitCommand, planListCommand } from './plan.js';
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ vi.spyOn(console, 'log').mockImplementation(() => { });
27
+ vi.spyOn(console, 'error').mockImplementation(() => { });
28
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
29
+ throw new Error(`process.exit(${code})`);
30
+ });
31
+ });
32
+ describe('planSubmitCommand', () => {
33
+ function setupGitMocks() {
34
+ ;
35
+ getToken.mockResolvedValue('test-token');
36
+ getGitBranch.mockReturnValue('feature/PROJ-123-add-feature');
37
+ getGitCommit.mockReturnValue('def5678');
38
+ getGitRepository.mockReturnValue({
39
+ slug: 'Org/Repo',
40
+ url: 'https://github.com/Org/Repo.git',
41
+ });
42
+ getProjectName.mockReturnValue('test-project');
43
+ extractTicketId.mockReturnValue('PROJ-123');
44
+ }
45
+ it('submits a plan with correct payload fields', async () => {
46
+ setupGitMocks();
47
+ fs.pathExists.mockResolvedValue(true);
48
+ fs.readFile.mockResolvedValue('# Plan\nSome plan content.');
49
+ const mockResponse = {
50
+ ok: true,
51
+ json: vi.fn().mockResolvedValue({ id: 'plan-100', created: true }),
52
+ };
53
+ tokenFetch.mockResolvedValue(mockResponse);
54
+ await planSubmitCommand('PROJ-123-plan.md');
55
+ const call = tokenFetch.mock.calls[0];
56
+ const body = JSON.parse(call[2].body);
57
+ expect(body).toEqual({
58
+ project: 'test-project',
59
+ repository: 'Org/Repo',
60
+ repositoryUrl: 'https://github.com/Org/Repo.git',
61
+ branch: 'feature/PROJ-123-add-feature',
62
+ commit: 'def5678',
63
+ content: '# Plan\nSome plan content.',
64
+ metadata: { filename: 'PROJ-123-plan.md' },
65
+ ticket: 'PROJ-123',
66
+ });
67
+ });
68
+ it('logs "Plan submitted" when API returns created: true', async () => {
69
+ setupGitMocks();
70
+ fs.pathExists.mockResolvedValue(true);
71
+ fs.readFile.mockResolvedValue('plan content');
72
+ const mockResponse = {
73
+ ok: true,
74
+ json: vi.fn().mockResolvedValue({ id: 'plan-new', created: true }),
75
+ };
76
+ tokenFetch.mockResolvedValue(mockResponse);
77
+ await planSubmitCommand('plan.md');
78
+ const logCalls = console.log.mock.calls.map((c) => c[0]);
79
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Plan submitted'))).toBe(true);
80
+ });
81
+ it('logs "Plan updated" when API returns created: false', async () => {
82
+ setupGitMocks();
83
+ fs.pathExists.mockResolvedValue(true);
84
+ fs.readFile.mockResolvedValue('updated plan content');
85
+ const mockResponse = {
86
+ ok: true,
87
+ json: vi.fn().mockResolvedValue({ id: 'plan-upd', created: false }),
88
+ };
89
+ tokenFetch.mockResolvedValue(mockResponse);
90
+ await planSubmitCommand('plan.md');
91
+ const logCalls = console.log.mock.calls.map((c) => c[0]);
92
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Plan updated'))).toBe(true);
93
+ });
94
+ it('exits with error when file not found', async () => {
95
+ setupGitMocks();
96
+ fs.pathExists.mockResolvedValue(false);
97
+ await expect(planSubmitCommand('nonexistent.md')).rejects.toThrow('process.exit(1)');
98
+ expect(console.error).toHaveBeenCalled();
99
+ });
100
+ it('exits with error when API returns failure', async () => {
101
+ setupGitMocks();
102
+ fs.pathExists.mockResolvedValue(true);
103
+ fs.readFile.mockResolvedValue('content');
104
+ const mockResponse = {
105
+ ok: false,
106
+ statusText: 'Bad Request',
107
+ json: vi.fn().mockResolvedValue({ error: 'Invalid content' }),
108
+ };
109
+ tokenFetch.mockResolvedValue(mockResponse);
110
+ await expect(planSubmitCommand('plan.md')).rejects.toThrow('process.exit(1)');
111
+ });
112
+ it('auto-detects plan file from .plans/ directory when no file provided', async () => {
113
+ setupGitMocks();
114
+ findMdFiles.mockResolvedValue(['/project/.plans/PROJ-123-plan.md']);
115
+ extractTicketId.mockReturnValue('PROJ-123');
116
+ fs.pathExists.mockResolvedValue(true);
117
+ fs.readFile.mockResolvedValue('auto-detected plan');
118
+ const mockResponse = {
119
+ ok: true,
120
+ json: vi.fn().mockResolvedValue({ id: 'plan-auto', created: true }),
121
+ };
122
+ tokenFetch.mockResolvedValue(mockResponse);
123
+ await planSubmitCommand();
124
+ expect(tokenFetch).toHaveBeenCalledWith('test-token', '/plans', expect.objectContaining({
125
+ method: 'POST',
126
+ }));
127
+ });
128
+ it('exits with error when no file provided and none auto-detected', async () => {
129
+ ;
130
+ getToken.mockResolvedValue('test-token');
131
+ findMdFiles.mockResolvedValue([]);
132
+ getGitBranch.mockReturnValue('main');
133
+ extractTicketId.mockReturnValue(null);
134
+ await expect(planSubmitCommand()).rejects.toThrow('process.exit(1)');
135
+ expect(console.error).toHaveBeenCalled();
136
+ });
137
+ it('does not include ticket in payload when extractTicketId returns null', async () => {
138
+ ;
139
+ getToken.mockResolvedValue('test-token');
140
+ getGitBranch.mockReturnValue('main');
141
+ getGitCommit.mockReturnValue('abc1234');
142
+ getGitRepository.mockReturnValue({
143
+ slug: 'Org/Repo',
144
+ url: 'https://github.com/Org/Repo.git',
145
+ });
146
+ getProjectName.mockReturnValue('test-project');
147
+ extractTicketId.mockReturnValue(null);
148
+ fs.pathExists.mockResolvedValue(true);
149
+ fs.readFile.mockResolvedValue('plan without ticket');
150
+ const mockResponse = {
151
+ ok: true,
152
+ json: vi.fn().mockResolvedValue({ id: 'plan-no-ticket', created: true }),
153
+ };
154
+ tokenFetch.mockResolvedValue(mockResponse);
155
+ await planSubmitCommand('plan.md');
156
+ const call = tokenFetch.mock.calls[0];
157
+ const body = JSON.parse(call[2].body);
158
+ expect(body.ticket).toBeUndefined();
159
+ });
160
+ it('includes status from frontmatter when present', async () => {
161
+ setupGitMocks();
162
+ fs.pathExists.mockResolvedValue(true);
163
+ fs.readFile.mockResolvedValue('---\nstatus: approved\n---\n# Plan');
164
+ const mockResponse = {
165
+ ok: true,
166
+ json: vi.fn().mockResolvedValue({ id: 'plan-status', created: true }),
167
+ };
168
+ tokenFetch.mockResolvedValue(mockResponse);
169
+ await planSubmitCommand('plan.md');
170
+ const call = tokenFetch.mock.calls[0];
171
+ const body = JSON.parse(call[2].body);
172
+ expect(body.status).toBe('approved');
173
+ });
174
+ it('does not include status when no frontmatter present', async () => {
175
+ setupGitMocks();
176
+ fs.pathExists.mockResolvedValue(true);
177
+ fs.readFile.mockResolvedValue('# Plan\nNo frontmatter here.');
178
+ const mockResponse = {
179
+ ok: true,
180
+ json: vi.fn().mockResolvedValue({ id: 'plan-no-status', created: true }),
181
+ };
182
+ tokenFetch.mockResolvedValue(mockResponse);
183
+ await planSubmitCommand('plan.md');
184
+ const call = tokenFetch.mock.calls[0];
185
+ const body = JSON.parse(call[2].body);
186
+ expect(body.status).toBeUndefined();
187
+ });
188
+ });
189
+ describe('planListCommand', () => {
190
+ it('calls API with correct query params', async () => {
191
+ ;
192
+ getToken.mockResolvedValue('test-token');
193
+ const mockResponse = {
194
+ ok: true,
195
+ status: 200,
196
+ json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
197
+ };
198
+ tokenFetch.mockResolvedValue(mockResponse);
199
+ await planListCommand({ repository: 'Org/Repo', author: 'dev@meltstudio.co', limit: '5' });
200
+ expect(tokenFetch).toHaveBeenCalledWith('test-token', expect.stringContaining('/plans?'));
201
+ const url = tokenFetch.mock.calls[0][1];
202
+ expect(url).toContain('repository=Org%2FRepo');
203
+ expect(url).toContain('author=dev%40meltstudio.co');
204
+ expect(url).toContain('limit=5');
205
+ });
206
+ it('exits with error on 403 response', async () => {
207
+ ;
208
+ getToken.mockResolvedValue('test-token');
209
+ const mockResponse = {
210
+ ok: false,
211
+ status: 403,
212
+ statusText: 'Forbidden',
213
+ };
214
+ tokenFetch.mockResolvedValue(mockResponse);
215
+ await expect(planListCommand({})).rejects.toThrow('process.exit(1)');
216
+ expect(console.error).toHaveBeenCalled();
217
+ });
218
+ it('displays plan list when plans exist', async () => {
219
+ ;
220
+ getToken.mockResolvedValue('test-token');
221
+ const mockResponse = {
222
+ ok: true,
223
+ status: 200,
224
+ json: vi.fn().mockResolvedValue({
225
+ plans: [
226
+ {
227
+ id: '1',
228
+ project: 'my-project',
229
+ repository: 'Org/Repo',
230
+ author: 'dev@meltstudio.co',
231
+ branch: 'feature/PROJ-123',
232
+ commit: 'abc1234',
233
+ ticket: 'PROJ-123',
234
+ status: 'submitted',
235
+ createdAt: '2026-03-25T10:00:00Z',
236
+ updatedAt: '2026-03-25T12:00:00Z',
237
+ },
238
+ ],
239
+ count: 1,
240
+ }),
241
+ };
242
+ tokenFetch.mockResolvedValue(mockResponse);
243
+ await planListCommand({});
244
+ expect(console.log).toHaveBeenCalled();
245
+ });
246
+ it('shows "No plans found" when list is empty', async () => {
247
+ ;
248
+ getToken.mockResolvedValue('test-token');
249
+ const mockResponse = {
250
+ ok: true,
251
+ status: 200,
252
+ json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
253
+ };
254
+ tokenFetch.mockResolvedValue(mockResponse);
255
+ await planListCommand({});
256
+ const logCalls = console.log.mock.calls.map((c) => c[0]);
257
+ expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('No plans found'))).toBe(true);
258
+ });
259
+ it('calls API without query params when no options provided', async () => {
260
+ ;
261
+ getToken.mockResolvedValue('test-token');
262
+ const mockResponse = {
263
+ ok: true,
264
+ status: 200,
265
+ json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
266
+ };
267
+ tokenFetch.mockResolvedValue(mockResponse);
268
+ await planListCommand({});
269
+ expect(tokenFetch).toHaveBeenCalledWith('test-token', '/plans');
270
+ });
271
+ it('exits with error on non-403 failure response', async () => {
272
+ ;
273
+ getToken.mockResolvedValue('test-token');
274
+ const mockResponse = {
275
+ ok: false,
276
+ status: 500,
277
+ statusText: 'Internal Server Error',
278
+ json: vi.fn().mockResolvedValue({ error: 'Server error' }),
279
+ };
280
+ tokenFetch.mockResolvedValue(mockResponse);
281
+ await expect(planListCommand({})).rejects.toThrow('process.exit(1)');
282
+ });
283
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('../utils/auth.js', () => ({
3
+ isAuthenticated: vi.fn(),
4
+ authenticatedFetch: vi.fn(),
5
+ }));
6
+ vi.mock('@inquirer/prompts', () => ({
7
+ input: vi.fn(),
8
+ editor: vi.fn(),
9
+ }));
10
+ import { isAuthenticated, authenticatedFetch } from '../utils/auth.js';
11
+ import { input, editor } from '@inquirer/prompts';
12
+ import { standupCommand } from './standup.js';
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ vi.spyOn(console, 'log').mockImplementation(() => { });
16
+ vi.spyOn(console, 'error').mockImplementation(() => { });
17
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
18
+ throw new Error(`process.exit(${code})`);
19
+ });
20
+ });
21
+ describe('standupCommand', () => {
22
+ it('exits with error when not authenticated', async () => {
23
+ ;
24
+ isAuthenticated.mockResolvedValue(false);
25
+ await expect(standupCommand({})).rejects.toThrow('process.exit(1)');
26
+ expect(console.error).toHaveBeenCalled();
27
+ });
28
+ it('returns early when standup already submitted today', async () => {
29
+ ;
30
+ isAuthenticated.mockResolvedValue(true);
31
+ const statusResponse = {
32
+ ok: true,
33
+ json: vi.fn().mockResolvedValue({
34
+ yesterday: 'Did stuff',
35
+ today: 'More stuff',
36
+ blockers: null,
37
+ }),
38
+ };
39
+ authenticatedFetch.mockResolvedValue(statusResponse);
40
+ await standupCommand({});
41
+ expect(authenticatedFetch).toHaveBeenCalledWith('/standups/status');
42
+ // Should not call POST /standups
43
+ expect(authenticatedFetch).toHaveBeenCalledTimes(1);
44
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
45
+ expect(logCalls.some((msg) => msg.includes('already submitted'))).toBe(true);
46
+ });
47
+ it('submits standup with correct payload using option flags', async () => {
48
+ ;
49
+ isAuthenticated.mockResolvedValue(true);
50
+ // Status check returns not found (no existing standup)
51
+ const statusResponse = { ok: false };
52
+ const submitResponse = {
53
+ ok: true,
54
+ json: vi.fn().mockResolvedValue({}),
55
+ };
56
+ authenticatedFetch
57
+ .mockResolvedValueOnce(statusResponse)
58
+ .mockResolvedValueOnce(submitResponse);
59
+ await standupCommand({
60
+ yesterday: 'Fixed bugs',
61
+ today: 'Write tests',
62
+ blockers: 'None',
63
+ });
64
+ expect(authenticatedFetch).toHaveBeenCalledWith('/standups', {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({
68
+ yesterday: 'Fixed bugs',
69
+ today: 'Write tests',
70
+ blockers: 'None',
71
+ }),
72
+ });
73
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
74
+ expect(logCalls.some((msg) => msg.includes('Standup submitted'))).toBe(true);
75
+ });
76
+ it('submits standup without blockers when empty string', async () => {
77
+ ;
78
+ isAuthenticated.mockResolvedValue(true);
79
+ const statusResponse = { ok: false };
80
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
81
+ authenticatedFetch
82
+ .mockResolvedValueOnce(statusResponse)
83
+ .mockResolvedValueOnce(submitResponse);
84
+ await standupCommand({
85
+ yesterday: 'Did things',
86
+ today: 'More things',
87
+ });
88
+ const call = authenticatedFetch.mock.calls[1];
89
+ const body = JSON.parse(call[1].body);
90
+ expect(body.blockers).toBeUndefined();
91
+ });
92
+ it('exits with error when API returns failure', async () => {
93
+ ;
94
+ isAuthenticated.mockResolvedValue(true);
95
+ const statusResponse = { ok: false };
96
+ const submitResponse = {
97
+ ok: false,
98
+ statusText: 'Bad Request',
99
+ json: vi.fn().mockResolvedValue({ error: 'Invalid standup' }),
100
+ };
101
+ authenticatedFetch
102
+ .mockResolvedValueOnce(statusResponse)
103
+ .mockResolvedValueOnce(submitResponse);
104
+ await expect(standupCommand({ yesterday: 'x', today: 'y' })).rejects.toThrow('process.exit(1)');
105
+ expect(console.error).toHaveBeenCalled();
106
+ });
107
+ it('continues to submission when status check throws', async () => {
108
+ ;
109
+ isAuthenticated.mockResolvedValue(true);
110
+ authenticatedFetch
111
+ .mockRejectedValueOnce(new Error('Network error'))
112
+ .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({}) });
113
+ await standupCommand({ yesterday: 'a', today: 'b' });
114
+ expect(authenticatedFetch).toHaveBeenCalledTimes(2);
115
+ expect(authenticatedFetch).toHaveBeenCalledWith('/standups', expect.objectContaining({
116
+ method: 'POST',
117
+ }));
118
+ });
119
+ it('displays existing standup with blockers', async () => {
120
+ ;
121
+ isAuthenticated.mockResolvedValue(true);
122
+ const statusResponse = {
123
+ ok: true,
124
+ json: vi.fn().mockResolvedValue({
125
+ yesterday: 'Bug fixes',
126
+ today: 'Feature work',
127
+ blockers: 'Waiting on review',
128
+ }),
129
+ };
130
+ authenticatedFetch.mockResolvedValue(statusResponse);
131
+ await standupCommand({});
132
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
133
+ expect(logCalls.some((msg) => msg.includes('already submitted'))).toBe(true);
134
+ });
135
+ describe('interactive flow', () => {
136
+ function setupInteractiveAuth() {
137
+ ;
138
+ isAuthenticated.mockResolvedValue(true);
139
+ authenticatedFetch.mockResolvedValueOnce({ ok: false });
140
+ }
141
+ it('prompts for yesterday, today, and blockers in interactive mode', async () => {
142
+ setupInteractiveAuth();
143
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
144
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
145
+ input
146
+ .mockResolvedValueOnce('Worked on feature X')
147
+ .mockResolvedValueOnce('Working on feature Y')
148
+ .mockResolvedValueOnce('No blockers');
149
+ await standupCommand({});
150
+ expect(input).toHaveBeenCalledTimes(3);
151
+ expect(authenticatedFetch).toHaveBeenCalledWith('/standups', expect.objectContaining({
152
+ method: 'POST',
153
+ body: JSON.stringify({
154
+ yesterday: 'Worked on feature X',
155
+ today: 'Working on feature Y',
156
+ blockers: 'No blockers',
157
+ }),
158
+ }));
159
+ });
160
+ it('submits without blockers when left empty in interactive mode', async () => {
161
+ setupInteractiveAuth();
162
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
163
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
164
+ input
165
+ .mockResolvedValueOnce('Did code review')
166
+ .mockResolvedValueOnce('Deploy to staging')
167
+ .mockResolvedValueOnce('');
168
+ await standupCommand({});
169
+ const call = authenticatedFetch.mock.calls[1];
170
+ const body = JSON.parse(call[1].body);
171
+ expect(body.blockers).toBeUndefined();
172
+ });
173
+ it('re-prompts when required field is empty', async () => {
174
+ setupInteractiveAuth();
175
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
176
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
177
+ input
178
+ .mockResolvedValueOnce(' ') // empty yesterday -> re-prompt
179
+ .mockResolvedValueOnce('Fixed a bug') // valid yesterday
180
+ .mockResolvedValueOnce('Write tests') // valid today
181
+ .mockResolvedValueOnce(''); // empty blockers (ok, not required)
182
+ await standupCommand({});
183
+ expect(input).toHaveBeenCalledTimes(4);
184
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
185
+ expect(logCalls.some((msg) => msg.includes('required'))).toBe(true);
186
+ });
187
+ it('opens editor when user types \\e', async () => {
188
+ setupInteractiveAuth();
189
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
190
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
191
+ input
192
+ .mockResolvedValueOnce('\\e') // trigger editor for yesterday
193
+ .mockResolvedValueOnce('Writing docs') // today
194
+ .mockResolvedValueOnce('') // no blockers
195
+ ;
196
+ editor.mockResolvedValueOnce('Detailed yesterday notes from editor');
197
+ await standupCommand({});
198
+ expect(editor).toHaveBeenCalledTimes(1);
199
+ const call = authenticatedFetch.mock.calls[1];
200
+ const body = JSON.parse(call[1].body);
201
+ expect(body.yesterday).toBe('Detailed yesterday notes from editor');
202
+ });
203
+ it('falls back to inline input when editor fails', async () => {
204
+ setupInteractiveAuth();
205
+ const submitResponse = { ok: true, json: vi.fn().mockResolvedValue({}) };
206
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
207
+ input
208
+ .mockResolvedValueOnce('\\e') // trigger editor for yesterday
209
+ .mockResolvedValueOnce('Fallback input') // fallback after editor fails
210
+ .mockResolvedValueOnce('Today tasks') // today
211
+ .mockResolvedValueOnce('') // no blockers
212
+ ;
213
+ editor.mockRejectedValueOnce(new Error('Editor not found'));
214
+ await standupCommand({});
215
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
216
+ expect(logCalls.some((msg) => msg.includes('Editor failed'))).toBe(true);
217
+ expect(input).toHaveBeenCalledTimes(4);
218
+ });
219
+ it('exits with error when API fails in interactive mode', async () => {
220
+ setupInteractiveAuth();
221
+ const submitResponse = {
222
+ ok: false,
223
+ statusText: 'Unprocessable Entity',
224
+ json: vi.fn().mockResolvedValue({ error: 'Missing fields' }),
225
+ };
226
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
227
+ input
228
+ .mockResolvedValueOnce('Yesterday work')
229
+ .mockResolvedValueOnce('Today work')
230
+ .mockResolvedValueOnce('');
231
+ await expect(standupCommand({})).rejects.toThrow('process.exit(1)');
232
+ const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
233
+ expect(errorCalls.some((msg) => msg.includes('Missing fields'))).toBe(true);
234
+ });
235
+ it('falls back to statusText when API error has no error body', async () => {
236
+ setupInteractiveAuth();
237
+ const submitResponse = {
238
+ ok: false,
239
+ statusText: 'Bad Gateway',
240
+ json: vi.fn().mockResolvedValue({}),
241
+ };
242
+ authenticatedFetch.mockResolvedValueOnce(submitResponse);
243
+ input
244
+ .mockResolvedValueOnce('Yesterday')
245
+ .mockResolvedValueOnce('Today')
246
+ .mockResolvedValueOnce('');
247
+ await expect(standupCommand({})).rejects.toThrow('process.exit(1)');
248
+ const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
249
+ expect(errorCalls.some((msg) => msg.includes('Bad Gateway'))).toBe(true);
250
+ });
251
+ });
252
+ });
@@ -0,0 +1 @@
1
+ export {};