diff --git a/src/feishu/tools/__tests__/task.test.ts b/src/feishu/tools/__tests__/task.test.ts index 0fd05d45..72e4b274 100644 --- a/src/feishu/tools/__tests__/task.test.ts +++ b/src/feishu/tools/__tests__/task.test.ts @@ -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() }, })); @@ -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")', () => { @@ -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', () => { @@ -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'); + }); }); // ============================================================ @@ -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); }); @@ -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; @@ -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 () => { diff --git a/src/feishu/tools/task.ts b/src/feishu/tools/task.ts index c8bed4bb..005a97c6 100644 --- a/src/feishu/tools/task.ts +++ b/src/feishu/tools/task.ts @@ -24,6 +24,7 @@ import { validateToken } from './validation.js'; */ export function parseDueDate(input: string): string { const trimmed = input.trim(); + let seconds: number; // 1. 纯数字 → 可能是 Unix 时间戳 if (/^\d+$/.test(trimmed)) { @@ -31,35 +32,49 @@ export function parseDueDate(input: string): string { // 合理的秒级时间戳范围: >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}")。截止/开始时间不能是过去`, + ); + } + 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); } /** @@ -227,7 +242,7 @@ export function feishuTaskTool(getUserToken?: () => Promise, '任务已创建', `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'), }], @@ -411,10 +426,19 @@ export function feishuTaskTool(getUserToken?: () => Promise, 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'), }], }; }