diff --git a/.changes/1045-attendance-mail-shortcuts.md b/.changes/1045-attendance-mail-shortcuts.md new file mode 100644 index 00000000..256619d4 --- /dev/null +++ b/.changes/1045-attendance-mail-shortcuts.md @@ -0,0 +1,11 @@ +--- +category: Changed +--- + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md index 3a968a32..bd97f131 100644 --- a/docs/shortcut-downstream-requirements/attendance.md +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -246,7 +246,8 @@ | Shortcut | 上游根因 | 已完成修复 | 回归证据 | |---|---|---|---| | 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵已完成;9 个公开入口已按适用语义完成真实 nonempty/zero、详情或模板 exact/raw 双层复核;最终 PR HEAD 仍从零复核 | -| `+check-record` | `query_check_record` 在真实非空查询中会额外返回请求开始日前一个业务日的记录;把 raw 全量直接做范围断言会使合法请求整体失败 | 仅该叶子在完整 raw 集合通过显式 collection、全量正整数唯一 ID、用户身份与时间字段类型校验后,过滤请求区间外的下游 overfetch;错用户、缺字段、错型、坏时间与重复 ID 仍 fail-closed。`+check-result`、`+get-schedule` 保持原严格范围拒绝,不共享该过滤语义 | helper/Execute 负向矩阵通过;raw 157 条、exact 156 条,逐对象精确等于 raw 的请求用户与本地日期范围子集,仅过滤 start-24h 一项;fresh zero 双层通过 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | reviewer 反馈后以脱敏 live 字段关系确认:旧轮唯一 `workDate=start-24h` 项的 `userCheckTime` 在请求范围内,属于跨午夜 OffDuty;night-shift、missing/wrong/out-of-range actual-time 与稳定 ID 负向回归通过,最终 clean HEAD 待从零复核 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日,并锁定 `request_range_mismatch` reason;最终 clean HEAD 待真实双层复核 | | `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | | `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | @@ -259,7 +260,7 @@ | 叶子 | clean executable HEAD 双层证据 | 发布状态 | |---|---|---| | `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+check-record` | raw 157 条先全部通过集合、ID、用户与时间类型校验;exact 156 条逐对象等于 raw 的请求用户+本地日期范围子集,唯一过滤 start-24h 一项;fresh future zero 双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | 旧轮 raw 157→exact 156 的差异已被确认是错误过滤跨午夜夜班记录,不再作为 PASS 证据;修复后按 `userCheckTime` 绑定并保留该记录 | 最终 clean HEAD 必须从零复核 nonempty/zero 后才能恢复 `PASS` | | `+list-approve`, `+get-schedule` | exact/raw known-nonempty 分别 7/54 条,稳定 ID、用户/类型或用户/日期绑定及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | | `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | | `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md index 54ca7efe..f4a027cd 100644 --- a/docs/shortcut-downstream-requirements/mail.md +++ b/docs/shortcut-downstream-requirements/mail.md @@ -168,6 +168,7 @@ |---|---|---|---| | 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | | `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20 + fresh guaranteed-zero;stable ID set 与 raw pagination/meta 精确一致 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 clean HEAD 待真实自动解析复核 | | `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | | `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 19 + fresh guaranteed-zero;stable identity set 与 raw pagination/meta 精确一致 | | `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 7a2216d7..63f07c0d 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 422 + publicShortcutCount = 423 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 446 + schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 422 + publiclyDeliveredShortcutCount = 423 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index aeedf75a..b80e5b5c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -88,7 +88,7 @@ func flexDateTime(s string) (string, error) { } // dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date -// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +// resolves to the final millisecond before the next local calendar day. func dateToMillis(s string, endOfDay bool) (int64, error) { s = strings.TrimSpace(s) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { @@ -96,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) { } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { if endOfDay { - return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil } return t.UnixMilli(), nil } @@ -265,7 +265,7 @@ var CheckResult = shortcut.Shortcut{ return err } startMillis, _ := dayMillis(rt.Str("start")) - endMillis, _ := dayMillis(rt.Str("end")) + endMillis, _ := dateToMillis(rt.Str("end"), true) if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { return err } @@ -360,8 +360,7 @@ var CheckRecord = shortcut.Shortcut{ } startMillis, _ := dayMillis(rt.Str("start")) endMillis, _ := dateToMillis(rt.Str("end"), true) - items, err = attendanceFilterUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis) - if err != nil { + if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil { return err } return attendanceOutputCollection(rt, "records", items, true, nil, false, "") @@ -484,7 +483,7 @@ var ListApprove = shortcut.Shortcut{ requestedTypes[value] = struct{}{} } startMillis, _ := dayMillis(rt.Str("start")) - endMillis, _ := dayMillis(rt.Str("end")) + endMillis, _ := dateToMillis(rt.Str("end"), true) return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) }, "result") }, diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go index df84c599..4557e9a9 100644 --- a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -71,6 +72,8 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { }{ {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false}, + {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false}, {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, {"missing time", []map[string]any{{"userId": "u1"}}, false}, {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, @@ -96,6 +99,8 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true}, + {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true}, {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, } for _, tc := range approvalCases { @@ -109,47 +114,43 @@ func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { } } -func TestCrossPlatformCoverageAttendanceCheckRecordOverfetchFilter(t *testing.T) { +func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) { const startMillis, endMillis = int64(1000), int64(2000) - items := []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 1}, - {"id": 2, "userId": "u1", "workDate": startMillis}, - {"id": 3, "userId": "u1", "workDate": endMillis}, - {"id": 4, "userId": "u1", "workDate": endMillis + 1}, - } - filtered, err := attendanceFilterUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "workDate", startMillis, endMillis) - if err != nil || len(filtered) != 2 || filtered[0]["id"] != 2 || filtered[1]["id"] != 3 { - t.Fatalf("filtered=%#v err=%v", filtered, err) + items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}} + if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil { + t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err) } invalid := []struct { - name string - items []map[string]any - start int64 - end int64 + name string + item map[string]any + reason string }{ - {"reversed request", nil, endMillis, startMillis}, - {"missing user", []map[string]any{{"workDate": startMillis}}, startMillis, endMillis}, - {"wrong user type", []map[string]any{{"userId": 1, "workDate": startMillis}}, startMillis, endMillis}, - {"empty user", []map[string]any{{"userId": " ", "workDate": startMillis}}, startMillis, endMillis}, - {"wrong user outside range", []map[string]any{{"userId": "u2", "workDate": startMillis - 1}}, startMillis, endMillis}, - {"missing time", []map[string]any{{"userId": "u1"}}, startMillis, endMillis}, - {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, startMillis, endMillis}, - {"zero time", []map[string]any{{"userId": "u1", "workDate": 0}}, startMillis, endMillis}, + {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"}, + {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"}, + {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"}, + {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"}, + {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"}, + {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"}, } for _, tc := range invalid { t.Run(tc.name, func(t *testing.T) { - if got, err := attendanceFilterUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", tc.start, tc.end); err == nil || got != nil { - t.Fatalf("filtered=%#v err=%v", got, err) + err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || !ok || typed.Reason != tc.reason { + t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason) } }) } } -func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfetch(t *testing.T) { +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) { values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} startMillis, _ := dayMillis(values["start"]) - endMillis, _ := dateToMillis(values["end"], true) execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { t.Helper() caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} @@ -182,33 +183,24 @@ func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfe return envelope, nil } - t.Run("keeps in-range and filters start minus 24 hours", func(t *testing.T) { + t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) { envelope, err := execute(t, []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, - {"id": 2, "userId": "u1", "workDate": startMillis}, + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000}, + {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis}, }) if err != nil { t.Fatal(err) } data, ok := envelope["data"].(map[string]any) records, recordsOK := data["records"].([]any) - if !ok || !recordsOK || data["count"] != float64(1) || len(records) != 1 || records[0].(map[string]any)["id"] != float64(2) { + if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) { t.Fatalf("data=%#v", envelope["data"]) } }) - t.Run("all valid overfetch becomes explicit empty", func(t *testing.T) { - envelope, err := execute(t, []map[string]any{ - {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000}, - {"id": 2, "userId": "u1", "workDate": endMillis + 1}, - }) - if err != nil { - t.Fatal(err) - } - data, ok := envelope["data"].(map[string]any) - records, recordsOK := data["records"].([]any) - if !ok || !recordsOK || data["count"] != float64(0) || len(records) != 0 { - t.Fatalf("data=%#v", envelope["data"]) + t.Run("actual-time overfetch fails closed", func(t *testing.T) { + if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil { + t.Fatal("out-of-range actual check was silently filtered") } }) @@ -216,11 +208,12 @@ func TestCrossPlatformCoverageAttendanceCheckRecordExecuteFiltersValidatedOverfe name string records []map[string]any }{ - {"missing identity outside range", []map[string]any{{"userId": "u1", "workDate": startMillis - 1}}}, - {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "workDate": startMillis - 1}}}, - {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "workDate": startMillis - 1}}}, - {"malformed time outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": "bad"}}}, - {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 2}, {"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}}, + {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}}, } for _, tc := range invalid { t.Run(tc.name, func(t *testing.T) { @@ -826,8 +819,19 @@ func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { startMillis, _ := dayMillis("2026-01-01") - endMillis, _ := dayMillis("2026-01-31") + endStartMillis, _ := dayMillis("2026-01-31") + endMillis, _ := dateToMillis("2026-01-31", true) + nextDayStartMillis, _ := dayMillis("2026-02-01") + finalDayNoon := endStartMillis + 12*60*60*1000 validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + if endMillis != nextDayStartMillis-1 { + t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1) + } + explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true) + explicitWant, _ := flexMillis("2026-01-31 12:34:56") + if err != nil || explicitEnd != explicitWant { + t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err) + } t.Run("check result bad execute bounds", func(t *testing.T) { for _, values := range []map[string]string{ @@ -874,6 +878,44 @@ func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { } }) + t.Run("end date includes the full business day", func(t *testing.T) { + checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon} + caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls) + } + + approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"} + approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000} + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + if err != nil || caller.calls != 1 { + t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls) + } + + checkRow["workDate"] = endMillis + caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls) + } + checkRow["workDate"] = nextDayStartMillis + if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 { + t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls) + } + + approval["beginTime"] = nextDayStartMillis + approval["endTime"] = nextDayStartMillis + 1000 + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" { + t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason) + } + }) + collectionCases := []struct { name string declaration shortcut.Shortcut @@ -881,7 +923,7 @@ func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { valid map[string]any invalid map[string]any }{ - {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}}, {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, } diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go index 899c967c..a8f6d2fd 100644 --- a/internal/shortcut/attendance/common.go +++ b/internal/shortcut/attendance/common.go @@ -343,68 +343,32 @@ func attendanceValidateUserAndTimeBinding(items []map[string]any, operation stri for _, user := range requestedUsers { users[user] = struct{}{} } - for index, item := range items { - user, ok := item[userField].(string) - user = strings.TrimSpace(user) - if !ok || user == "" { - return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) - } - if _, requested := users[user]; !requested { - return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) - } - timestamp, ok := attendancePositiveInteger(item[timeField]) - if !ok || timestamp < startMillis || timestamp > endMillis { - return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) - } - } - return nil -} - -// attendanceFilterUserAndTimeBinding validates every raw row's request -// binding before filtering service-side range overfetch. Callers must validate -// stable identities for the complete raw collection first: an out-of-range row -// is allowed to be omitted from the projection, but it must never hide a -// malformed user/time binding or a row belonging to another requested user. -func attendanceFilterUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) ([]map[string]any, error) { - if startMillis > endMillis { - return nil, responsecheck.Error(operation, "invalid_request_range", "请求开始时间不能晚于结束时间") - } - users := make(map[string]struct{}, len(requestedUsers)) - for _, user := range requestedUsers { - users[strings.TrimSpace(user)] = struct{}{} - } - timestamps := make([]int64, len(items)) for index, item := range items { rawUser, present := item[userField] if !present { - return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) } user, ok := rawUser.(string) user = strings.TrimSpace(user) if !ok || user == "" { - return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) } if _, requested := users[user]; !requested { - return nil, responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) } rawTimestamp, present := item[timeField] if !present { - return nil, responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) } - timestamp, valid := attendancePositiveInteger(rawTimestamp) - if !valid { - return nil, responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + timestamp, ok := attendancePositiveInteger(rawTimestamp) + if !ok { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + if timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) } - timestamps[index] = timestamp } - filtered := make([]map[string]any, 0, len(items)) - for index, item := range items { - if timestamps[index] < startMillis || timestamps[index] > endMillis { - continue - } - filtered = append(filtered, item) - } - return filtered, nil + return nil } func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go index aa17517e..bd62a5f7 100644 --- a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -156,6 +157,39 @@ func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { } func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + want string + }{ + {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"}, + {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + } { + t.Run("mailbox "+tc.name, func(t *testing.T) { + got, err := searchMailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != tc.want { + t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err) + } + }) + } + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing": {"success": true}, + "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run("mailbox "+name, func(t *testing.T) { + if _, err := searchMailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } + for _, tc := range []struct { message map[string]any want any @@ -165,14 +199,14 @@ func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, - {message: map[string]any{}, want: ""}, + {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"}, } { got, err := searchMailFrom(tc.message) if err != nil || got != tc.want { t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) } } - for _, message := range []map[string]any{{"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} { if _, err := searchMailFrom(message); err == nil { t.Fatal("malformed sender unexpectedly succeeded") } @@ -212,6 +246,8 @@ func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { wantCalls int }{ {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1}, + {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1}, {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, @@ -286,6 +322,21 @@ func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, }, wantCalls: 3, }, + { + name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"}, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 2, + }, + { + name: "triage resolves string mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, { name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{ @@ -313,4 +364,26 @@ func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { } }) } + + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"}, + {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"}, + } { + t.Run(tc.name+" typed reason", func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}} + err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture") + typed, ok := err.(*apperrors.Error) + if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 { + reason := "" + if ok { + reason = typed.Reason + } + t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls)) + } + }) + } } diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 536da856..96eedb7d 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -117,7 +117,7 @@ var MyAttendance = shortcut.Shortcut{ }, } -// myAttendanceCurrentUserID extracts the current user's userId from a +// strictAttendanceCurrentUserID extracts the current user's userId from a // get_current_user_profile response. The gateway returns one of several shapes, // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index 5a6a0040..d0d2a22b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -171,24 +171,73 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - mailboxes, err := smartMailRows(data, "mail/list_user_mailboxes", "emailAccounts", "email") + mailboxes, err := searchMailMailboxAddresses(data) if err != nil { return "", err } if len(mailboxes) > 0 { - return searchMailFirstString(mailboxes[0], "email"), nil + return mailboxes[0], nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } +// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes: +// non-empty address strings and non-empty objects carrying an email field. +// The collection itself and every item remain fail-closed, so malformed rows +// cannot be skipped and misreported as an empty mailbox list. +func searchMailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := smartMailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + address = searchMailFirstString(typed, "email") + default: + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil +} + // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). func searchMailFrom(m map[string]any) (any, error) { raw, present := m["from"] switch v := raw.(type) { case string: - if v != "" { - return v, nil + if normalized := strings.TrimSpace(v); normalized != "" { + return normalized, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") @@ -212,7 +261,10 @@ func searchMailFrom(m map[string]any) (any, error) { if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { return s, nil } - return "", nil + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空") + } + return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段") } func searchMailFirstString(m map[string]any, keys ...string) string {