Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 76 additions & 23 deletions src/feishu/tools/__tests__/task.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// @ts-nocheck — test file, vitest uses esbuild transform
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── 测试用动态日期(避免硬编码过期)──
const _nowSec = Math.floor(Date.now() / 1000);
/** 30 天后的秒级时间戳 (字符串) */
const FUTURE_TS = String(_nowSec + 30 * 86400);
/** 30 天后的 ISO 日期 "YYYY-MM-DD" */
const FUTURE_DATE = new Date((_nowSec + 30 * 86400) * 1000).toISOString().slice(0, 10);

vi.mock('../../../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
Expand Down Expand Up @@ -68,14 +75,17 @@ beforeEach(() => {
// ============================================================

describe('parseDueDate', () => {
// 用相对于"现在"的偏移量生成测试用时间戳,避免硬编码过期
const nowSec = Math.floor(Date.now() / 1000);
const futureTs = String(nowSec + 7 * 86400); // 7 天后
const futureTsMs = String((nowSec + 7 * 86400) * 1000 + 123); // 7 天后 (毫秒)

it('should pass through valid second-level timestamps', () => {
expect(parseDueDate('1773532800')).toBe('1773532800');
expect(parseDueDate('1700000000')).toBe('1700000000');
expect(parseDueDate(futureTs)).toBe(futureTs);
});

it('should convert millisecond timestamps to seconds', () => {
expect(parseDueDate('1773532800000')).toBe('1773532800');
expect(parseDueDate('1700000000123')).toBe('1700000000');
expect(parseDueDate(futureTsMs)).toBe(String(nowSec + 7 * 86400));
});

it('should reject out-of-range numeric strings (e.g. "20260315")', () => {
Expand All @@ -85,33 +95,39 @@ describe('parseDueDate', () => {
});

it('should parse date-only strings as UTC midnight', () => {
// 2026-03-15T00:00:00Z = 1773532800
const result = parseDueDate('2026-03-15');
expect(result).toBe(String(new Date('2026-03-15T00:00:00Z').getTime() / 1000));
// 用 30 天后的日期,确保在未来且在 1 年内
const futureDate = new Date((nowSec + 30 * 86400) * 1000);
const dateStr = futureDate.toISOString().slice(0, 10);
const result = parseDueDate(dateStr);
expect(result).toBe(String(new Date(dateStr + 'T00:00:00Z').getTime() / 1000));
});

it('should parse datetime without timezone as UTC', () => {
// "2026-03-15T10:00:00" should be treated as UTC
const result = parseDueDate('2026-03-15T10:00:00');
const expected = String(new Date('2026-03-15T10:00:00Z').getTime() / 1000);
const futureDate = new Date((nowSec + 30 * 86400) * 1000);
const dateStr = futureDate.toISOString().slice(0, 10);
const result = parseDueDate(`${dateStr}T10:00:00`);
const expected = String(new Date(`${dateStr}T10:00:00Z`).getTime() / 1000);
expect(result).toBe(expected);
});

it('should parse datetime with short format (no seconds) as UTC', () => {
const result = parseDueDate('2026-03-15T10:00');
const expected = String(new Date('2026-03-15T10:00Z').getTime() / 1000);
const futureDate = new Date((nowSec + 30 * 86400) * 1000);
const dateStr = futureDate.toISOString().slice(0, 10);
const result = parseDueDate(`${dateStr}T10:00`);
const expected = String(new Date(`${dateStr}T10:00Z`).getTime() / 1000);
expect(result).toBe(expected);
});

it('should preserve timezone in datetime with explicit timezone', () => {
const result = parseDueDate('2026-03-15T10:00:00+08:00');
const expected = String(new Date('2026-03-15T10:00:00+08:00').getTime() / 1000);
const futureDate = new Date((nowSec + 30 * 86400) * 1000);
const dateStr = futureDate.toISOString().slice(0, 10);
const result = parseDueDate(`${dateStr}T10:00:00+08:00`);
const expected = String(new Date(`${dateStr}T10:00:00+08:00`).getTime() / 1000);
expect(result).toBe(expected);
});

it('should handle whitespace trimming', () => {
expect(parseDueDate(' 1773532800 ')).toBe('1773532800');
expect(parseDueDate(' 2026-03-15 ')).toBe(String(new Date('2026-03-15T00:00:00Z').getTime() / 1000));
expect(parseDueDate(` ${futureTs} `)).toBe(futureTs);
});

it('should throw on invalid date strings', () => {
Expand All @@ -123,6 +139,38 @@ describe('parseDueDate', () => {
it('should throw on invalid date-only format', () => {
expect(() => parseDueDate('9999-99-99')).toThrow('无效的日期');
});

// ── 合理性校验 ──

it('should reject past timestamps', () => {
const pastTs = String(nowSec - 2 * 86400); // 2 天前(确保跨过 UTC 午夜)
expect(() => parseDueDate(pastTs)).toThrow('已过期');
});

it('should reject past ISO dates', () => {
expect(() => parseDueDate('2020-01-01')).toThrow('已过期');
});

it('should allow today as due date (all-day task scenario)', () => {
const todayStr = new Date(nowSec * 1000).toISOString().slice(0, 10);
// 今天的 UTC 午夜不应被拒绝
expect(() => parseDueDate(todayStr)).not.toThrow();
});

it('should reject dates more than 1 year in the future', () => {
const twoYearsLater = String(nowSec + 2 * 365 * 86400);
expect(() => parseDueDate(twoYearsLater)).toThrow('超过 1 年');
});

it('should reject ISO dates more than 1 year in the future', () => {
const futureDate = new Date((nowSec + 400 * 86400) * 1000);
const dateStr = futureDate.toISOString().slice(0, 10);
expect(() => parseDueDate(dateStr)).toThrow('超过 1 年');
});

it('should include human-readable date in error messages', () => {
expect(() => parseDueDate('2020-01-01')).toThrow('2020-01-01');
});
});

// ============================================================
Expand All @@ -147,20 +195,22 @@ describe('feishu_task tool', () => {
});

it('should create a task with due date and description', async () => {
const expectedTs = String(new Date(FUTURE_DATE + 'T00:00:00Z').getTime() / 1000);
mockTaskCreate.mockResolvedValue({
code: 0,
data: { task: { guid: 'TASK_002', summary: '发布版本', due: { timestamp: '1773532800' } } },
data: { task: { guid: 'TASK_002', summary: '发布版本', due: { timestamp: expectedTs } } },
});
const result = await capturedHandler({
action: 'create',
summary: '发布版本',
description: '发布 v2.0',
due: '2026-03-15',
due: FUTURE_DATE,
});
expect(result.content[0].text).toContain('TASK_002');
expect(result.content[0].text).toContain(FUTURE_DATE); // 回显人类可读日期
const callData = mockTaskCreate.mock.calls[0][0].data;
expect(callData.description).toBe('发布 v2.0');
expect(callData.due.timestamp).toBe(String(new Date('2026-03-15T00:00:00Z').getTime() / 1000));
expect(callData.due.timestamp).toBe(expectedTs);
expect(callData.due.is_all_day).toBe(false);
});

Expand All @@ -187,7 +237,7 @@ describe('feishu_task tool', () => {
await capturedHandler({
action: 'create',
summary: '全天任务',
due: '2026-03-15',
due: FUTURE_DATE,
is_all_day: true,
});
const callData = mockTaskCreate.mock.calls[0][0].data;
Expand Down Expand Up @@ -326,17 +376,20 @@ describe('feishu_task tool', () => {

it('should update multiple fields', async () => {
mockTaskPatch.mockResolvedValue({ code: 0 });
await capturedHandler({
const result = await capturedHandler({
action: 'update',
task_guid: 'TASK_001',
update_fields: 'summary, due',
summary: '更新标题',
due: '1773532800',
due: FUTURE_TS,
});
const callData = mockTaskPatch.mock.calls[0][0].data;
expect(callData.update_fields).toEqual(['summary', 'due']);
expect(callData.task.summary).toBe('更新标题');
expect(callData.task.due.timestamp).toBe('1773532800');
expect(callData.task.due.timestamp).toBe(FUTURE_TS);
// 验证响应回显了人类可读日期
expect(result.content[0].text).toContain('任务已更新');
expect(result.content[0].text).toContain(FUTURE_DATE);
});

it('should require task_guid', async () => {
Expand Down
72 changes: 48 additions & 24 deletions src/feishu/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,42 +24,57 @@ import { validateToken } from './validation.js';
*/
export function parseDueDate(input: string): string {
const trimmed = input.trim();
let seconds: number;

// 1. 纯数字 → 可能是 Unix 时间戳
if (/^\d+$/.test(trimmed)) {
const num = Number(trimmed);
// 合理的秒级时间戳范围: >1e9 (2001-09-09) 且 <1e10 (2286-11-20)
// 排除 "20260315" 等被误判为时间戳的类日期数字串
if (num > 1_000_000_000 && num < 10_000_000_000) {
return trimmed;
seconds = num;
} else if (num > 1_000_000_000_000 && num < 10_000_000_000_000) {
// 毫秒级时间戳 → 转秒
seconds = Math.floor(num / 1000);
} else {
// 不在合理范围的纯数字,当作无效输入
throw new Error(`无效的时间戳: ${trimmed}(秒级时间戳应在 1e9 ~ 1e10 范围内)`);
}
// 毫秒级时间戳 → 转秒
if (num > 1_000_000_000_000 && num < 10_000_000_000_000) {
return String(Math.floor(num / 1000));
}
// 不在合理范围的纯数字,当作无效输入
throw new Error(`无效的时间戳: ${trimmed}(秒级时间戳应在 1e9 ~ 1e10 范围内)`);
}

// 2. date-only 格式 (如 "2026-03-15") → 显式追加 T00:00:00Z 避免时区歧义
// ECMAScript 规范: date-only 解析为 UTC 午夜,但行为在不同引擎间可能不一致
// 显式追加 Z 保证结果确定性
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
} else if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
// 2. date-only 格式 (如 "2026-03-15") → 显式追加 T00:00:00Z 避免时区歧义
const ms = new Date(trimmed + 'T00:00:00Z').getTime();
if (isNaN(ms)) throw new Error(`无效的日期: ${trimmed}`);
return String(Math.floor(ms / 1000));
seconds = Math.floor(ms / 1000);
} else {
// 3. ISO datetime (带或不带时区)
let dateStr = trimmed;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(dateStr)) {
dateStr += 'Z';
}
const ms = new Date(dateStr).getTime();
if (isNaN(ms)) throw new Error(`无效的日期格式: ${trimmed}`);
seconds = Math.floor(ms / 1000);
}

// 3. ISO datetime 无时区后缀 (如 "2026-03-15T10:00:00" 或 "2026-03-15T10:00")
// 追加 Z 统一按 UTC 解析,避免服务器本地时区导致偏差
let dateStr = trimmed;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(dateStr)) {
dateStr += 'Z';
// 4. 合理性校验: 不能是过去,不能超过 1 年后
const now = Math.floor(Date.now() / 1000);
const oneYearLater = now + 365 * 86400;
const humanDate = new Date(seconds * 1000).toISOString().slice(0, 10);
// 允许"今天"的日期通过: 将 now 对齐到当天 UTC 午夜
const todayMidnight = now - (now % 86400);

if (seconds < todayMidnight) {
throw new Error(
`日期 ${humanDate} 已过期(输入: "${trimmed}")。截止/开始时间不能是过去`,
);
}
Comment thread
lishuceo marked this conversation as resolved.
if (seconds > oneYearLater) {
throw new Error(
`日期 ${humanDate} 超过 1 年后(输入: "${trimmed}")。截止/开始时间最长不超过 1 年`,
);
}

const ms = new Date(dateStr).getTime();
if (isNaN(ms)) throw new Error(`无效的日期格式: ${trimmed}`);
return String(Math.floor(ms / 1000));
return String(seconds);
}

/**
Expand Down Expand Up @@ -227,7 +242,7 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>,
'任务已创建',
`guid: ${guid}`,
`summary: ${task?.summary ?? ''}`,
task?.due ? `due: ${task.due.timestamp}` : '',
task?.due ? `due: ${task.due.timestamp} (${new Date(Number(task.due.timestamp) * 1000).toISOString().slice(0, 10)})` : '',
guid !== '(未知)' ? `link: https://applink.feishu.cn/client/todo/detail?guid=${guid}` : '',
].filter(Boolean).join('\n'),
}],
Expand Down Expand Up @@ -411,10 +426,19 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>,
params: { user_id_type: args.user_id_type ?? 'open_id' },
}, userTokenOpt);
if (resp.code !== 0) throw new Error(`更新任务失败 (${resp.code}): ${resp.msg}`);
const updatedParts = ['任务已更新'];
if (taskData.due) {
const ts = (taskData.due as { timestamp: string }).timestamp;
updatedParts.push(`due: ${ts} (${new Date(Number(ts) * 1000).toISOString().slice(0, 10)})`);
}
if (taskData.start) {
const ts = (taskData.start as { timestamp: string }).timestamp;
updatedParts.push(`start: ${ts} (${new Date(Number(ts) * 1000).toISOString().slice(0, 10)})`);
}
return {
content: [{
type: 'text' as const,
text: '任务已更新',
text: updatedParts.join('\n'),
}],
};
}
Expand Down
Loading