shawnxixi-cli 0.3.0 → 1.1.0
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/.env.example +8 -2
- package/.github/workflows/ci.yml +9 -22
- package/.github/workflows/release.yml +30 -0
- package/CLAUDE.md +167 -63
- package/README.md +345 -65
- package/commitlint.config.js +10 -0
- package/dist/commands/biz/calendar.d.ts +17 -2
- package/dist/commands/biz/calendar.d.ts.map +1 -1
- package/dist/commands/biz/calendar.js +47 -9
- package/dist/commands/biz/calendar.js.map +1 -1
- package/dist/commands/biz/finance.d.ts +32 -1
- package/dist/commands/biz/finance.d.ts.map +1 -1
- package/dist/commands/biz/finance.js +99 -12
- package/dist/commands/biz/finance.js.map +1 -1
- package/dist/commands/biz/health.d.ts +32 -0
- package/dist/commands/biz/health.d.ts.map +1 -0
- package/dist/commands/biz/health.js +92 -0
- package/dist/commands/biz/health.js.map +1 -0
- package/dist/commands/biz/item.d.ts +41 -0
- package/dist/commands/biz/item.d.ts.map +1 -1
- package/dist/commands/biz/item.js +89 -5
- package/dist/commands/biz/item.js.map +1 -1
- package/dist/commands/biz/record.d.ts +0 -8
- package/dist/commands/biz/record.d.ts.map +1 -1
- package/dist/commands/biz/record.js +29 -8
- package/dist/commands/biz/record.js.map +1 -1
- package/dist/commands/biz/task.d.ts +38 -0
- package/dist/commands/biz/task.d.ts.map +1 -0
- package/dist/commands/biz/task.js +110 -0
- package/dist/commands/biz/task.js.map +1 -0
- package/dist/commands/biz/todo.d.ts +52 -8
- package/dist/commands/biz/todo.d.ts.map +1 -1
- package/dist/commands/biz/todo.js +167 -17
- package/dist/commands/biz/todo.js.map +1 -1
- package/dist/commands/sys/health.d.ts.map +1 -1
- package/dist/commands/sys/health.js +20 -3
- package/dist/commands/sys/health.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +281 -42
- package/dist/index.js.map +1 -1
- package/dist/services/api.d.ts +22 -8
- package/dist/services/api.d.ts.map +1 -1
- package/dist/services/api.js +108 -28
- package/dist/services/api.js.map +1 -1
- package/dist/utils/env.d.ts +16 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +78 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/finance.model.d.ts +80 -0
- package/dist/utils/finance.model.d.ts.map +1 -0
- package/dist/utils/finance.model.js +150 -0
- package/dist/utils/finance.model.js.map +1 -0
- package/dist/utils/output.d.ts +42 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +124 -0
- package/dist/utils/output.js.map +1 -0
- package/dist/utils/todo.model.d.ts +55 -0
- package/dist/utils/todo.model.d.ts.map +1 -0
- package/dist/utils/todo.model.js +135 -0
- package/dist/utils/todo.model.js.map +1 -0
- package/package.json +18 -2
- package/src/commands/biz/calendar.ts +61 -10
- package/src/commands/biz/finance.ts +112 -13
- package/src/commands/biz/health.ts +96 -0
- package/src/commands/biz/item.ts +113 -6
- package/src/commands/biz/record.ts +32 -8
- package/src/commands/biz/task.ts +115 -0
- package/src/commands/biz/todo.ts +193 -21
- package/src/commands/sys/health.ts +23 -3
- package/src/index.ts +311 -54
- package/src/services/api.ts +111 -30
- package/src/utils/env.ts +85 -0
- package/src/utils/finance.model.ts +182 -0
- package/src/utils/output.ts +126 -0
- package/src/utils/todo.model.ts +167 -0
- package/tests/commands/finance.test.ts +281 -0
- package/tests/commands/item.test.ts +215 -0
- package/tests/commands/todo.test.ts +292 -9
- package/tests/services/api.test.ts +292 -20
- package/tests/utils/finance.model.test.ts +319 -0
- package/tests/utils/todo.model.test.ts +315 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Model Utilities
|
|
3
|
+
* 包含重复规则计算、标签解析、时长格式化等功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
|
|
7
|
+
|
|
8
|
+
export interface RepeatRule {
|
|
9
|
+
type: RepeatType;
|
|
10
|
+
interval: number; // 重复间隔,如 2 表示每2天/周/月
|
|
11
|
+
endDate?: string; // 重复结束日期
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 解析重复规则字符串
|
|
16
|
+
* 格式: "daily", "weekly", "monthly", "2d", "3w", "1m" 等
|
|
17
|
+
*/
|
|
18
|
+
export function parseRepeatRule(rule: string): RepeatRule {
|
|
19
|
+
if (!rule || rule === 'none') {
|
|
20
|
+
return { type: 'none', interval: 1 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const lower = rule.toLowerCase();
|
|
24
|
+
|
|
25
|
+
if (lower === 'daily') {
|
|
26
|
+
return { type: 'daily', interval: 1 };
|
|
27
|
+
}
|
|
28
|
+
if (lower === 'weekly') {
|
|
29
|
+
return { type: 'weekly', interval: 1 };
|
|
30
|
+
}
|
|
31
|
+
if (lower === 'monthly') {
|
|
32
|
+
return { type: 'monthly', interval: 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 解析 "2d", "3w", "1m" 格式
|
|
36
|
+
const match = rule.match(/^(\d+)([dwm])$/i);
|
|
37
|
+
if (match) {
|
|
38
|
+
const num = parseInt(match[1], 10);
|
|
39
|
+
const unit = match[2].toLowerCase();
|
|
40
|
+
switch (unit) {
|
|
41
|
+
case 'd':
|
|
42
|
+
return { type: 'daily', interval: num };
|
|
43
|
+
case 'w':
|
|
44
|
+
return { type: 'weekly', interval: num };
|
|
45
|
+
case 'm':
|
|
46
|
+
return { type: 'monthly', interval: num };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { type: 'none', interval: 1 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 计算下一次重复日期
|
|
55
|
+
*/
|
|
56
|
+
export function calculateNextOccurrence(
|
|
57
|
+
currentDate: Date,
|
|
58
|
+
rule: RepeatRule
|
|
59
|
+
): Date | null {
|
|
60
|
+
if (rule.type === 'none') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const next = new Date(currentDate);
|
|
65
|
+
|
|
66
|
+
switch (rule.type) {
|
|
67
|
+
case 'daily':
|
|
68
|
+
next.setDate(next.getDate() + rule.interval);
|
|
69
|
+
break;
|
|
70
|
+
case 'weekly':
|
|
71
|
+
next.setDate(next.getDate() + 7 * rule.interval);
|
|
72
|
+
break;
|
|
73
|
+
case 'monthly':
|
|
74
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 检查是否超过结束日期
|
|
79
|
+
if (rule.endDate) {
|
|
80
|
+
const endDate = new Date(rule.endDate);
|
|
81
|
+
if (next > endDate) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 解析标签字符串
|
|
91
|
+
* 格式: "work,important,urgent" -> ["work", "important", "urgent"]
|
|
92
|
+
*/
|
|
93
|
+
export function parseTags(tagsString: string | undefined): string[] {
|
|
94
|
+
if (!tagsString || tagsString.trim() === '') {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return tagsString
|
|
98
|
+
.split(',')
|
|
99
|
+
.map(tag => tag.trim())
|
|
100
|
+
.filter(tag => tag.length > 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 格式化时长
|
|
105
|
+
* 秒数转换为可读格式: "1h30m", "45m", "2h"
|
|
106
|
+
*/
|
|
107
|
+
export function formatDuration(seconds: number): string {
|
|
108
|
+
if (seconds < 0) {
|
|
109
|
+
return '0m';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hours = Math.floor(seconds / 3600);
|
|
113
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
114
|
+
|
|
115
|
+
if (hours === 0) {
|
|
116
|
+
return `${minutes}m`;
|
|
117
|
+
}
|
|
118
|
+
if (minutes === 0) {
|
|
119
|
+
return `${hours}h`;
|
|
120
|
+
}
|
|
121
|
+
return `${hours}h${minutes}m`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 解析时长字符串为秒数
|
|
126
|
+
* 格式: "1h30m", "45m", "2h" -> 秒数
|
|
127
|
+
*/
|
|
128
|
+
export function parseDuration(duration: string): number {
|
|
129
|
+
const hourMatch = duration.match(/(\d+)h/);
|
|
130
|
+
const minMatch = duration.match(/(\d+)m/);
|
|
131
|
+
|
|
132
|
+
const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0;
|
|
133
|
+
const minutes = minMatch ? parseInt(minMatch[1], 10) : 0;
|
|
134
|
+
|
|
135
|
+
return hours * 3600 + minutes * 60;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TodoItem {
|
|
139
|
+
id: string;
|
|
140
|
+
title: string;
|
|
141
|
+
completed: boolean;
|
|
142
|
+
priority: 'low' | 'medium' | 'high';
|
|
143
|
+
dueDate?: string;
|
|
144
|
+
tags?: string[];
|
|
145
|
+
repeatRule?: RepeatRule;
|
|
146
|
+
estimatedDuration?: number; // 秒
|
|
147
|
+
createdAt: string;
|
|
148
|
+
completedAt?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 序列化 Todo 为 JSON
|
|
153
|
+
*/
|
|
154
|
+
export function serializeTodo(todo: TodoItem): string {
|
|
155
|
+
return JSON.stringify(todo);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 反序列化 Todo
|
|
160
|
+
*/
|
|
161
|
+
export function deserializeTodo(json: string): TodoItem | null {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(json) as TodoItem;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finance Command Tests
|
|
3
|
+
* Comprehensive CLI integration tests for biz finance commands
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { financeCommands } from '../../src/commands/biz/finance';
|
|
7
|
+
import { setGlobalFlags } from '../../src/utils/output';
|
|
8
|
+
|
|
9
|
+
// Mock apiService
|
|
10
|
+
jest.mock('../../src/services/api', () => ({
|
|
11
|
+
apiService: {
|
|
12
|
+
createFinance: jest.fn().mockResolvedValue({ id: 1, amount: 100 }),
|
|
13
|
+
listFinances: jest.fn().mockResolvedValue({ data: [{ id: 1, amount: 100 }] }),
|
|
14
|
+
getFinanceStats: jest.fn().mockResolvedValue({ totalIncome: 10000, totalExpense: 5000 }),
|
|
15
|
+
getFinanceReport: jest.fn().mockResolvedValue({ report: 'test' }),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { apiService } from '../../src/services/api';
|
|
20
|
+
|
|
21
|
+
// Mock process.exit to prevent test from exiting
|
|
22
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
|
23
|
+
|
|
24
|
+
// Suppress console output during tests
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
setGlobalFlags({ quiet: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
setGlobalFlags({ quiet: false });
|
|
31
|
+
mockExit.mockRestore();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('biz finance commands', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('create', () => {
|
|
40
|
+
it('should call apiService.createFinance with amount only', async () => {
|
|
41
|
+
await financeCommands.create('100');
|
|
42
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
43
|
+
100,
|
|
44
|
+
'income', // positive amounts default to income
|
|
45
|
+
'other',
|
|
46
|
+
undefined, // note
|
|
47
|
+
'cash', // account default
|
|
48
|
+
undefined, // date
|
|
49
|
+
undefined // tags
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should call apiService.createFinance with expense type', async () => {
|
|
54
|
+
await financeCommands.create('100', { type: 'expense' });
|
|
55
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
56
|
+
100,
|
|
57
|
+
'expense',
|
|
58
|
+
'other',
|
|
59
|
+
undefined,
|
|
60
|
+
'cash',
|
|
61
|
+
undefined,
|
|
62
|
+
undefined
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should call apiService.createFinance with income type', async () => {
|
|
67
|
+
await financeCommands.create('1000', { type: 'income' });
|
|
68
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
69
|
+
1000,
|
|
70
|
+
'income',
|
|
71
|
+
'other',
|
|
72
|
+
undefined,
|
|
73
|
+
'cash',
|
|
74
|
+
undefined,
|
|
75
|
+
undefined
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should call apiService.createFinance with category', async () => {
|
|
80
|
+
await financeCommands.create('100', { category: 'food' });
|
|
81
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
82
|
+
100,
|
|
83
|
+
'income',
|
|
84
|
+
'food',
|
|
85
|
+
undefined,
|
|
86
|
+
'cash',
|
|
87
|
+
undefined,
|
|
88
|
+
undefined
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should call apiService.createFinance with note', async () => {
|
|
93
|
+
await financeCommands.create('100', { note: '午饭' });
|
|
94
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
95
|
+
100,
|
|
96
|
+
'income',
|
|
97
|
+
'other',
|
|
98
|
+
'午饭',
|
|
99
|
+
'cash',
|
|
100
|
+
undefined,
|
|
101
|
+
undefined
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should call apiService.createFinance with account', async () => {
|
|
106
|
+
await financeCommands.create('100', { account: 'bank' });
|
|
107
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
108
|
+
100,
|
|
109
|
+
'income',
|
|
110
|
+
'other',
|
|
111
|
+
undefined,
|
|
112
|
+
'bank',
|
|
113
|
+
undefined,
|
|
114
|
+
undefined
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should call apiService.createFinance with date', async () => {
|
|
119
|
+
await financeCommands.create('100', { date: '2026-04-01' });
|
|
120
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
121
|
+
100,
|
|
122
|
+
'income',
|
|
123
|
+
'other',
|
|
124
|
+
undefined,
|
|
125
|
+
'cash',
|
|
126
|
+
'2026-04-01',
|
|
127
|
+
undefined
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should call apiService.createFinance with tags', async () => {
|
|
132
|
+
await financeCommands.create('100', { tags: 'food,lunch' });
|
|
133
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
134
|
+
100,
|
|
135
|
+
'income',
|
|
136
|
+
'other',
|
|
137
|
+
undefined,
|
|
138
|
+
'cash',
|
|
139
|
+
undefined,
|
|
140
|
+
'food,lunch'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should call apiService.createFinance with all options', async () => {
|
|
145
|
+
await financeCommands.create('500', { type: 'income', category: 'salary', note: '月薪', account: 'bank', date: '2026-04-01', tags: 'monthly' });
|
|
146
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
147
|
+
500,
|
|
148
|
+
'income',
|
|
149
|
+
'salary',
|
|
150
|
+
'月薪',
|
|
151
|
+
'bank',
|
|
152
|
+
'2026-04-01',
|
|
153
|
+
'monthly'
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should treat negative amounts as expense', async () => {
|
|
158
|
+
await financeCommands.create('-50');
|
|
159
|
+
expect(apiService.createFinance).toHaveBeenCalledWith(
|
|
160
|
+
50,
|
|
161
|
+
'expense',
|
|
162
|
+
'other',
|
|
163
|
+
undefined,
|
|
164
|
+
'cash',
|
|
165
|
+
undefined,
|
|
166
|
+
undefined
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should reject invalid amount', async () => {
|
|
171
|
+
await financeCommands.create('invalid');
|
|
172
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should handle api errors gracefully', async () => {
|
|
176
|
+
(apiService.createFinance as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
177
|
+
|
|
178
|
+
await financeCommands.create('100');
|
|
179
|
+
|
|
180
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('list', () => {
|
|
185
|
+
it('should call apiService.listFinances without filters', async () => {
|
|
186
|
+
await financeCommands.list();
|
|
187
|
+
expect(apiService.listFinances).toHaveBeenCalledWith(undefined, undefined, undefined);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should call apiService.listFinances with type filter', async () => {
|
|
191
|
+
await financeCommands.list({ type: 'expense' });
|
|
192
|
+
expect(apiService.listFinances).toHaveBeenCalledWith('expense', undefined, undefined);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should call apiService.listFinances with category filter', async () => {
|
|
196
|
+
await financeCommands.list({ category: 'food' });
|
|
197
|
+
expect(apiService.listFinances).toHaveBeenCalledWith(undefined, 'food', undefined);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should call apiService.listFinances with month filter', async () => {
|
|
201
|
+
await financeCommands.list({ month: '2026-04' });
|
|
202
|
+
expect(apiService.listFinances).toHaveBeenCalledWith(undefined, undefined, '2026-04');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should call apiService.listFinances with all filters', async () => {
|
|
206
|
+
await financeCommands.list({ type: 'expense', category: 'food', month: '2026-04' });
|
|
207
|
+
expect(apiService.listFinances).toHaveBeenCalledWith('expense', 'food', '2026-04');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should handle empty results', async () => {
|
|
211
|
+
(apiService.listFinances as jest.Mock).mockResolvedValueOnce({ data: [] });
|
|
212
|
+
|
|
213
|
+
await financeCommands.list();
|
|
214
|
+
|
|
215
|
+
expect(apiService.listFinances).toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should handle api errors gracefully', async () => {
|
|
219
|
+
(apiService.listFinances as jest.Mock).mockRejectedValueOnce(new Error('Network Error'));
|
|
220
|
+
|
|
221
|
+
await financeCommands.list();
|
|
222
|
+
|
|
223
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('stats', () => {
|
|
228
|
+
it('should call apiService.getFinanceStats with default month', async () => {
|
|
229
|
+
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
230
|
+
|
|
231
|
+
await financeCommands.stats();
|
|
232
|
+
|
|
233
|
+
expect(apiService.getFinanceStats).toHaveBeenCalledWith(currentMonth);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should call apiService.getFinanceStats with specified month', async () => {
|
|
237
|
+
await financeCommands.stats({ month: '2026-03' });
|
|
238
|
+
expect(apiService.getFinanceStats).toHaveBeenCalledWith('2026-03');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should return stats data', async () => {
|
|
242
|
+
const stats = { totalIncome: 10000, totalExpense: 5000, netBalance: 5000 };
|
|
243
|
+
(apiService.getFinanceStats as jest.Mock).mockResolvedValueOnce(stats);
|
|
244
|
+
|
|
245
|
+
await financeCommands.stats();
|
|
246
|
+
|
|
247
|
+
expect(apiService.getFinanceStats).toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle api errors gracefully', async () => {
|
|
251
|
+
(apiService.getFinanceStats as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
252
|
+
|
|
253
|
+
await financeCommands.stats();
|
|
254
|
+
|
|
255
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('report', () => {
|
|
260
|
+
it('should call apiService.getFinanceReport with default month', async () => {
|
|
261
|
+
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
262
|
+
|
|
263
|
+
await financeCommands.report();
|
|
264
|
+
|
|
265
|
+
expect(apiService.getFinanceReport).toHaveBeenCalledWith(currentMonth);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should call apiService.getFinanceReport with specified month', async () => {
|
|
269
|
+
await financeCommands.report({ month: '2026-03' });
|
|
270
|
+
expect(apiService.getFinanceReport).toHaveBeenCalledWith('2026-03');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should handle api errors gracefully', async () => {
|
|
274
|
+
(apiService.getFinanceReport as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
275
|
+
|
|
276
|
+
await financeCommands.report();
|
|
277
|
+
|
|
278
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item Command Tests
|
|
3
|
+
* Comprehensive CLI integration tests for biz item commands
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { itemCommands } from '../../src/commands/biz/item';
|
|
7
|
+
import { setGlobalFlags } from '../../src/utils/output';
|
|
8
|
+
|
|
9
|
+
// Mock apiService
|
|
10
|
+
jest.mock('../../src/services/api', () => ({
|
|
11
|
+
apiService: {
|
|
12
|
+
createItem: jest.fn().mockResolvedValue({ id: 1, name: '测试物品' }),
|
|
13
|
+
listItems: jest.fn().mockResolvedValue({ data: [{ id: 1, name: '测试物品' }] }),
|
|
14
|
+
getExpiringItems: jest.fn().mockResolvedValue({ data: [] }),
|
|
15
|
+
getItemStats: jest.fn().mockResolvedValue({ total: 0 }),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { apiService } from '../../src/services/api';
|
|
20
|
+
|
|
21
|
+
// Mock process.exit to prevent test from exiting
|
|
22
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
|
23
|
+
|
|
24
|
+
// Suppress console output during tests
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
setGlobalFlags({ quiet: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
setGlobalFlags({ quiet: false });
|
|
31
|
+
mockExit.mockRestore();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('biz item commands', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('create', () => {
|
|
40
|
+
it('should call apiService.createItem with name only', async () => {
|
|
41
|
+
await itemCommands.create('测试物品');
|
|
42
|
+
expect(apiService.createItem).toHaveBeenCalledWith(
|
|
43
|
+
'测试物品',
|
|
44
|
+
undefined, // category
|
|
45
|
+
undefined, // location
|
|
46
|
+
undefined, // expire
|
|
47
|
+
undefined, // quantity
|
|
48
|
+
undefined, // unit
|
|
49
|
+
undefined, // brand
|
|
50
|
+
undefined, // barcode
|
|
51
|
+
undefined, // price
|
|
52
|
+
undefined, // supplier
|
|
53
|
+
undefined // purchaseDate
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call apiService.createItem with category', async () => {
|
|
58
|
+
await itemCommands.create('测试物品', { category: 'electronics' });
|
|
59
|
+
expect(apiService.createItem).toHaveBeenCalledWith(
|
|
60
|
+
'测试物品',
|
|
61
|
+
'electronics',
|
|
62
|
+
undefined,
|
|
63
|
+
undefined,
|
|
64
|
+
undefined,
|
|
65
|
+
undefined,
|
|
66
|
+
undefined,
|
|
67
|
+
undefined,
|
|
68
|
+
undefined,
|
|
69
|
+
undefined,
|
|
70
|
+
undefined
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should call apiService.createItem with location', async () => {
|
|
75
|
+
await itemCommands.create('测试物品', { location: '客厅' });
|
|
76
|
+
expect(apiService.createItem).toHaveBeenCalledWith(
|
|
77
|
+
'测试物品',
|
|
78
|
+
undefined,
|
|
79
|
+
'客厅',
|
|
80
|
+
undefined,
|
|
81
|
+
undefined,
|
|
82
|
+
undefined,
|
|
83
|
+
undefined,
|
|
84
|
+
undefined,
|
|
85
|
+
undefined,
|
|
86
|
+
undefined,
|
|
87
|
+
undefined
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should call apiService.createItem with expire date', async () => {
|
|
92
|
+
await itemCommands.create('测试物品', { expire: '2026-12-31' });
|
|
93
|
+
expect(apiService.createItem).toHaveBeenCalledWith(
|
|
94
|
+
'测试物品',
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
'2026-12-31',
|
|
98
|
+
undefined,
|
|
99
|
+
undefined,
|
|
100
|
+
undefined,
|
|
101
|
+
undefined,
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
undefined
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should call apiService.createItem with all options', async () => {
|
|
109
|
+
await itemCommands.create('测试物品', {
|
|
110
|
+
category: 'electronics',
|
|
111
|
+
location: '客厅',
|
|
112
|
+
expire: '2026-12-31',
|
|
113
|
+
quantity: 2,
|
|
114
|
+
unit: '个',
|
|
115
|
+
brand: 'Apple',
|
|
116
|
+
barcode: '123456789',
|
|
117
|
+
price: 99.99,
|
|
118
|
+
supplier: '供应商A',
|
|
119
|
+
purchaseDate: '2026-01-01',
|
|
120
|
+
});
|
|
121
|
+
expect(apiService.createItem).toHaveBeenCalledWith(
|
|
122
|
+
'测试物品',
|
|
123
|
+
'electronics',
|
|
124
|
+
'客厅',
|
|
125
|
+
'2026-12-31',
|
|
126
|
+
2,
|
|
127
|
+
'个',
|
|
128
|
+
'Apple',
|
|
129
|
+
'123456789',
|
|
130
|
+
99.99,
|
|
131
|
+
'供应商A',
|
|
132
|
+
'2026-01-01'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle api errors gracefully', async () => {
|
|
137
|
+
(apiService.createItem as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
138
|
+
|
|
139
|
+
await itemCommands.create('测试物品');
|
|
140
|
+
|
|
141
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('list', () => {
|
|
146
|
+
it('should call apiService.listItems without filters', async () => {
|
|
147
|
+
await itemCommands.list();
|
|
148
|
+
expect(apiService.listItems).toHaveBeenCalledWith(undefined);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should call apiService.listItems with category filter', async () => {
|
|
152
|
+
await itemCommands.list({ category: 'electronics' });
|
|
153
|
+
expect(apiService.listItems).toHaveBeenCalledWith('electronics');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should handle empty results', async () => {
|
|
157
|
+
(apiService.listItems as jest.Mock).mockResolvedValueOnce({ data: [] });
|
|
158
|
+
|
|
159
|
+
await itemCommands.list();
|
|
160
|
+
|
|
161
|
+
expect(apiService.listItems).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should handle api errors gracefully', async () => {
|
|
165
|
+
(apiService.listItems as jest.Mock).mockRejectedValueOnce(new Error('Network Error'));
|
|
166
|
+
|
|
167
|
+
await itemCommands.list();
|
|
168
|
+
|
|
169
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('expiring', () => {
|
|
174
|
+
it('should call apiService.getExpiringItems with default days', async () => {
|
|
175
|
+
await itemCommands.expiring();
|
|
176
|
+
expect(apiService.getExpiringItems).toHaveBeenCalledWith(7);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should call apiService.getExpiringItems with custom days', async () => {
|
|
180
|
+
await itemCommands.expiring({ days: 14 });
|
|
181
|
+
expect(apiService.getExpiringItems).toHaveBeenCalledWith(14);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle empty results', async () => {
|
|
185
|
+
(apiService.getExpiringItems as jest.Mock).mockResolvedValueOnce({ data: [] });
|
|
186
|
+
|
|
187
|
+
await itemCommands.expiring();
|
|
188
|
+
|
|
189
|
+
expect(apiService.getExpiringItems).toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should handle api errors gracefully', async () => {
|
|
193
|
+
(apiService.getExpiringItems as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
194
|
+
|
|
195
|
+
await itemCommands.expiring();
|
|
196
|
+
|
|
197
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('stats', () => {
|
|
202
|
+
it('should call apiService.getItemStats', async () => {
|
|
203
|
+
await itemCommands.stats();
|
|
204
|
+
expect(apiService.getItemStats).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should handle api errors gracefully', async () => {
|
|
208
|
+
(apiService.getItemStats as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
|
|
209
|
+
|
|
210
|
+
await itemCommands.stats();
|
|
211
|
+
|
|
212
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|