@meltstudio/meltctl 4.28.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.
- package/dist/commands/audit.test.d.ts +1 -0
- package/dist/commands/audit.test.js +250 -0
- package/dist/commands/coins.test.d.ts +1 -0
- package/dist/commands/coins.test.js +133 -0
- package/dist/commands/feedback.test.d.ts +1 -0
- package/dist/commands/feedback.test.js +242 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +476 -0
- package/dist/commands/login.test.d.ts +1 -0
- package/dist/commands/login.test.js +174 -0
- package/dist/commands/logout.test.d.ts +1 -0
- package/dist/commands/logout.test.js +59 -0
- package/dist/commands/plan.test.d.ts +1 -0
- package/dist/commands/plan.test.js +283 -0
- package/dist/commands/standup.test.d.ts +1 -0
- package/dist/commands/standup.test.js +252 -0
- package/dist/commands/templates.test.d.ts +1 -0
- package/dist/commands/templates.test.js +89 -0
- package/dist/commands/version.test.d.ts +1 -0
- package/dist/commands/version.test.js +86 -0
- package/dist/utils/api.test.d.ts +1 -0
- package/dist/utils/api.test.js +96 -0
- package/dist/utils/auth.test.d.ts +1 -0
- package/dist/utils/auth.test.js +165 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +34 -0
- package/dist/utils/git.test.d.ts +1 -0
- package/dist/utils/git.test.js +184 -0
- package/dist/utils/package-manager.test.d.ts +1 -0
- package/dist/utils/package-manager.test.js +76 -0
- package/dist/utils/templates.test.d.ts +1 -0
- package/dist/utils/templates.test.js +50 -0
- package/dist/utils/version-check.test.d.ts +1 -0
- package/dist/utils/version-check.test.js +135 -0
- package/package.json +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
findMdFiles: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
import fs from 'fs-extra';
|
|
20
|
+
import { getToken, tokenFetch } from '../utils/api.js';
|
|
21
|
+
import { getGitBranch, getGitCommit, getGitRepository, getProjectName, findMdFiles, } from '../utils/git.js';
|
|
22
|
+
import { auditSubmitCommand, auditListCommand } from './audit.js';
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
26
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
27
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
28
|
+
throw new Error(`process.exit(${code})`);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('auditSubmitCommand', () => {
|
|
32
|
+
function setupGitMocks() {
|
|
33
|
+
;
|
|
34
|
+
getToken.mockResolvedValue('test-token');
|
|
35
|
+
getGitBranch.mockReturnValue('main');
|
|
36
|
+
getGitCommit.mockReturnValue('abc1234');
|
|
37
|
+
getGitRepository.mockReturnValue({
|
|
38
|
+
slug: 'Org/Repo',
|
|
39
|
+
url: 'https://github.com/Org/Repo.git',
|
|
40
|
+
});
|
|
41
|
+
getProjectName.mockReturnValue('test-project');
|
|
42
|
+
}
|
|
43
|
+
it('submits audit with type "security-audit" when filename contains security-audit', async () => {
|
|
44
|
+
setupGitMocks();
|
|
45
|
+
fs.pathExists.mockResolvedValue(true);
|
|
46
|
+
fs.readFile.mockResolvedValue('# Security Audit\nFindings here.');
|
|
47
|
+
const mockResponse = {
|
|
48
|
+
ok: true,
|
|
49
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-123' }),
|
|
50
|
+
};
|
|
51
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
52
|
+
await auditSubmitCommand('2026-03-26-security-audit.md');
|
|
53
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', '/audits', expect.objectContaining({
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: expect.stringContaining('"type":"security-audit"'),
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
it('submits audit with type "ux-audit" when filename contains ux-audit', async () => {
|
|
59
|
+
setupGitMocks();
|
|
60
|
+
fs.pathExists.mockResolvedValue(true);
|
|
61
|
+
fs.readFile.mockResolvedValue('# UX Audit');
|
|
62
|
+
const mockResponse = {
|
|
63
|
+
ok: true,
|
|
64
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-456' }),
|
|
65
|
+
};
|
|
66
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
67
|
+
await auditSubmitCommand('UX-AUDIT.md');
|
|
68
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', '/audits', expect.objectContaining({
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: expect.stringContaining('"type":"ux-audit"'),
|
|
71
|
+
}));
|
|
72
|
+
});
|
|
73
|
+
it('submits audit with type "audit" for generic audit filenames', async () => {
|
|
74
|
+
setupGitMocks();
|
|
75
|
+
fs.pathExists.mockResolvedValue(true);
|
|
76
|
+
fs.readFile.mockResolvedValue('# Audit');
|
|
77
|
+
const mockResponse = {
|
|
78
|
+
ok: true,
|
|
79
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-789' }),
|
|
80
|
+
};
|
|
81
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
82
|
+
await auditSubmitCommand('AUDIT.md');
|
|
83
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', '/audits', expect.objectContaining({
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: expect.stringContaining('"type":"audit"'),
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it('submits audit with type "audit" for random filenames', async () => {
|
|
89
|
+
setupGitMocks();
|
|
90
|
+
fs.pathExists.mockResolvedValue(true);
|
|
91
|
+
fs.readFile.mockResolvedValue('# Random');
|
|
92
|
+
const mockResponse = {
|
|
93
|
+
ok: true,
|
|
94
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-000' }),
|
|
95
|
+
};
|
|
96
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
97
|
+
await auditSubmitCommand('random-file.md');
|
|
98
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', '/audits', expect.objectContaining({
|
|
99
|
+
method: 'POST',
|
|
100
|
+
body: expect.stringContaining('"type":"audit"'),
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
it('exits with error when file not found', async () => {
|
|
104
|
+
setupGitMocks();
|
|
105
|
+
fs.pathExists.mockResolvedValue(false);
|
|
106
|
+
await expect(auditSubmitCommand('nonexistent.md')).rejects.toThrow('process.exit(1)');
|
|
107
|
+
expect(console.error).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
it('sends correct payload fields', async () => {
|
|
110
|
+
setupGitMocks();
|
|
111
|
+
vi.mocked(fs.pathExists).mockResolvedValue(true);
|
|
112
|
+
vi.mocked(fs.readFile).mockResolvedValue('audit content here');
|
|
113
|
+
const mockResponse = {
|
|
114
|
+
ok: true,
|
|
115
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-100' }),
|
|
116
|
+
};
|
|
117
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
118
|
+
await auditSubmitCommand('AUDIT.md');
|
|
119
|
+
const call = tokenFetch.mock.calls[0];
|
|
120
|
+
const body = JSON.parse(call[2].body);
|
|
121
|
+
expect(body).toEqual({
|
|
122
|
+
type: 'audit',
|
|
123
|
+
project: 'test-project',
|
|
124
|
+
repository: 'Org/Repo',
|
|
125
|
+
repositoryUrl: 'https://github.com/Org/Repo.git',
|
|
126
|
+
branch: 'main',
|
|
127
|
+
commit: 'abc1234',
|
|
128
|
+
content: 'audit content here',
|
|
129
|
+
metadata: { filename: 'AUDIT.md' },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('exits with error when API returns failure', async () => {
|
|
133
|
+
setupGitMocks();
|
|
134
|
+
fs.pathExists.mockResolvedValue(true);
|
|
135
|
+
fs.readFile.mockResolvedValue('content');
|
|
136
|
+
const mockResponse = {
|
|
137
|
+
ok: false,
|
|
138
|
+
statusText: 'Bad Request',
|
|
139
|
+
json: vi.fn().mockResolvedValue({ error: 'Invalid content' }),
|
|
140
|
+
};
|
|
141
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
142
|
+
await expect(auditSubmitCommand('AUDIT.md')).rejects.toThrow('process.exit(1)');
|
|
143
|
+
});
|
|
144
|
+
it('auto-detects audit file from .audits/ directory when no file provided', async () => {
|
|
145
|
+
setupGitMocks();
|
|
146
|
+
findMdFiles.mockResolvedValue(['/project/.audits/2026-03-26-security-audit.md']);
|
|
147
|
+
fs.pathExists.mockResolvedValue(true);
|
|
148
|
+
fs.readFile.mockResolvedValue('auto-detected content');
|
|
149
|
+
const mockResponse = {
|
|
150
|
+
ok: true,
|
|
151
|
+
json: vi.fn().mockResolvedValue({ id: 'audit-auto' }),
|
|
152
|
+
};
|
|
153
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
154
|
+
await auditSubmitCommand();
|
|
155
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', '/audits', expect.objectContaining({
|
|
156
|
+
method: 'POST',
|
|
157
|
+
body: expect.stringContaining('"type":"security-audit"'),
|
|
158
|
+
}));
|
|
159
|
+
});
|
|
160
|
+
it('exits with error when no file provided and none auto-detected', async () => {
|
|
161
|
+
;
|
|
162
|
+
getToken.mockResolvedValue('test-token');
|
|
163
|
+
findMdFiles.mockResolvedValue([]);
|
|
164
|
+
fs.pathExists.mockResolvedValue(false);
|
|
165
|
+
await expect(auditSubmitCommand()).rejects.toThrow('process.exit(1)');
|
|
166
|
+
expect(console.error).toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('auditListCommand', () => {
|
|
170
|
+
it('calls API with correct query params', async () => {
|
|
171
|
+
;
|
|
172
|
+
getToken.mockResolvedValue('test-token');
|
|
173
|
+
const mockResponse = {
|
|
174
|
+
ok: true,
|
|
175
|
+
status: 200,
|
|
176
|
+
json: vi.fn().mockResolvedValue({ audits: [], count: 0 }),
|
|
177
|
+
};
|
|
178
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
179
|
+
await auditListCommand({ type: 'ux-audit', repository: 'Org/Repo', limit: '5' });
|
|
180
|
+
expect(tokenFetch).toHaveBeenCalledWith('test-token', expect.stringContaining('/audits?'));
|
|
181
|
+
const url = tokenFetch.mock.calls[0][1];
|
|
182
|
+
expect(url).toContain('type=ux-audit');
|
|
183
|
+
expect(url).toContain('repository=Org%2FRepo');
|
|
184
|
+
expect(url).toContain('limit=5');
|
|
185
|
+
});
|
|
186
|
+
it('passes latest=true query param when option set', async () => {
|
|
187
|
+
;
|
|
188
|
+
getToken.mockResolvedValue('test-token');
|
|
189
|
+
const mockResponse = {
|
|
190
|
+
ok: true,
|
|
191
|
+
status: 200,
|
|
192
|
+
json: vi.fn().mockResolvedValue({ audits: [], count: 0 }),
|
|
193
|
+
};
|
|
194
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
195
|
+
await auditListCommand({ latest: true });
|
|
196
|
+
const url = tokenFetch.mock.calls[0][1];
|
|
197
|
+
expect(url).toContain('latest=true');
|
|
198
|
+
});
|
|
199
|
+
it('exits with error on 403 response', async () => {
|
|
200
|
+
;
|
|
201
|
+
getToken.mockResolvedValue('test-token');
|
|
202
|
+
const mockResponse = {
|
|
203
|
+
ok: false,
|
|
204
|
+
status: 403,
|
|
205
|
+
statusText: 'Forbidden',
|
|
206
|
+
};
|
|
207
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
208
|
+
await expect(auditListCommand({})).rejects.toThrow('process.exit(1)');
|
|
209
|
+
expect(console.error).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
it('displays audit list when audits exist', async () => {
|
|
212
|
+
;
|
|
213
|
+
getToken.mockResolvedValue('test-token');
|
|
214
|
+
const mockResponse = {
|
|
215
|
+
ok: true,
|
|
216
|
+
status: 200,
|
|
217
|
+
json: vi.fn().mockResolvedValue({
|
|
218
|
+
audits: [
|
|
219
|
+
{
|
|
220
|
+
id: '1',
|
|
221
|
+
type: 'audit',
|
|
222
|
+
project: 'my-project',
|
|
223
|
+
repository: 'Org/Repo',
|
|
224
|
+
author: 'dev@meltstudio.co',
|
|
225
|
+
branch: 'main',
|
|
226
|
+
commit: 'abc1234',
|
|
227
|
+
createdAt: '2026-03-25T10:00:00Z',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
count: 1,
|
|
231
|
+
}),
|
|
232
|
+
};
|
|
233
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
234
|
+
await auditListCommand({});
|
|
235
|
+
expect(console.log).toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
it('shows "No audits found" when list is empty', async () => {
|
|
238
|
+
;
|
|
239
|
+
getToken.mockResolvedValue('test-token');
|
|
240
|
+
const mockResponse = {
|
|
241
|
+
ok: true,
|
|
242
|
+
status: 200,
|
|
243
|
+
json: vi.fn().mockResolvedValue({ audits: [], count: 0 }),
|
|
244
|
+
};
|
|
245
|
+
tokenFetch.mockResolvedValue(mockResponse);
|
|
246
|
+
await auditListCommand({});
|
|
247
|
+
const errorCalls = console.log.mock.calls.map((c) => c[0]);
|
|
248
|
+
expect(errorCalls.some((msg) => typeof msg === 'string' && msg.includes('No audits found'))).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
import { isAuthenticated, authenticatedFetch } from '../utils/auth.js';
|
|
7
|
+
import { coinsCommand } from './coins.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('coinsCommand', () => {
|
|
17
|
+
describe('balance (default)', () => {
|
|
18
|
+
it('exits with error when not authenticated', async () => {
|
|
19
|
+
;
|
|
20
|
+
isAuthenticated.mockResolvedValue(false);
|
|
21
|
+
await expect(coinsCommand({})).rejects.toThrow('process.exit(1)');
|
|
22
|
+
expect(console.error).toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
it('displays coin balance on success', async () => {
|
|
25
|
+
;
|
|
26
|
+
isAuthenticated.mockResolvedValue(true);
|
|
27
|
+
const mockResponse = {
|
|
28
|
+
ok: true,
|
|
29
|
+
json: vi.fn().mockResolvedValue({ coins: 5, period: '28d' }),
|
|
30
|
+
};
|
|
31
|
+
authenticatedFetch.mockResolvedValue(mockResponse);
|
|
32
|
+
await coinsCommand({});
|
|
33
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/coins');
|
|
34
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
35
|
+
expect(logCalls.some((msg) => msg.includes('5'))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('displays singular coin text for 1 coin', async () => {
|
|
38
|
+
;
|
|
39
|
+
isAuthenticated.mockResolvedValue(true);
|
|
40
|
+
authenticatedFetch.mockResolvedValue({
|
|
41
|
+
ok: true,
|
|
42
|
+
json: vi.fn().mockResolvedValue({ coins: 1, period: '28d' }),
|
|
43
|
+
});
|
|
44
|
+
await coinsCommand({});
|
|
45
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
46
|
+
expect(logCalls.some((msg) => msg.includes('coin') && !msg.includes('coins'))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('displays plural coin text for multiple coins', async () => {
|
|
49
|
+
;
|
|
50
|
+
isAuthenticated.mockResolvedValue(true);
|
|
51
|
+
authenticatedFetch.mockResolvedValue({
|
|
52
|
+
ok: true,
|
|
53
|
+
json: vi.fn().mockResolvedValue({ coins: 3, period: '28d' }),
|
|
54
|
+
});
|
|
55
|
+
await coinsCommand({});
|
|
56
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
57
|
+
expect(logCalls.some((msg) => msg.includes('coins'))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it('exits with error when API returns failure', async () => {
|
|
60
|
+
;
|
|
61
|
+
isAuthenticated.mockResolvedValue(true);
|
|
62
|
+
const mockResponse = {
|
|
63
|
+
ok: false,
|
|
64
|
+
statusText: 'Internal Server Error',
|
|
65
|
+
json: vi.fn().mockResolvedValue({ error: 'Something went wrong' }),
|
|
66
|
+
};
|
|
67
|
+
authenticatedFetch.mockResolvedValue(mockResponse);
|
|
68
|
+
await expect(coinsCommand({})).rejects.toThrow('process.exit(1)');
|
|
69
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
70
|
+
expect(errorCalls.some((msg) => msg.includes('Something went wrong'))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('leaderboard', () => {
|
|
74
|
+
it('exits with error when not authenticated', async () => {
|
|
75
|
+
;
|
|
76
|
+
isAuthenticated.mockResolvedValue(false);
|
|
77
|
+
await expect(coinsCommand({ leaderboard: true })).rejects.toThrow('process.exit(1)');
|
|
78
|
+
});
|
|
79
|
+
it('displays leaderboard entries on success', async () => {
|
|
80
|
+
;
|
|
81
|
+
isAuthenticated.mockResolvedValue(true);
|
|
82
|
+
const entries = [
|
|
83
|
+
{ name: 'Alice', coins: 10 },
|
|
84
|
+
{ name: 'Bob', coins: 7 },
|
|
85
|
+
{ name: 'Charlie', coins: 3 },
|
|
86
|
+
];
|
|
87
|
+
authenticatedFetch.mockResolvedValue({
|
|
88
|
+
ok: true,
|
|
89
|
+
json: vi.fn().mockResolvedValue(entries),
|
|
90
|
+
});
|
|
91
|
+
await coinsCommand({ leaderboard: true });
|
|
92
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/coins/leaderboard');
|
|
93
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
94
|
+
expect(logCalls.some((msg) => msg.includes('Leaderboard'))).toBe(true);
|
|
95
|
+
expect(logCalls.some((msg) => msg.includes('Alice'))).toBe(true);
|
|
96
|
+
expect(logCalls.some((msg) => msg.includes('Bob'))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('shows message when no coins have been sent', async () => {
|
|
99
|
+
;
|
|
100
|
+
isAuthenticated.mockResolvedValue(true);
|
|
101
|
+
authenticatedFetch.mockResolvedValue({
|
|
102
|
+
ok: true,
|
|
103
|
+
json: vi.fn().mockResolvedValue([]),
|
|
104
|
+
});
|
|
105
|
+
await coinsCommand({ leaderboard: true });
|
|
106
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
107
|
+
expect(logCalls.some((msg) => msg.includes('No coins'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('exits with error when leaderboard API fails', async () => {
|
|
110
|
+
;
|
|
111
|
+
isAuthenticated.mockResolvedValue(true);
|
|
112
|
+
authenticatedFetch.mockResolvedValue({
|
|
113
|
+
ok: false,
|
|
114
|
+
statusText: 'Forbidden',
|
|
115
|
+
json: vi.fn().mockResolvedValue({ error: 'Access denied' }),
|
|
116
|
+
});
|
|
117
|
+
await expect(coinsCommand({ leaderboard: true })).rejects.toThrow('process.exit(1)');
|
|
118
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
119
|
+
expect(errorCalls.some((msg) => msg.includes('Access denied'))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('calls /coins/leaderboard endpoint not /coins', async () => {
|
|
122
|
+
;
|
|
123
|
+
isAuthenticated.mockResolvedValue(true);
|
|
124
|
+
authenticatedFetch.mockResolvedValue({
|
|
125
|
+
ok: true,
|
|
126
|
+
json: vi.fn().mockResolvedValue([{ name: 'Test', coins: 1 }]),
|
|
127
|
+
});
|
|
128
|
+
await coinsCommand({ leaderboard: true });
|
|
129
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/coins/leaderboard');
|
|
130
|
+
expect(authenticatedFetch).not.toHaveBeenCalledWith('/coins');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
select: vi.fn(),
|
|
9
|
+
editor: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
import { isAuthenticated, authenticatedFetch } from '../utils/auth.js';
|
|
12
|
+
import { input, select } from '@inquirer/prompts';
|
|
13
|
+
import { feedbackCommand } from './feedback.js';
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
17
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
18
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
19
|
+
throw new Error(`process.exit(${code})`);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('feedbackCommand', () => {
|
|
23
|
+
it('exits with error when not authenticated', async () => {
|
|
24
|
+
;
|
|
25
|
+
isAuthenticated.mockResolvedValue(false);
|
|
26
|
+
await expect(feedbackCommand({})).rejects.toThrow('process.exit(1)');
|
|
27
|
+
expect(console.error).toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
it('submits feedback with correct payload using option flags', async () => {
|
|
30
|
+
;
|
|
31
|
+
isAuthenticated.mockResolvedValue(true);
|
|
32
|
+
const submitResponse = {
|
|
33
|
+
ok: true,
|
|
34
|
+
json: vi.fn().mockResolvedValue({}),
|
|
35
|
+
};
|
|
36
|
+
authenticatedFetch.mockResolvedValue(submitResponse);
|
|
37
|
+
await feedbackCommand({
|
|
38
|
+
to: '42',
|
|
39
|
+
coins: '2',
|
|
40
|
+
description: 'Great job on the feature implementation!',
|
|
41
|
+
});
|
|
42
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/feedback', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
toUserId: 42,
|
|
47
|
+
coins: 2,
|
|
48
|
+
description: 'Great job on the feature implementation!',
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
52
|
+
expect(logCalls.some((msg) => msg.includes('Feedback sent'))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('displays singular coin text when sending 1 coin', async () => {
|
|
55
|
+
;
|
|
56
|
+
isAuthenticated.mockResolvedValue(true);
|
|
57
|
+
authenticatedFetch.mockResolvedValue({
|
|
58
|
+
ok: true,
|
|
59
|
+
json: vi.fn().mockResolvedValue({}),
|
|
60
|
+
});
|
|
61
|
+
await feedbackCommand({ to: '1', coins: '1', description: 'Nice work' });
|
|
62
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
63
|
+
expect(logCalls.some((msg) => msg.includes('1 coin') && !msg.includes('1 coins'))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('displays plural coin text when sending multiple coins', async () => {
|
|
66
|
+
;
|
|
67
|
+
isAuthenticated.mockResolvedValue(true);
|
|
68
|
+
authenticatedFetch.mockResolvedValue({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: vi.fn().mockResolvedValue({}),
|
|
71
|
+
});
|
|
72
|
+
await feedbackCommand({ to: '1', coins: '3', description: 'Excellent work' });
|
|
73
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
74
|
+
expect(logCalls.some((msg) => msg.includes('3 coins'))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('exits with error when API returns failure', async () => {
|
|
77
|
+
;
|
|
78
|
+
isAuthenticated.mockResolvedValue(true);
|
|
79
|
+
const submitResponse = {
|
|
80
|
+
ok: false,
|
|
81
|
+
statusText: 'Bad Request',
|
|
82
|
+
json: vi.fn().mockResolvedValue({ error: 'Insufficient coins' }),
|
|
83
|
+
};
|
|
84
|
+
authenticatedFetch.mockResolvedValue(submitResponse);
|
|
85
|
+
await expect(feedbackCommand({ to: '42', coins: '2', description: 'Good work' })).rejects.toThrow('process.exit(1)');
|
|
86
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
87
|
+
expect(errorCalls.some((msg) => msg.includes('Insufficient coins'))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
it('exits with error when API returns failure without error body', async () => {
|
|
90
|
+
;
|
|
91
|
+
isAuthenticated.mockResolvedValue(true);
|
|
92
|
+
const submitResponse = {
|
|
93
|
+
ok: false,
|
|
94
|
+
statusText: 'Internal Server Error',
|
|
95
|
+
json: vi.fn().mockResolvedValue({}),
|
|
96
|
+
};
|
|
97
|
+
authenticatedFetch.mockResolvedValue(submitResponse);
|
|
98
|
+
await expect(feedbackCommand({ to: '42', coins: '2', description: 'Good work' })).rejects.toThrow('process.exit(1)');
|
|
99
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
100
|
+
expect(errorCalls.some((msg) => msg.includes('Internal Server Error'))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
it('sends correct numeric types in payload', async () => {
|
|
103
|
+
;
|
|
104
|
+
isAuthenticated.mockResolvedValue(true);
|
|
105
|
+
authenticatedFetch.mockResolvedValue({
|
|
106
|
+
ok: true,
|
|
107
|
+
json: vi.fn().mockResolvedValue({}),
|
|
108
|
+
});
|
|
109
|
+
await feedbackCommand({ to: '7', coins: '3', description: 'Awesome' });
|
|
110
|
+
const call = authenticatedFetch.mock.calls[0];
|
|
111
|
+
const body = JSON.parse(call[1].body);
|
|
112
|
+
expect(body).toEqual({
|
|
113
|
+
toUserId: 7,
|
|
114
|
+
coins: 3,
|
|
115
|
+
description: 'Awesome',
|
|
116
|
+
});
|
|
117
|
+
expect(typeof body.toUserId).toBe('number');
|
|
118
|
+
expect(typeof body.coins).toBe('number');
|
|
119
|
+
});
|
|
120
|
+
describe('interactive flow', () => {
|
|
121
|
+
const mockRecipients = [
|
|
122
|
+
{ id: 1, firstName: 'Alice', lastName: 'Smith', email: 'alice@meltstudio.co' },
|
|
123
|
+
{ id: 2, firstName: 'Bob', lastName: 'Jones', email: 'bob@meltstudio.co' },
|
|
124
|
+
];
|
|
125
|
+
function setupInteractiveMocks() {
|
|
126
|
+
;
|
|
127
|
+
isAuthenticated.mockResolvedValue(true);
|
|
128
|
+
}
|
|
129
|
+
it('prompts for recipient, coins, and description in interactive mode', async () => {
|
|
130
|
+
setupInteractiveMocks();
|
|
131
|
+
const recipientsResponse = {
|
|
132
|
+
ok: true,
|
|
133
|
+
json: vi.fn().mockResolvedValue(mockRecipients),
|
|
134
|
+
};
|
|
135
|
+
const submitResponse = {
|
|
136
|
+
ok: true,
|
|
137
|
+
json: vi.fn().mockResolvedValue({}),
|
|
138
|
+
};
|
|
139
|
+
authenticatedFetch
|
|
140
|
+
.mockResolvedValueOnce(recipientsResponse)
|
|
141
|
+
.mockResolvedValueOnce(submitResponse);
|
|
142
|
+
select.mockResolvedValueOnce(1).mockResolvedValueOnce(2);
|
|
143
|
+
input.mockResolvedValueOnce('This is a great piece of feedback that is long enough to pass validation easily.');
|
|
144
|
+
await feedbackCommand({});
|
|
145
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/feedback/recipients');
|
|
146
|
+
expect(select).toHaveBeenCalledTimes(2);
|
|
147
|
+
expect(input).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(authenticatedFetch).toHaveBeenCalledWith('/feedback', expect.objectContaining({
|
|
149
|
+
method: 'POST',
|
|
150
|
+
body: expect.stringContaining('"toUserId":1'),
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
it('shows no recipients message when list is empty', async () => {
|
|
154
|
+
setupInteractiveMocks();
|
|
155
|
+
const recipientsResponse = {
|
|
156
|
+
ok: true,
|
|
157
|
+
json: vi.fn().mockResolvedValue([]),
|
|
158
|
+
};
|
|
159
|
+
authenticatedFetch.mockResolvedValueOnce(recipientsResponse);
|
|
160
|
+
await feedbackCommand({});
|
|
161
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
162
|
+
expect(logCalls.some((msg) => msg.includes('No recipients'))).toBe(true);
|
|
163
|
+
// Should not call POST /feedback
|
|
164
|
+
expect(authenticatedFetch).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
it('exits with error when recipients API fails', async () => {
|
|
167
|
+
setupInteractiveMocks();
|
|
168
|
+
const recipientsResponse = {
|
|
169
|
+
ok: false,
|
|
170
|
+
statusText: 'Forbidden',
|
|
171
|
+
json: vi.fn().mockResolvedValue({ error: 'Not allowed' }),
|
|
172
|
+
};
|
|
173
|
+
authenticatedFetch.mockResolvedValueOnce(recipientsResponse);
|
|
174
|
+
await expect(feedbackCommand({})).rejects.toThrow('process.exit(1)');
|
|
175
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
176
|
+
expect(errorCalls.some((msg) => msg.includes('Not allowed'))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it('exits with error when recipients fetch throws network error', async () => {
|
|
179
|
+
setupInteractiveMocks();
|
|
180
|
+
authenticatedFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
181
|
+
await expect(feedbackCommand({})).rejects.toThrow('process.exit(1)');
|
|
182
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
183
|
+
expect(errorCalls.some((msg) => msg.includes('Failed to connect'))).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
it('re-prompts when description is too short', async () => {
|
|
186
|
+
setupInteractiveMocks();
|
|
187
|
+
const recipientsResponse = {
|
|
188
|
+
ok: true,
|
|
189
|
+
json: vi.fn().mockResolvedValue(mockRecipients),
|
|
190
|
+
};
|
|
191
|
+
const submitResponse = {
|
|
192
|
+
ok: true,
|
|
193
|
+
json: vi.fn().mockResolvedValue({}),
|
|
194
|
+
};
|
|
195
|
+
authenticatedFetch
|
|
196
|
+
.mockResolvedValueOnce(recipientsResponse)
|
|
197
|
+
.mockResolvedValueOnce(submitResponse);
|
|
198
|
+
select.mockResolvedValueOnce(2).mockResolvedValueOnce(3);
|
|
199
|
+
input
|
|
200
|
+
.mockResolvedValueOnce('Too short')
|
|
201
|
+
.mockResolvedValueOnce('This description is now long enough to pass the fifty character minimum validation check.');
|
|
202
|
+
await feedbackCommand({});
|
|
203
|
+
expect(input).toHaveBeenCalledTimes(2);
|
|
204
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
205
|
+
expect(logCalls.some((msg) => msg.includes('Too short'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
it('re-prompts when description is too long', async () => {
|
|
208
|
+
setupInteractiveMocks();
|
|
209
|
+
const recipientsResponse = {
|
|
210
|
+
ok: true,
|
|
211
|
+
json: vi.fn().mockResolvedValue(mockRecipients),
|
|
212
|
+
};
|
|
213
|
+
const submitResponse = {
|
|
214
|
+
ok: true,
|
|
215
|
+
json: vi.fn().mockResolvedValue({}),
|
|
216
|
+
};
|
|
217
|
+
authenticatedFetch
|
|
218
|
+
.mockResolvedValueOnce(recipientsResponse)
|
|
219
|
+
.mockResolvedValueOnce(submitResponse);
|
|
220
|
+
select.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
|
|
221
|
+
input
|
|
222
|
+
.mockResolvedValueOnce('x'.repeat(501))
|
|
223
|
+
.mockResolvedValueOnce('This description is now exactly the right length to pass all validation checks successfully.');
|
|
224
|
+
await feedbackCommand({});
|
|
225
|
+
expect(input).toHaveBeenCalledTimes(2);
|
|
226
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0]));
|
|
227
|
+
expect(logCalls.some((msg) => msg.includes('Too long'))).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
it('falls back to recipients error statusText when no error body', async () => {
|
|
230
|
+
setupInteractiveMocks();
|
|
231
|
+
const recipientsResponse = {
|
|
232
|
+
ok: false,
|
|
233
|
+
statusText: 'Service Unavailable',
|
|
234
|
+
json: vi.fn().mockResolvedValue({}),
|
|
235
|
+
};
|
|
236
|
+
authenticatedFetch.mockResolvedValueOnce(recipientsResponse);
|
|
237
|
+
await expect(feedbackCommand({})).rejects.toThrow('process.exit(1)');
|
|
238
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
239
|
+
expect(errorCalls.some((msg) => msg.includes('Service Unavailable'))).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|