@urugus/slack-cli 0.2.6 → 0.2.7
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/history.d.ts.map +1 -1
- package/dist/commands/history.js +7 -7
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/send.d.ts.map +1 -1
- package/dist/commands/send.js +5 -18
- package/dist/commands/send.js.map +1 -1
- package/dist/utils/profile-config.d.ts.map +1 -1
- package/dist/utils/profile-config.js +2 -6
- package/dist/utils/profile-config.js.map +1 -1
- package/dist/utils/slack-operations/channel-operations.d.ts +9 -0
- package/dist/utils/slack-operations/channel-operations.d.ts.map +1 -1
- package/dist/utils/slack-operations/channel-operations.js +77 -50
- package/dist/utils/slack-operations/channel-operations.js.map +1 -1
- package/dist/utils/token-utils.d.ts +7 -0
- package/dist/utils/token-utils.d.ts.map +1 -0
- package/dist/utils/token-utils.js +18 -0
- package/dist/utils/token-utils.js.map +1 -0
- package/dist/utils/validators.d.ts +79 -0
- package/dist/utils/validators.d.ts.map +1 -0
- package/dist/utils/validators.js +175 -0
- package/dist/utils/validators.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/history.ts +17 -12
- package/src/commands/send.ts +8 -19
- package/src/utils/profile-config.ts +3 -15
- package/src/utils/slack-operations/channel-operations.ts +91 -54
- package/src/utils/token-utils.ts +17 -0
- package/src/utils/validators.ts +212 -0
- package/tests/utils/option-parsers.test.ts +173 -0
- package/tests/utils/profile-config.test.ts +282 -0
- package/tests/utils/slack-operations/channel-operations-refactored.test.ts +179 -0
- package/tests/utils/token-utils.test.ts +33 -0
- package/tests/utils/validators.test.ts +307 -0
- package/src/utils/profile-config-refactored.ts +0 -161
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { ERROR_MESSAGES } from './constants';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Common validation functions for CLI commands
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ValidationRule<T = unknown> {
|
|
9
|
+
validate: (value: T) => boolean | string;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationOptions {
|
|
14
|
+
required?: boolean;
|
|
15
|
+
rules?: ValidationRule[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates that a value exists (not undefined, null, or empty string)
|
|
20
|
+
*/
|
|
21
|
+
export function validateRequired(value: unknown, fieldName: string): string | null {
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return `${fieldName} is required`;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates mutually exclusive options
|
|
30
|
+
*/
|
|
31
|
+
export function validateMutuallyExclusive(
|
|
32
|
+
options: Record<string, unknown>,
|
|
33
|
+
fields: string[],
|
|
34
|
+
errorMessage?: string
|
|
35
|
+
): string | null {
|
|
36
|
+
const presentFields = fields.filter((field) => options[field] !== undefined);
|
|
37
|
+
if (presentFields.length > 1) {
|
|
38
|
+
return errorMessage || `Cannot use both ${presentFields.join(' and ')}`;
|
|
39
|
+
}
|
|
40
|
+
if (presentFields.length === 0) {
|
|
41
|
+
return errorMessage || `Must specify one of: ${fields.join(', ')}`;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validates string format using regex
|
|
48
|
+
*/
|
|
49
|
+
export function validateFormat(
|
|
50
|
+
value: string,
|
|
51
|
+
pattern: RegExp,
|
|
52
|
+
errorMessage: string
|
|
53
|
+
): string | null {
|
|
54
|
+
if (!pattern.test(value)) {
|
|
55
|
+
return errorMessage;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates numeric range
|
|
62
|
+
*/
|
|
63
|
+
export function validateRange(
|
|
64
|
+
value: number,
|
|
65
|
+
min?: number,
|
|
66
|
+
max?: number,
|
|
67
|
+
fieldName = 'Value'
|
|
68
|
+
): string | null {
|
|
69
|
+
if (min !== undefined && value < min) {
|
|
70
|
+
return `${fieldName} must be at least ${min}`;
|
|
71
|
+
}
|
|
72
|
+
if (max !== undefined && value > max) {
|
|
73
|
+
return `${fieldName} must be at most ${max}`;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validates date format
|
|
80
|
+
*/
|
|
81
|
+
export function validateDateFormat(dateString: string): string | null {
|
|
82
|
+
const date = new Date(dateString);
|
|
83
|
+
if (isNaN(date.getTime())) {
|
|
84
|
+
return 'Invalid date format';
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Common format validators
|
|
91
|
+
*/
|
|
92
|
+
export const formatValidators = {
|
|
93
|
+
/**
|
|
94
|
+
* Validates Slack thread timestamp format (1234567890.123456)
|
|
95
|
+
*/
|
|
96
|
+
threadTimestamp: (value: string): string | null => {
|
|
97
|
+
const pattern = /^\d{10}\.\d{6}$/;
|
|
98
|
+
return validateFormat(value, pattern, ERROR_MESSAGES.INVALID_THREAD_TIMESTAMP);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validates Slack channel ID format (C1234567890, D1234567890, G1234567890)
|
|
103
|
+
*/
|
|
104
|
+
channelId: (value: string): string | null => {
|
|
105
|
+
const pattern = /^[CDG][A-Z0-9]{10,}$/;
|
|
106
|
+
return validateFormat(value, pattern, 'Invalid channel ID format');
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validates output format options
|
|
111
|
+
*/
|
|
112
|
+
outputFormat: (value: string): string | null => {
|
|
113
|
+
const validFormats = ['table', 'simple', 'json', 'compact'];
|
|
114
|
+
if (!validFormats.includes(value)) {
|
|
115
|
+
return `Invalid format. Must be one of: ${validFormats.join(', ')}`;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Creates a preAction hook for command validation
|
|
123
|
+
*/
|
|
124
|
+
export function createValidationHook(
|
|
125
|
+
validations: Array<(options: Record<string, unknown>, command: Command) => string | null>
|
|
126
|
+
): (thisCommand: Command) => void {
|
|
127
|
+
return (thisCommand: Command) => {
|
|
128
|
+
const options = thisCommand.opts();
|
|
129
|
+
|
|
130
|
+
for (const validation of validations) {
|
|
131
|
+
const error = validation(options, thisCommand);
|
|
132
|
+
if (error) {
|
|
133
|
+
thisCommand.error(`Error: ${error}`);
|
|
134
|
+
break; // Stop processing after first error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Common command option validators
|
|
142
|
+
*/
|
|
143
|
+
export const optionValidators = {
|
|
144
|
+
/**
|
|
145
|
+
* Validates message/file options for send command
|
|
146
|
+
*/
|
|
147
|
+
messageOrFile: (options: Record<string, unknown>): string | null => {
|
|
148
|
+
if (!options.message && !options.file) {
|
|
149
|
+
return ERROR_MESSAGES.NO_MESSAGE_OR_FILE;
|
|
150
|
+
}
|
|
151
|
+
if (options.message && options.file) {
|
|
152
|
+
return ERROR_MESSAGES.BOTH_MESSAGE_AND_FILE;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validates thread timestamp if provided
|
|
159
|
+
*/
|
|
160
|
+
threadTimestamp: (options: Record<string, unknown>): string | null => {
|
|
161
|
+
if (options.thread) {
|
|
162
|
+
return formatValidators.threadTimestamp(options.thread as string);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Validates message count for history command
|
|
169
|
+
*/
|
|
170
|
+
messageCount: (options: Record<string, unknown>): string | null => {
|
|
171
|
+
if (options.number) {
|
|
172
|
+
const count = parseInt(options.number as string, 10);
|
|
173
|
+
if (isNaN(count)) {
|
|
174
|
+
return 'Message count must be a number';
|
|
175
|
+
}
|
|
176
|
+
return validateRange(count, 1, 1000, 'Message count');
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validates date format for history command
|
|
183
|
+
*/
|
|
184
|
+
sinceDate: (options: Record<string, unknown>): string | null => {
|
|
185
|
+
if (options.since) {
|
|
186
|
+
const error = validateDateFormat(options.since as string);
|
|
187
|
+
if (error) {
|
|
188
|
+
return 'Invalid date format. Use YYYY-MM-DD HH:MM:SS';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Creates a validated option parser
|
|
197
|
+
*/
|
|
198
|
+
export function createOptionParser<T>(
|
|
199
|
+
parser: (value: string | undefined, defaultValue: T) => T,
|
|
200
|
+
validator?: (value: T) => string | null
|
|
201
|
+
): (value: string | undefined, defaultValue: T) => T {
|
|
202
|
+
return (value: string | undefined, defaultValue: T): T => {
|
|
203
|
+
const parsed = parser(value, defaultValue);
|
|
204
|
+
if (validator) {
|
|
205
|
+
const error = validator(parsed);
|
|
206
|
+
if (error) {
|
|
207
|
+
throw new Error(error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return parsed;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parseFormat,
|
|
4
|
+
parseLimit,
|
|
5
|
+
parseBoolean,
|
|
6
|
+
parseCount,
|
|
7
|
+
parseProfile,
|
|
8
|
+
parseListOptions,
|
|
9
|
+
OPTION_DEFAULTS,
|
|
10
|
+
} from '../../src/utils/option-parsers';
|
|
11
|
+
|
|
12
|
+
describe('option-parsers', () => {
|
|
13
|
+
describe('parseFormat', () => {
|
|
14
|
+
it('should return default format when undefined', () => {
|
|
15
|
+
expect(parseFormat(undefined)).toBe('table');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should return custom default format when specified', () => {
|
|
19
|
+
expect(parseFormat(undefined, 'json')).toBe('json');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return provided format', () => {
|
|
23
|
+
expect(parseFormat('compact')).toBe('compact');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should handle empty string', () => {
|
|
27
|
+
expect(parseFormat('')).toBe('table');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('parseLimit', () => {
|
|
32
|
+
it('should return default limit when undefined', () => {
|
|
33
|
+
expect(parseLimit(undefined, 100)).toBe(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse string limit', () => {
|
|
37
|
+
expect(parseLimit('50', 100)).toBe(50);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should handle invalid number string', () => {
|
|
41
|
+
expect(parseLimit('abc', 100)).toBe(NaN);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle empty string', () => {
|
|
45
|
+
expect(parseLimit('', 100)).toBe(100);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('parseBoolean', () => {
|
|
50
|
+
it('should return default value when undefined', () => {
|
|
51
|
+
expect(parseBoolean(undefined)).toBe(false);
|
|
52
|
+
expect(parseBoolean(undefined, true)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return provided boolean value', () => {
|
|
56
|
+
expect(parseBoolean(true)).toBe(true);
|
|
57
|
+
expect(parseBoolean(false)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should handle explicit false over default true', () => {
|
|
61
|
+
expect(parseBoolean(false, true)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('parseCount', () => {
|
|
66
|
+
it('should return default count when undefined', () => {
|
|
67
|
+
expect(parseCount(undefined, 10)).toBe(10);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should parse string count', () => {
|
|
71
|
+
expect(parseCount('25', 10)).toBe(25);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle invalid number string', () => {
|
|
75
|
+
expect(parseCount('invalid', 10)).toBe(10);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should enforce minimum value', () => {
|
|
79
|
+
expect(parseCount('5', 10, 10, 100)).toBe(10);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should enforce maximum value', () => {
|
|
83
|
+
expect(parseCount('150', 10, 10, 100)).toBe(100);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should allow values within range', () => {
|
|
87
|
+
expect(parseCount('50', 10, 10, 100)).toBe(50);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle empty string', () => {
|
|
91
|
+
expect(parseCount('', 20)).toBe(20);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('parseProfile', () => {
|
|
96
|
+
it('should return undefined when not provided', () => {
|
|
97
|
+
expect(parseProfile(undefined)).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should return provided profile', () => {
|
|
101
|
+
expect(parseProfile('production')).toBe('production');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle empty string', () => {
|
|
105
|
+
expect(parseProfile('')).toBe('');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('parseListOptions', () => {
|
|
110
|
+
it('should use all defaults when no options provided', () => {
|
|
111
|
+
const result = parseListOptions({});
|
|
112
|
+
expect(result).toEqual({
|
|
113
|
+
format: 'table',
|
|
114
|
+
limit: 100,
|
|
115
|
+
countOnly: false,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should override specific options', () => {
|
|
120
|
+
const result = parseListOptions({
|
|
121
|
+
format: 'json',
|
|
122
|
+
limit: '50',
|
|
123
|
+
countOnly: true,
|
|
124
|
+
});
|
|
125
|
+
expect(result).toEqual({
|
|
126
|
+
format: 'json',
|
|
127
|
+
limit: 50,
|
|
128
|
+
countOnly: true,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should use custom defaults', () => {
|
|
133
|
+
const result = parseListOptions({}, {
|
|
134
|
+
format: 'compact',
|
|
135
|
+
limit: 200,
|
|
136
|
+
countOnly: true,
|
|
137
|
+
});
|
|
138
|
+
expect(result).toEqual({
|
|
139
|
+
format: 'compact',
|
|
140
|
+
limit: 200,
|
|
141
|
+
countOnly: true,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should override custom defaults with provided options', () => {
|
|
146
|
+
const result = parseListOptions(
|
|
147
|
+
{
|
|
148
|
+
format: 'json',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
format: 'compact',
|
|
152
|
+
limit: 200,
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
expect(result).toEqual({
|
|
156
|
+
format: 'json',
|
|
157
|
+
limit: 200,
|
|
158
|
+
countOnly: false,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('OPTION_DEFAULTS', () => {
|
|
164
|
+
it('should have expected default values', () => {
|
|
165
|
+
expect(OPTION_DEFAULTS).toEqual({
|
|
166
|
+
format: 'table',
|
|
167
|
+
limit: 100,
|
|
168
|
+
countOnly: false,
|
|
169
|
+
includeArchived: false,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { ProfileConfigManager } from '../../src/utils/profile-config';
|
|
6
|
+
|
|
7
|
+
vi.mock('fs/promises');
|
|
8
|
+
vi.mock('os');
|
|
9
|
+
|
|
10
|
+
describe('ProfileConfigManager', () => {
|
|
11
|
+
let configManager: ProfileConfigManager;
|
|
12
|
+
const mockConfigPath = '/home/user/.slack-cli/config.json';
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetAllMocks();
|
|
16
|
+
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
|
17
|
+
configManager = new ProfileConfigManager();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('setToken', () => {
|
|
21
|
+
it('should set token for default profile when no profile specified', async () => {
|
|
22
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce({ code: 'ENOENT' });
|
|
23
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
24
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
25
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
26
|
+
|
|
27
|
+
await configManager.setToken('test-token');
|
|
28
|
+
|
|
29
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
30
|
+
mockConfigPath,
|
|
31
|
+
expect.stringContaining('"default"'),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should set token for specified profile', async () => {
|
|
36
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce({ code: 'ENOENT' });
|
|
37
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
38
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
39
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
40
|
+
|
|
41
|
+
await configManager.setToken('test-token', 'production');
|
|
42
|
+
|
|
43
|
+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
|
44
|
+
const savedData = JSON.parse(writeCall[1] as string);
|
|
45
|
+
expect(savedData.profiles.production.token).toBe('test-token');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getConfig', () => {
|
|
50
|
+
it('should return null when no config exists', async () => {
|
|
51
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce({ code: 'ENOENT' });
|
|
52
|
+
|
|
53
|
+
const config = await configManager.getConfig();
|
|
54
|
+
|
|
55
|
+
expect(config).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return config for default profile', async () => {
|
|
59
|
+
const mockStore = {
|
|
60
|
+
profiles: {
|
|
61
|
+
default: {
|
|
62
|
+
token: 'test-token',
|
|
63
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
defaultProfile: 'default',
|
|
67
|
+
};
|
|
68
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
69
|
+
|
|
70
|
+
const config = await configManager.getConfig();
|
|
71
|
+
|
|
72
|
+
expect(config).toEqual(mockStore.profiles.default);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return config for specified profile', async () => {
|
|
76
|
+
const mockStore = {
|
|
77
|
+
profiles: {
|
|
78
|
+
production: {
|
|
79
|
+
token: 'prod-token',
|
|
80
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
defaultProfile: 'default',
|
|
84
|
+
};
|
|
85
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
86
|
+
|
|
87
|
+
const config = await configManager.getConfig('production');
|
|
88
|
+
|
|
89
|
+
expect(config).toEqual(mockStore.profiles.production);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('listProfiles', () => {
|
|
94
|
+
it('should return empty array when no profiles exist', async () => {
|
|
95
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce({ code: 'ENOENT' });
|
|
96
|
+
|
|
97
|
+
const profiles = await configManager.listProfiles();
|
|
98
|
+
|
|
99
|
+
expect(profiles).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return all profiles with default flag', async () => {
|
|
103
|
+
const mockStore = {
|
|
104
|
+
profiles: {
|
|
105
|
+
default: {
|
|
106
|
+
token: 'default-token',
|
|
107
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
108
|
+
},
|
|
109
|
+
production: {
|
|
110
|
+
token: 'prod-token',
|
|
111
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
defaultProfile: 'default',
|
|
115
|
+
};
|
|
116
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
117
|
+
|
|
118
|
+
const profiles = await configManager.listProfiles();
|
|
119
|
+
|
|
120
|
+
expect(profiles).toHaveLength(2);
|
|
121
|
+
expect(profiles.find((p) => p.name === 'default')?.isDefault).toBe(true);
|
|
122
|
+
expect(profiles.find((p) => p.name === 'production')?.isDefault).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('useProfile', () => {
|
|
127
|
+
it('should switch to existing profile', async () => {
|
|
128
|
+
const mockStore = {
|
|
129
|
+
profiles: {
|
|
130
|
+
default: { token: 'default-token', updatedAt: '2024-01-01T00:00:00.000Z' },
|
|
131
|
+
production: { token: 'prod-token', updatedAt: '2024-01-02T00:00:00.000Z' },
|
|
132
|
+
},
|
|
133
|
+
defaultProfile: 'default',
|
|
134
|
+
};
|
|
135
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
136
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
137
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
138
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
139
|
+
|
|
140
|
+
await configManager.useProfile('production');
|
|
141
|
+
|
|
142
|
+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
|
143
|
+
const savedData = JSON.parse(writeCall[1] as string);
|
|
144
|
+
expect(savedData.defaultProfile).toBe('production');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should throw error when profile does not exist', async () => {
|
|
148
|
+
const mockStore = {
|
|
149
|
+
profiles: {
|
|
150
|
+
default: { token: 'default-token', updatedAt: '2024-01-01T00:00:00.000Z' },
|
|
151
|
+
},
|
|
152
|
+
defaultProfile: 'default',
|
|
153
|
+
};
|
|
154
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
155
|
+
|
|
156
|
+
await expect(configManager.useProfile('nonexistent')).rejects.toThrow(
|
|
157
|
+
'Profile "nonexistent" does not exist',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('getCurrentProfile', () => {
|
|
163
|
+
it('should return default profile when none set', async () => {
|
|
164
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce({ code: 'ENOENT' });
|
|
165
|
+
|
|
166
|
+
const profile = await configManager.getCurrentProfile();
|
|
167
|
+
|
|
168
|
+
expect(profile).toBe('default');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should return current profile', async () => {
|
|
172
|
+
const mockStore = {
|
|
173
|
+
profiles: {},
|
|
174
|
+
defaultProfile: 'production',
|
|
175
|
+
};
|
|
176
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
177
|
+
|
|
178
|
+
const profile = await configManager.getCurrentProfile();
|
|
179
|
+
|
|
180
|
+
expect(profile).toBe('production');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('clearConfig', () => {
|
|
185
|
+
it('should remove specified profile', async () => {
|
|
186
|
+
const mockStore = {
|
|
187
|
+
profiles: {
|
|
188
|
+
default: { token: 'default-token', updatedAt: '2024-01-01T00:00:00.000Z' },
|
|
189
|
+
production: { token: 'prod-token', updatedAt: '2024-01-02T00:00:00.000Z' },
|
|
190
|
+
},
|
|
191
|
+
defaultProfile: 'default',
|
|
192
|
+
};
|
|
193
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
194
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
195
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
196
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
197
|
+
|
|
198
|
+
await configManager.clearConfig('production');
|
|
199
|
+
|
|
200
|
+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
|
201
|
+
const savedData = JSON.parse(writeCall[1] as string);
|
|
202
|
+
expect(savedData.profiles.production).toBeUndefined();
|
|
203
|
+
expect(savedData.profiles.default).toBeDefined();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should delete config file when last profile removed', async () => {
|
|
207
|
+
const mockStore = {
|
|
208
|
+
profiles: {
|
|
209
|
+
default: { token: 'default-token', updatedAt: '2024-01-01T00:00:00.000Z' },
|
|
210
|
+
},
|
|
211
|
+
defaultProfile: 'default',
|
|
212
|
+
};
|
|
213
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
214
|
+
vi.mocked(fs.unlink).mockResolvedValue();
|
|
215
|
+
|
|
216
|
+
await configManager.clearConfig('default');
|
|
217
|
+
|
|
218
|
+
expect(fs.unlink).toHaveBeenCalledWith(mockConfigPath);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should set new default when current default is removed', async () => {
|
|
222
|
+
const mockStore = {
|
|
223
|
+
profiles: {
|
|
224
|
+
default: { token: 'default-token', updatedAt: '2024-01-01T00:00:00.000Z' },
|
|
225
|
+
production: { token: 'prod-token', updatedAt: '2024-01-02T00:00:00.000Z' },
|
|
226
|
+
},
|
|
227
|
+
defaultProfile: 'default',
|
|
228
|
+
};
|
|
229
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockStore));
|
|
230
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
231
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
232
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
233
|
+
|
|
234
|
+
await configManager.clearConfig('default');
|
|
235
|
+
|
|
236
|
+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
|
237
|
+
const savedData = JSON.parse(writeCall[1] as string);
|
|
238
|
+
expect(savedData.defaultProfile).toBe('production');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('maskToken', () => {
|
|
243
|
+
it('should mask short tokens completely', () => {
|
|
244
|
+
const masked = configManager.maskToken('short');
|
|
245
|
+
expect(masked).toBe('****');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should mask long tokens showing prefix and suffix', () => {
|
|
249
|
+
const token = 'test-1234567890-abcdefghijklmnop';
|
|
250
|
+
const masked = configManager.maskToken(token);
|
|
251
|
+
expect(masked).toBe('test-****-****-mnop');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('migration', () => {
|
|
256
|
+
it('should migrate old format to new format', async () => {
|
|
257
|
+
const oldConfig = {
|
|
258
|
+
token: 'old-token',
|
|
259
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
260
|
+
};
|
|
261
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(oldConfig));
|
|
262
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
263
|
+
vi.mocked(fs.writeFile).mockResolvedValue();
|
|
264
|
+
vi.mocked(fs.chmod).mockResolvedValue();
|
|
265
|
+
|
|
266
|
+
const config = await configManager.getConfig();
|
|
267
|
+
|
|
268
|
+
expect(config).toEqual(oldConfig);
|
|
269
|
+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
|
270
|
+
const savedData = JSON.parse(writeCall[1] as string);
|
|
271
|
+
expect(savedData.profiles.default).toEqual(oldConfig);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('error handling', () => {
|
|
276
|
+
it('should throw error for invalid JSON', async () => {
|
|
277
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce('invalid json');
|
|
278
|
+
|
|
279
|
+
await expect(configManager.getConfig()).rejects.toThrow('Invalid config file format');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|