Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
9b0441ca56 | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
b6eaf3c5af | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
aa4ae9a903 | ||
|
|
7a019c6fa3 | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
e2e4d6fc22 | ||
|
|
2c0d6e4118 | ||
|
|
08595594d7 | ||
|
|
379f625ca9 | ||
|
|
540bbac35b | ||
|
|
9d27313f5e | ||
|
|
cc86d1e958 | ||
|
|
5619cb150e | ||
|
|
c4d5595ca9 | ||
|
|
5aaf2efb59 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
f79c066806 | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
a0495c169b | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
20c901d7ae | ||
|
|
e51ecc04f1 | ||
|
|
ce57cdf260 | ||
|
|
17741851f5 | ||
|
|
d10446bea7 | ||
|
|
3ec138ba99 | ||
|
|
0ed05a2c5d | ||
|
|
ae1edefee6 | ||
|
|
6dbc7ed82b | ||
|
|
586ad0a5df | ||
|
|
6d88c9968e | ||
|
|
36c61fd8ac | ||
|
|
272b6b8a70 | ||
|
|
c3328411c9 | ||
|
|
83cfd10416 | ||
|
|
9d43a12e08 | ||
|
|
7900e27946 | ||
|
|
42f54832b0 | ||
|
|
5f6ca90821 | ||
|
|
cad437aabd | ||
|
|
47d71375f6 | ||
|
|
69540c3372 | ||
|
|
3a2b738c06 | ||
|
|
725e60f07c | ||
|
|
8b4453adf9 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
ab0d1d2ad6 | ||
|
|
2a1ed8cc7a | ||
|
|
358e1ab065 | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
fab84af434 | ||
|
|
2dd724f1e1 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
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. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -617,7 +617,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
@@ -757,7 +763,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test release scripts
|
||||
shell: bash
|
||||
@@ -1135,7 +1147,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
@@ -1186,7 +1204,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
@@ -1267,7 +1291,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -2698,9 +2698,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.3"
|
||||
version "1.0.59-beta.5"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
|
||||
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并按 lifecycle 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+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 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+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 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -0,0 +1,198 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+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 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-18T17:38:50.904696",
|
||||
"count": 436,
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -933,137 +933,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-result",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-approve-template",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-checkin-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-leave-records",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-schedule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-self-setting",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-summary",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+list-approve",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+list-leave-types",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+my-attendance",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+query-report-data",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-class",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+search-group",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+this-month",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3305,74 +3252,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+contact-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+find-mail-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+folder-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+recent-mail",
|
||||
"command": "+message",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+messages",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+search-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+tag-list",
|
||||
"command": "+thread",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+template-list",
|
||||
"command": "+triage",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+thread-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+unread-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+user-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -232,6 +232,67 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
@@ -531,6 +592,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -600,6 +675,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +808,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +817,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +831,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +843,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1209,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1304,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 435
|
||||
publicShortcutCount = 422
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 438
|
||||
schemaPublishedShortcutCount = 447
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+366
-3
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
// executeAitableWorkflowPublish executes a publish exactly once, then requires
|
||||
// the reviewed valid/flowId envelope before rendering any success output.
|
||||
// create_workflow is non-idempotent, so transport uncertainty is never retried.
|
||||
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("aitable", toolName, args)
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
|
||||
return fmt.Errorf("%s response is not a JSON object", toolName)
|
||||
}
|
||||
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s response validation failed: %w", toolName, err)
|
||||
}
|
||||
if !validFound {
|
||||
return fmt.Errorf("%s response is missing valid", toolName)
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
|
||||
}
|
||||
if flowID == "" {
|
||||
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
|
||||
}
|
||||
return RenderLegacyMCPText(toolName, raw)
|
||||
}
|
||||
|
||||
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
|
||||
var visit func(map[string]any) error
|
||||
visit = func(object map[string]any) error {
|
||||
if raw, exists := object["valid"]; exists {
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("valid must be boolean, got %T", raw)
|
||||
}
|
||||
if validFound && valid != value {
|
||||
return fmt.Errorf("conflicting valid values")
|
||||
}
|
||||
valid, validFound = value, true
|
||||
}
|
||||
for _, key := range []string{"flowId", "workflowId"} {
|
||||
raw, exists := object[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return fmt.Errorf("%s must be a non-empty string", key)
|
||||
}
|
||||
if flowID != "" && flowID != value {
|
||||
return fmt.Errorf("conflicting workflow IDs")
|
||||
}
|
||||
flowID = value
|
||||
}
|
||||
for _, key := range []string{"data", "result", "response"} {
|
||||
if nested, ok := object[key].(map[string]any); ok {
|
||||
if err := visit(nested); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope == nil {
|
||||
return false, false, "", fmt.Errorf("empty response")
|
||||
}
|
||||
if err := visit(envelope); err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
return valid, validFound, flowID, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
|
||||
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
|
||||
}
|
||||
|
||||
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
|
||||
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonStr, _ := cmd.Flags().GetString("json")
|
||||
if jsonStr == "" {
|
||||
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
return fmt.Errorf("--json 解析失败: %v", err)
|
||||
}
|
||||
cfgMap := map[string]any{"filter": parsed}
|
||||
if err := normalizeViewConfigBlock(cfgMap); err != nil {
|
||||
return err
|
||||
}
|
||||
filter, _ := cfgMap["filter"].([]any)
|
||||
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "viewId": viewID,
|
||||
"config": map[string]any{"filter": filter},
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
|
||||
})
|
||||
}
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
var actual any
|
||||
var readBackErr error
|
||||
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
}
|
||||
aitableViewFilterReadbackSleep(backoff)
|
||||
}
|
||||
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
|
||||
if err != nil {
|
||||
readBackErr = err
|
||||
continue
|
||||
}
|
||||
actualViewID, _ := view["viewId"].(string)
|
||||
if actualViewID != viewID {
|
||||
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
|
||||
continue
|
||||
}
|
||||
actual = walkViewPath(view, "filter")
|
||||
if persistedViewFilterMatches(actual, filter) {
|
||||
readBackErr = nil
|
||||
break
|
||||
}
|
||||
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
|
||||
}
|
||||
if readBackErr != nil {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
|
||||
})
|
||||
}
|
||||
|
||||
const aitableViewFilterReadbackAttempts = 6
|
||||
|
||||
var aitableViewFilterReadbackSleep = time.Sleep
|
||||
|
||||
func persistedViewFilterMatches(actual any, expected []any) bool {
|
||||
if reflect.DeepEqual(actual, expected) {
|
||||
return true
|
||||
}
|
||||
root, ok := actual.(map[string]any)
|
||||
if !ok || root["operator"] != "and" {
|
||||
return false
|
||||
}
|
||||
operands, ok := root["operands"].([]any)
|
||||
return ok && reflect.DeepEqual(operands, expected)
|
||||
}
|
||||
|
||||
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
|
||||
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
|
||||
}
|
||||
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get_fields response is missing the fields collection")
|
||||
}
|
||||
types := make(map[string]string, len(fields))
|
||||
for index, field := range fields {
|
||||
fieldID, _ := field["fieldId"].(string)
|
||||
if strings.TrimSpace(fieldID) == "" {
|
||||
fieldID, _ = field["id"].(string)
|
||||
}
|
||||
fieldType, _ := field["type"].(string)
|
||||
if strings.TrimSpace(fieldType) == "" {
|
||||
fieldType, _ = field["fieldType"].(string)
|
||||
}
|
||||
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
|
||||
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
|
||||
}
|
||||
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.ToLower(name)
|
||||
if !seen[name] {
|
||||
wanted = append(wanted, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
var walk func(any) ([]map[string]any, bool)
|
||||
walk = func(current any) ([]map[string]any, bool) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range wanted {
|
||||
for _, key := range keys {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
items, ok := typed[key].([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, ok := walk(typed[key]); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if found, ok := walk(child); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
|
||||
for index, raw := range filter {
|
||||
condition, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("filter[%d] must be an object", index)
|
||||
}
|
||||
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
|
||||
return fmt.Errorf("filter[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
|
||||
operator, _ := condition["operator"].(string)
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" || !validFilterOperators[operator] {
|
||||
return fmt.Errorf("unsupported operator %q", operator)
|
||||
}
|
||||
operands, ok := condition["operands"].([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("operator %s requires an operands array", operator)
|
||||
}
|
||||
if operator == "and" || operator == "or" {
|
||||
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
|
||||
}
|
||||
wantOperands := 2
|
||||
if operator == "exist" || operator == "un_exist" {
|
||||
wantOperands = 1
|
||||
}
|
||||
if len(operands) != wantOperands {
|
||||
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
|
||||
}
|
||||
fieldID, ok := operands[0].(string)
|
||||
fieldID = strings.TrimSpace(fieldID)
|
||||
if !ok || fieldID == "" {
|
||||
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
|
||||
}
|
||||
fieldType, exists := fieldTypes[fieldID]
|
||||
if !exists {
|
||||
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
|
||||
}
|
||||
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
|
||||
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
|
||||
}
|
||||
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
|
||||
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
|
||||
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
|
||||
}
|
||||
if operator == "any_of" {
|
||||
if values, ok := operands[1].([]any); ok {
|
||||
if err := validateAitableMultiSelectOptionNames(values); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectOptionNames(values []any) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("multipleSelect any_of array must not be empty")
|
||||
}
|
||||
for index, value := range values {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if !ok || text == "" {
|
||||
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAitableDateFieldType(fieldType string) bool {
|
||||
return strings.EqualFold(fieldType, "date") ||
|
||||
strings.EqualFold(fieldType, "createdTime") ||
|
||||
strings.EqualFold(fieldType, "lastModifiedTime")
|
||||
}
|
||||
|
||||
func isAitableDateFilterOperator(operator string) bool {
|
||||
switch operator {
|
||||
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectFilterValue(value any) error {
|
||||
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
|
||||
}
|
||||
|
||||
func compactJSON(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func newAitableCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/aitable.json
|
||||
// products.aitable). Catalog assembly stamps provenance contract_final.
|
||||
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
|
||||
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
|
||||
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runAitableViewUpdateArray(cmd, "filter")
|
||||
return runAitableViewUpdateFilter(cmd)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
|
||||
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
}
|
||||
// create_workflow is non-idempotent. Bypass the retry wrapper to
|
||||
// prevent an uncertain first response from creating a duplicate.
|
||||
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("create_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
|
||||
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
return callAitableTool("update_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("update_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
|
||||
|
||||
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
|
||||
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
|
||||
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
|
||||
|
||||
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
|
||||
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
config, _ := caller.calls[1].args["config"].(map[string]any)
|
||||
if _, ok := config["filter"].([]any); !ok {
|
||||
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logical groups fail closed before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field is rejected before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
|
||||
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
|
||||
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
|
||||
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select operator rejects text field", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
|
||||
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select array fails closed before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
|
||||
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date and system-time fields require date operators", func(t *testing.T) {
|
||||
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
|
||||
for fieldID := range fieldTypes {
|
||||
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
|
||||
t.Fatalf("date_eq for %s: %v", fieldID, err)
|
||||
}
|
||||
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
|
||||
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back mismatch is not success", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
|
||||
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
|
||||
|
||||
t.Run("update transport error", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
|
||||
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback transport errors exhaust", func(t *testing.T) {
|
||||
errs := []error{nil, nil}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
errs = append(errs, context.DeadlineExceeded)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback wrong identity exhausts", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
|
||||
t.Fatalf("wrong readback identity = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
loadCases := []struct {
|
||||
name string
|
||||
response string
|
||||
callErr error
|
||||
wantError string
|
||||
}{
|
||||
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
|
||||
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
|
||||
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
|
||||
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
|
||||
}
|
||||
for _, tc := range loadCases {
|
||||
t.Run("load fields "+tc.name, func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
|
||||
installAitableDeps(t, caller)
|
||||
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("load legacy field keys", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
|
||||
if err != nil || got["legacy"] != "text" {
|
||||
t.Fatalf("legacy field keys = %#v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
|
||||
t.Fatal("scalar fields collection must fail")
|
||||
}
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
|
||||
t.Fatal("scalar field item must fail")
|
||||
}
|
||||
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
|
||||
t.Fatalf("recursive fields = %#v, %v", got, ok)
|
||||
}
|
||||
if got, ok := findAitableObjectList(map[string]any{
|
||||
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
|
||||
"fields": []any{map[string]any{"fieldId": "canonical"}},
|
||||
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
|
||||
t.Fatalf("declared collection priority = %#v, %v", got, ok)
|
||||
}
|
||||
|
||||
invalidFilters := []struct {
|
||||
filter []any
|
||||
want string
|
||||
}{
|
||||
{filter: []any{"bad"}, want: "must be an object"},
|
||||
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
|
||||
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
|
||||
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
|
||||
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
|
||||
}
|
||||
for _, tc := range invalidFilters {
|
||||
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
|
||||
t.Fatal("empty any_of array must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
|
||||
t.Fatal("non-string any_of option must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
|
||||
t.Fatalf("valid any_of option names = %v", err)
|
||||
}
|
||||
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
|
||||
t.Fatalf("compactJSON fallback = %q", got)
|
||||
}
|
||||
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
|
||||
t.Fatal("invalid persisted wrapper must not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
|
||||
responses []string
|
||||
errors []error
|
||||
calls []aitableTestCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
|
||||
}
|
||||
return textToolResult(response), nil
|
||||
}
|
||||
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, server, tool, args)
|
||||
}
|
||||
func (*aitableTestCaller) Format() string { return "json" }
|
||||
func (*aitableTestCaller) DryRun() bool { return false }
|
||||
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableTestCaller) Fields() string { return "" }
|
||||
func (*aitableTestCaller) JQ() string { return "" }
|
||||
|
||||
|
||||
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
|
||||
}
|
||||
|
||||
type aitableWorkflowCaller struct {
|
||||
calls []aitableWorkflowCall
|
||||
calls []aitableWorkflowCall
|
||||
response string
|
||||
err error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
response := c.response
|
||||
if response == "" {
|
||||
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
|
||||
Text: response,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableWorkflowCaller) Format() string { return "json" }
|
||||
func (*aitableWorkflowCaller) DryRun() bool { return false }
|
||||
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableWorkflowCaller) Fields() string { return "" }
|
||||
func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableWorkflowCaller{}
|
||||
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
|
||||
}
|
||||
|
||||
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
wantDSL := map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "create test",
|
||||
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
|
||||
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
|
||||
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
|
||||
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
|
||||
{name: "malformed response", response: `{`, want: "not a JSON object"},
|
||||
{name: "null response", response: `null`, want: "not a JSON object"},
|
||||
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
|
||||
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
|
||||
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: tc.response}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("update uses the same strict contract", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
|
||||
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run does not call publish tool", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{dryRun: true}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{err: context.Canceled}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
|
||||
t.Fatalf("nil workflow envelope = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
||||
if err != nil {
|
||||
|
||||
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
newChatEmotionFavoriteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收藏表情",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "list_personal_emotions",
|
||||
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
|
||||
},
|
||||
Description: "列出当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出当前用户的个人收藏表情",
|
||||
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
|
||||
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
|
||||
Examples: []string{"dws chat emotion list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionSendCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "发送个人收藏表情",
|
||||
Long: `发送当前用户的个人收藏表情。
|
||||
|
||||
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
|
||||
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
target, err := personalEmotionSendTarget(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
emotionID, _ := cmd.Flags().GetString("emotion-id")
|
||||
if strings.TrimSpace(emotionID) != "" {
|
||||
payload["emotionId"] = strings.TrimSpace(emotionID)
|
||||
}
|
||||
for key, value := range target {
|
||||
payload[key] = value
|
||||
}
|
||||
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
|
||||
payload["uuid"] = uuid
|
||||
}
|
||||
return callMCPToolOnServer("im", "send_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
|
||||
cmd.Flags().String("emotion-id", "", "表情 ID")
|
||||
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
|
||||
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
|
||||
cmd.Flags().String("uuid", "", "幂等键")
|
||||
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
|
||||
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
})
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionSendContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionFavoriteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "favorite",
|
||||
Short: "新增个人收藏表情",
|
||||
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
|
||||
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
|
||||
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(name) != "" {
|
||||
payload["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if strings.TrimSpace(sourceConversationID) != "" {
|
||||
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
|
||||
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
|
||||
}
|
||||
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
|
||||
cmd.Flags().String("name", "", "表情名称")
|
||||
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
|
||||
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionFavoriteContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
|
||||
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
userID = strings.TrimSpace(userID)
|
||||
openDingTalkID = strings.TrimSpace(openDingTalkID)
|
||||
specified := 0
|
||||
for _, value := range []string{groupID, userID, openDingTalkID} {
|
||||
if value != "" {
|
||||
specified++
|
||||
}
|
||||
}
|
||||
if specified != 1 {
|
||||
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
|
||||
}
|
||||
if groupID != "" {
|
||||
return map[string]any{"openConversationId": groupID}, nil
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
|
||||
}
|
||||
if isOpenDingTalkID(userID) {
|
||||
return map[string]any{"receiverOpenDingTalkId": userID}, nil
|
||||
}
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
|
||||
}
|
||||
|
||||
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
|
||||
hasConversation := strings.TrimSpace(sourceConversationID) != ""
|
||||
hasMessage := strings.TrimSpace(sourceMessageID) != ""
|
||||
if hasConversation != hasMessage {
|
||||
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalEmotionSendContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "send_personal_emotion",
|
||||
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
|
||||
},
|
||||
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以当前用户身份发送个人收藏表情",
|
||||
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
|
||||
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
|
||||
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
|
||||
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func personalEmotionFavoriteContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "favorite_personal_emotion",
|
||||
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
|
||||
},
|
||||
Description: "将 mediaId 新增到当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "新增当前用户的个人收藏表情",
|
||||
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
|
||||
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
|
||||
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "name", Required: boolPtr(false)},
|
||||
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
|
||||
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type personalEmotionCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type personalEmotionCaller struct {
|
||||
calls []personalEmotionCall
|
||||
}
|
||||
|
||||
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
|
||||
if server == "contact" && tool == "get_user_info_by_user_ids" {
|
||||
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
|
||||
}
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*personalEmotionCaller) Format() string { return "json" }
|
||||
func (*personalEmotionCaller) DryRun() bool { return false }
|
||||
func (*personalEmotionCaller) Fields() string { return "" }
|
||||
func (*personalEmotionCaller) JQ() string { return "" }
|
||||
|
||||
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installHelpersCoreDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
|
||||
t.Helper()
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "im" || call.tool != tool {
|
||||
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
|
||||
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
|
||||
caller := &personalEmotionCaller{}
|
||||
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
|
||||
t.Fatalf("chat emotion list returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
|
||||
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--emotion-id", "emotion123",
|
||||
"--group", "cid123",
|
||||
"--idempotency-key", "idem-001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"emotionId": "emotion123",
|
||||
"openConversationId": "cid123",
|
||||
"uuid": "idem-001",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
|
||||
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--open-dingtalk-id", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
|
||||
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
|
||||
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", "u1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
resolveCall := caller.calls[0]
|
||||
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
|
||||
}
|
||||
sendCall := caller.calls[1]
|
||||
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
|
||||
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID2,
|
||||
}
|
||||
if !reflect.DeepEqual(sendCall.args, want) {
|
||||
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"emotion", "send", "--media-id", "@media"},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "send", "--group", "cid"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
|
||||
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "favorite",
|
||||
"--media-id", "@media",
|
||||
"--name", "赞",
|
||||
"--source-conversation-id", "cid123",
|
||||
"--source-message-id", "msg123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion favorite returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"name": "赞",
|
||||
"sourceConversationId": "cid123",
|
||||
"sourceMessageId": "msg123",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "favorite", "--name", "赞"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
{
|
||||
name: "source conversation only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
{
|
||||
name: "source message only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"oneOf":[
|
||||
{"required":["detection","diff","summary","items"]},
|
||||
|
||||
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
|
||||
if name == "sync" {
|
||||
assertDriveSyncResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
if name == "push" {
|
||||
assertDrivePushResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
properties := resultSchemaProperties(t, raw)
|
||||
plan := schemaObjectProperty(t, properties, "plan")
|
||||
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
|
||||
summaryProperties := schemaProperties(t, summary)
|
||||
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
|
||||
if _, ok := summaryProperties[name]; !ok {
|
||||
t.Fatalf("push plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
var schema map[string]any
|
||||
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
planProperties := schemaProperties(t, plan)
|
||||
planDiff := schemaObjectProperty(t, planProperties, "diff")
|
||||
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
planSummary := schemaObjectProperty(t, planProperties, "summary")
|
||||
planSummaryProperties := schemaProperties(t, planSummary)
|
||||
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
|
||||
if _, ok := planSummaryProperties[name]; !ok {
|
||||
t.Fatalf("sync plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
|
||||
@@ -39,6 +39,11 @@ const (
|
||||
pushActionSkipped = "skipped" // 按 --if-exists 跳过
|
||||
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
|
||||
pushActionFailed = "failed"
|
||||
|
||||
pushActionPlannedUpload = "planned_upload"
|
||||
pushActionPlannedOverwrite = "planned_overwrite"
|
||||
pushActionPlannedSkip = "planned_skip"
|
||||
pushActionPlannedFolderCreate = "planned_folder_create"
|
||||
)
|
||||
|
||||
// localPushFile 描述一个待推送的本地常规文件。
|
||||
@@ -79,10 +84,13 @@ type drivePushItem struct {
|
||||
|
||||
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
|
||||
type drivePushSummary struct {
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
PlannedUploads int `json:"planned_uploads,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
type drivePushResult struct {
|
||||
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
plan.Summary.PlannedFolders++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
|
||||
}
|
||||
for _, lf := range localFiles {
|
||||
size := lf.Size
|
||||
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
action := pushActionUploaded
|
||||
action := pushActionPlannedUpload
|
||||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
case ifExistsSmart:
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
} else {
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
}
|
||||
if action == pushActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
if action == pushActionPlannedSkip {
|
||||
plan.Summary.PlannedSkips++
|
||||
} else {
|
||||
plan.Summary.Uploaded++
|
||||
plan.Summary.PlannedUploads++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek upload file: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||||
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
|
||||
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
|
||||
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
|
||||
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "payload")
|
||||
mustWrite(t, path, "payload")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
|
||||
t.Fatalf("PUT failed: %v", err)
|
||||
}
|
||||
if _, err := file.Stat(); err != nil {
|
||||
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
|
||||
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
|
||||
@@ -65,6 +65,14 @@ const (
|
||||
syncActionRenamedLocal = "renamed_local"
|
||||
syncActionSkipped = "skipped"
|
||||
syncActionFailed = "failed"
|
||||
|
||||
syncActionPlannedDownload = "planned_download"
|
||||
syncActionPlannedUpload = "planned_upload"
|
||||
syncActionPlannedOverwrite = "planned_overwrite"
|
||||
syncActionPlannedFolderCreate = "planned_folder_create"
|
||||
syncActionPlannedRenameLocal = "planned_rename_local"
|
||||
syncActionPlannedSkip = "planned_skip"
|
||||
syncActionDecisionRequired = "decision_required"
|
||||
)
|
||||
|
||||
// driveSyncItem 是输出 items[] 中每条操作的明细。
|
||||
@@ -77,10 +85,14 @@ type driveSyncItem struct {
|
||||
|
||||
// driveSyncSummary 是各方向的计数汇总。
|
||||
type driveSyncSummary struct {
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
PlannedPulls int `json:"planned_pulls,omitempty"`
|
||||
PlannedPushes int `json:"planned_pushes,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
|
||||
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// --dry-run:只算差异、不执行任何同步动作。
|
||||
if deps.Caller.DryRun() {
|
||||
if len(preflight) == 0 && res.Summary.Failed == 0 {
|
||||
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
|
||||
})
|
||||
}
|
||||
return deps.Out.PrintJSON(driveSyncDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
|
||||
})
|
||||
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type driveSyncDryRunPlanInput struct {
|
||||
LocalDirs []string
|
||||
LocalByRel map[string]localPushFile
|
||||
RemoteFiles map[string]*remoteFile
|
||||
RemoteFolders map[string]string
|
||||
NewLocal []string
|
||||
NewRemote []string
|
||||
Modified []string
|
||||
Unknown []string
|
||||
OnConflict string
|
||||
}
|
||||
|
||||
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
|
||||
for _, rel := range input.Unknown {
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
|
||||
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
|
||||
})
|
||||
}
|
||||
|
||||
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
|
||||
for rel, fileID := range input.RemoteFolders {
|
||||
plannedFolders[rel] = fileID
|
||||
}
|
||||
for _, dir := range input.LocalDirs {
|
||||
if _, ok := plannedFolders[dir]; ok {
|
||||
continue
|
||||
}
|
||||
parentRel, _ := splitRel(dir)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
res.Summary.PlannedFolders++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
for _, rel := range input.NewRemote {
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
}
|
||||
for _, rel := range input.NewLocal {
|
||||
parentRel, _ := splitRel(rel)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
|
||||
for rel := range input.LocalByRel {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for _, rel := range input.LocalDirs {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFiles {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFolders {
|
||||
if rel != "" {
|
||||
occupied[rel] = true
|
||||
}
|
||||
}
|
||||
for _, rel := range input.Modified {
|
||||
switch input.OnConflict {
|
||||
case syncConflictRemoteWins:
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
case syncConflictLocalWins:
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
|
||||
case syncConflictKeepBoth:
|
||||
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
|
||||
occupied[candidate] = true
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items,
|
||||
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
|
||||
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
|
||||
)
|
||||
case syncConflictAsk:
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
|
||||
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
|
||||
})
|
||||
default:
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
|
||||
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
|
||||
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
caller := syncCaller(nil)
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run push: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
|
||||
}
|
||||
plan := payload["plan"].(map[string]any)
|
||||
summary := plan["summary"].(map[string]any)
|
||||
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
|
||||
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
|
||||
}
|
||||
items := plan["items"].([]any)
|
||||
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
|
||||
t.Fatalf("dry-run action = %v, want planned_upload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
|
||||
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
|
||||
})
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
||||
cmd := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run sync: %v", err)
|
||||
}
|
||||
|
||||
var payload driveSyncDryRunResult
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
|
||||
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
|
||||
}
|
||||
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
|
||||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
|
||||
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
|
||||
}
|
||||
actions := map[string]int{}
|
||||
for _, item := range payload.Plan.Items {
|
||||
actions[item.Action]++
|
||||
}
|
||||
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
|
||||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
|
||||
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
|
||||
}
|
||||
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Fatalf("dry-run called %s: %#v", tool, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
|
||||
remoteFiles := map[string]*remoteFile{
|
||||
"conflict.txt": {FileID: "remote-file"},
|
||||
}
|
||||
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
|
||||
localDirs := []string{"existing", "new", "missing/child"}
|
||||
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
|
||||
|
||||
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
res := &driveSyncResult{}
|
||||
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
|
||||
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
|
||||
})
|
||||
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
|
||||
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
|
||||
}
|
||||
switch policy {
|
||||
case syncConflictRemoteWins, syncConflictKeepBoth:
|
||||
if res.Summary.PlannedPulls < 2 {
|
||||
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
|
||||
}
|
||||
case syncConflictLocalWins:
|
||||
if res.Summary.PlannedPushes < 2 {
|
||||
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
||||
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
|
||||
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
||||
t.Fatalf("overwrite plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pushActionOverwritten) {
|
||||
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
|
||||
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
+237
-1
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -322,6 +323,95 @@ func newOAAttachmentCommand() *cobra.Command {
|
||||
return attachmentCmd
|
||||
}
|
||||
|
||||
// oaAdminQueryMaxPageSize is the pageSize upper bound of
|
||||
// get_process_instances_by_admin.
|
||||
const oaAdminQueryMaxPageSize = float64(20)
|
||||
|
||||
// validateOARequestProcessCode checks the processCode field of a decoded
|
||||
// --request payload: the tool requires it, and the backend answers a bad
|
||||
// processCode with success:true and an empty list, so reject it client-side.
|
||||
func validateOARequestProcessCode(request map[string]any) error {
|
||||
v, ok := request["processCode"]
|
||||
if !ok {
|
||||
return fmt.Errorf("--request 缺少必填字段 processCode")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return fmt.Errorf("--request processCode 必须为非空字符串")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOARequestPageSize checks the pageSize field of a decoded
|
||||
// --request payload (json.Number values from decodeOARequest).
|
||||
func validateOARequestPageSize(request map[string]any) error {
|
||||
v, ok := request["pageSize"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
return fmt.Errorf("pageSize 必须为数字")
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err != nil || f < 1 || f > oaAdminQueryMaxPageSize {
|
||||
return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// oaAdminTimeLayout is the startTime/endTime wire format of
|
||||
// get_process_instances_by_admin since the 2026-08 MCP contract update
|
||||
// (both fields changed from epoch millis to strings).
|
||||
const oaAdminTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in
|
||||
// error messages.
|
||||
const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
// formatOAAdminQueryTime renders a millisecond timestamp into the
|
||||
// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin.
|
||||
func formatOAAdminQueryTime(ms int64) string {
|
||||
return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout)
|
||||
}
|
||||
|
||||
// validateOARequestTimeRange checks startTime/endTime of a decoded
|
||||
// --request payload: startTime is required, both must be
|
||||
// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after
|
||||
// startTime (matching simple mode's ValidateTimeRange).
|
||||
func validateOARequestTimeRange(request map[string]any) error {
|
||||
startRaw, ok := request["startTime"]
|
||||
if !ok {
|
||||
return fmt.Errorf("--request 缺少必填字段 startTime")
|
||||
}
|
||||
startStr, ok := startRaw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
|
||||
}
|
||||
start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr)
|
||||
}
|
||||
endRaw, ok := request["endTime"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
endStr, ok := endRaw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint)
|
||||
}
|
||||
end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return fmt.Errorf("--request endTime 必须晚于 startTime")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws oa — OA 审批
|
||||
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
|
||||
@@ -329,7 +419,8 @@ func newOAAttachmentCommand() *cobra.Command {
|
||||
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
|
||||
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance, get_attachment_download_url, auth_download_file,
|
||||
// start_process_instance, get_process_instances_by_admin,
|
||||
// get_attachment_download_url, auth_download_file,
|
||||
// auth_preview_attachment
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1432,6 +1523,129 @@ func newOaCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
// 以管理员身份查询审批实例列表
|
||||
listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"}
|
||||
approvalListByAdminCmd := &cobra.Command{
|
||||
Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表",
|
||||
Example: ` dws oa approval list-by-admin --process-code <code> --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20
|
||||
dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行,
|
||||
// 这里先校验同一组约束,保证用户看到的是中文错误。
|
||||
request, _ := cmd.Flags().GetString("request")
|
||||
processCode, _ := cmd.Flags().GetString("process-code")
|
||||
if request == "" && processCode == "" {
|
||||
return fmt.Errorf("--request、--process-code 至少指定一个")
|
||||
}
|
||||
if request != "" {
|
||||
for _, name := range listByAdminSimpleFlags {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return fmt.Errorf("--request 与 --%s 不能同时指定", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if processCode != "" && !cmd.Flags().Changed("start") {
|
||||
return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
|
||||
request, err := decodeOARequest(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--request JSON 解析失败: %w", err)
|
||||
}
|
||||
if err := validateOARequestProcessCode(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOARequestPageSize(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOARequestTimeRange(request); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil {
|
||||
return err
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--cursor 必须为数字: %w", err)
|
||||
}
|
||||
pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--limit 必须为数字: %w", err)
|
||||
}
|
||||
if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize {
|
||||
return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit"))
|
||||
}
|
||||
request := map[string]any{
|
||||
"processCode": mustGetFlag(cmd, "process-code"),
|
||||
"startTime": formatOAAdminQueryTime(startMs),
|
||||
"cursor": cursor,
|
||||
"pageSize": pageSize,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("end"); v != "" {
|
||||
endMs, err := parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
request["endTime"] = formatOAAdminQueryTime(endMs)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("user-ids"); v != "" {
|
||||
request["userIds"] = strings.Split(v, ",")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("statuses"); v != "" {
|
||||
request["statuses"] = strings.Split(v, ",")
|
||||
}
|
||||
return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "get_process_instances_by_admin",
|
||||
CanonicalPath: "oa.get_process_instances_by_admin",
|
||||
CLIPath: "oa approval list-by-admin",
|
||||
PrimaryCLIPath: "oa approval list-by-admin",
|
||||
},
|
||||
Description: "以管理员身份获取审批单列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表",
|
||||
UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"},
|
||||
AvoidWhen: []string{
|
||||
"只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc",
|
||||
"无 OA 管理员权限时不要使用,该命令查不到数据",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20",
|
||||
"dws oa approval list-by-admin --process-code <code> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"",
|
||||
},
|
||||
},
|
||||
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest).
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
approvalCreateCmd := &cobra.Command{
|
||||
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
|
||||
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
|
||||
@@ -1610,6 +1824,27 @@ func newOaCommand() *cobra.Command {
|
||||
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
|
||||
})
|
||||
|
||||
approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)")
|
||||
approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)")
|
||||
approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0")
|
||||
approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20")
|
||||
approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)")
|
||||
approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)")
|
||||
approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
|
||||
approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code")
|
||||
approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start")
|
||||
listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags))
|
||||
for _, name := range listByAdminSimpleFlags {
|
||||
approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name)
|
||||
listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name})
|
||||
}
|
||||
cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: listByAdminMutuallyExclusive,
|
||||
RequireOneOf: [][]string{{"request", "process-code"}},
|
||||
RequireTogether: [][]string{{"process-code", "start"}},
|
||||
})
|
||||
|
||||
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
|
||||
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
|
||||
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
|
||||
@@ -1656,6 +1891,7 @@ func newOaCommand() *cobra.Command {
|
||||
approvalRevertTaskCmd,
|
||||
approvalFormSchemaCmd,
|
||||
approvalForecastCmd,
|
||||
approvalListByAdminCmd,
|
||||
approvalCreateCmd,
|
||||
)
|
||||
approvalCmd.AddCommand(newOAAttachmentCommand())
|
||||
|
||||
@@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "list-by-admin",
|
||||
"--process-code", "PROC",
|
||||
"--start", "2030-01-01T09:00:00+08:00",
|
||||
"--end", "2030-01-01T10:00:00+08:00",
|
||||
"--cursor", "5",
|
||||
"--limit", "20",
|
||||
"--user-ids", "user-1,user-2",
|
||||
"--statuses", "RUNNING,COMPLETED",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list by admin: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" {
|
||||
t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool)
|
||||
}
|
||||
request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request payload = %#v", caller.args)
|
||||
}
|
||||
if got := request["processCode"]; got != "PROC" {
|
||||
t.Fatalf("processCode = %#v", got)
|
||||
}
|
||||
if got := request["cursor"]; got != float64(5) {
|
||||
t.Fatalf("cursor = %#v", got)
|
||||
}
|
||||
if got := request["pageSize"]; got != float64(20) {
|
||||
t.Fatalf("pageSize = %#v", got)
|
||||
}
|
||||
if got := request["startTime"]; got != "2030-01-01 09:00:00" {
|
||||
t.Fatalf("startTime = %#v", got)
|
||||
}
|
||||
if got := request["endTime"]; got != "2030-01-01 10:00:00" {
|
||||
t.Fatalf("endTime = %#v", got)
|
||||
}
|
||||
if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" {
|
||||
t.Fatalf("userIds = %#v", got)
|
||||
}
|
||||
if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" {
|
||||
t.Fatalf("statuses = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
|
||||
validCases := []struct {
|
||||
name string
|
||||
@@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
|
||||
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
|
||||
tool: "start_process_instance",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin simple mode",
|
||||
args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode without pageSize",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
{
|
||||
name: "list-by-admin request mode with endTime",
|
||||
args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`},
|
||||
tool: "get_process_instances_by_admin",
|
||||
},
|
||||
}
|
||||
for _, tc := range validCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
|
||||
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
|
||||
{"approval", "list-by-admin"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"},
|
||||
{"approval", "list-by-admin", "--process-code", "PROC", "--start", ""},
|
||||
{"approval", "list-by-admin", "--request", "{"},
|
||||
{"approval", "list-by-admin", "--request", "null"},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
{"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
|
||||
}
|
||||
for _, args := range invalidCases {
|
||||
caller := &scriptedToolCaller{}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"roleId": role,
|
||||
"userIds": userIds,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func executeWikiEdge(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
_, err := executeWikiEdgeWithCaller(t, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) {
|
||||
t.Helper()
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
caller := &scriptedToolCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
@@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error {
|
||||
root.SetIn(os.Stdin)
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
return root.Execute()
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
|
||||
@@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) {
|
||||
caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER")
|
||||
if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") {
|
||||
t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("member add OWNER error = %#v, want validation category", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) {
|
||||
oldStdin := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("copy_base", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"targetFolderId": rt.Str("target-folder-id"),
|
||||
"onlyCopyMeta": rt.Bool("only-struct"),
|
||||
})
|
||||
return executeBaseCopy(rt)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
|
||||
Command: "+record-query",
|
||||
Product: serverMain,
|
||||
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
|
||||
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
|
||||
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
|
||||
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
|
||||
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{
|
||||
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
|
||||
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("get_primary_doc", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"recordId": rt.Str("record-id"),
|
||||
})
|
||||
return executeRecordPrimaryDocGet(rt)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
|
||||
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
|
||||
if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
|
||||
row["workflowId"] = v
|
||||
}
|
||||
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
|
||||
|
||||
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
|
||||
t.Run("verified copy", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
|
||||
{text: `{"data":{"newBaseId":"new-base"}}`},
|
||||
{text: `{"data":{"baseId":"new-base","tables":[]}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
|
||||
t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{text: `{"success":true}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
|
||||
t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{err: errors.New("Invalid target folder ID")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a non-folder target before write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects target metadata without a supported type", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects URL target before transport", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
|
||||
t.Run("invalid source", func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid source = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target lookup error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic copy error is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{err: errors.New("transport failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 2 {
|
||||
t.Fatalf("copy error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
readBackCases := []struct {
|
||||
name string
|
||||
step upsertByKeyStep
|
||||
}{
|
||||
{name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
|
||||
{name: "missing id", step: upsertByKeyStep{text: `{}`}},
|
||||
{name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
|
||||
}
|
||||
for _, tc := range readBackCases {
|
||||
t.Run("readback "+tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{text: `{"newBaseId":"new-base"}`},
|
||||
tc.step,
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "white space", "control\x00"} {
|
||||
if validCompositeOpaqueID(value) {
|
||||
t.Errorf("validCompositeOpaqueID(%q) = true", value)
|
||||
}
|
||||
}
|
||||
if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
|
||||
t.Fatal("pureNumericID edge mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func bootstrapFields(count int) []any {
|
||||
fields := make([]any, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func executeBaseCopy(rt *shortcut.RuntimeContext) error {
|
||||
baseID := strings.TrimSpace(rt.Str("base-id"))
|
||||
targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
|
||||
if !validCompositeOpaqueID(baseID) {
|
||||
return apperrors.NewValidation("--base-id 不是合法的 Base ID")
|
||||
}
|
||||
if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
|
||||
return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"targetFolderId": targetFolderID,
|
||||
"onlyCopyMeta": rt.Bool("only-struct"),
|
||||
}
|
||||
result := newCompositeResult("base_copy")
|
||||
result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
|
||||
result.Plan = []compositeStep{
|
||||
{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
|
||||
{Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
|
||||
if actualTargetID == "" || actualTargetID != targetFolderID {
|
||||
return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
|
||||
if targetType == "" {
|
||||
return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
if !strings.EqualFold(targetType, "folder") {
|
||||
return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
|
||||
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
|
||||
newBaseID := findStringByKeys(writeData, "newBaseId")
|
||||
if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
|
||||
return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
|
||||
apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
|
||||
apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
|
||||
apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
|
||||
}
|
||||
if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
|
||||
cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
|
||||
if writeErr != nil {
|
||||
cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
|
||||
}
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, cause, false)
|
||||
}
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
|
||||
|
||||
readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
|
||||
if verifyErr == nil {
|
||||
actualID := findStringByKeys(readBack, "baseId")
|
||||
if actualID == "" {
|
||||
verifyErr = fmt.Errorf("get_base read-back is missing baseId")
|
||||
} else if actualID != newBaseID {
|
||||
verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
|
||||
}
|
||||
}
|
||||
if verifyErr != nil {
|
||||
result.Status = "partial_success"
|
||||
result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
|
||||
result.CompletedCount = 2
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
|
||||
result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
|
||||
result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func copyBaseRejectedTarget(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "invalid target folder") ||
|
||||
strings.Contains(message, "invalid targetfolder")
|
||||
}
|
||||
|
||||
func validCompositeOpaqueID(value string) bool {
|
||||
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pureNumericID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error {
|
||||
baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id")
|
||||
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
|
||||
if !found {
|
||||
return apperrors.NewAPI("get_fields response is missing the fields collection",
|
||||
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
primaryFieldIDs := make([]string, 0, 1)
|
||||
for index, field := range fields {
|
||||
fieldType := stringValue(field, "type", "fieldType")
|
||||
if !strings.EqualFold(fieldType, "primaryDoc") {
|
||||
continue
|
||||
}
|
||||
fieldID := stringValue(field, "fieldId", "id")
|
||||
if fieldID == "" {
|
||||
return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index),
|
||||
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
primaryFieldIDs = append(primaryFieldIDs, fieldID)
|
||||
}
|
||||
|
||||
recordParams := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID},
|
||||
}
|
||||
if len(primaryFieldIDs) > 0 {
|
||||
recordParams["fieldIds"] = primaryFieldIDs
|
||||
}
|
||||
window, err := queryRecordWindow(rt, recordParams, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records := window.Records
|
||||
if len(records) == 0 {
|
||||
return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID),
|
||||
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
if len(records) != 1 || recordID(records[0]) != requestedRecordID {
|
||||
return apperrors.NewAPI("record preflight did not return the exact requested record",
|
||||
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
if len(primaryFieldIDs) == 0 {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordId": requestedRecordID,
|
||||
})
|
||||
if err != nil {
|
||||
if knownPrimaryDocUnassociatedError(err) {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
nodeID := primaryDocNodeID(data)
|
||||
if nodeID == "" {
|
||||
if knownPrimaryDocUnassociatedData(data) {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
return apperrors.NewAPI("get_primary_doc response is missing nodeId",
|
||||
apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID,
|
||||
})
|
||||
}
|
||||
|
||||
func knownPrimaryDocUnassociatedError(err error) bool {
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "no record") || strings.Contains(message, "unassociated")
|
||||
}
|
||||
|
||||
func primaryDocNodeID(value any) string {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"nodeId", "dentryUuid"} {
|
||||
if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" {
|
||||
return strings.TrimSpace(nodeID)
|
||||
}
|
||||
}
|
||||
for _, envelopeKey := range []string{"data", "result", "response"} {
|
||||
if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" {
|
||||
return nodeID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func knownPrimaryDocUnassociatedData(value any) bool {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if exists, ok := object["exists"].(bool); ok && !exists {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"nodeId", "dentryUuid"} {
|
||||
if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if status, ok := object["status"].(string); ok {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
if status == "unassociated" || status == "no_record" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, envelopeKey := range []string{"data", "result", "response"} {
|
||||
if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(ids); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(ids))
|
||||
batch := ids[offset:end]
|
||||
existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingSet := make(map[string]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existingSet[id] = true
|
||||
}
|
||||
missingIDs := make([]string, 0, len(ids)-len(existingIDs))
|
||||
for _, id := range ids {
|
||||
if !existingSet[id] {
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
}
|
||||
result.Resolved = map[string]any{
|
||||
"existingRecordIds": existingIDs,
|
||||
"missingRecordIds": missingIDs,
|
||||
}
|
||||
result.Plan = nil
|
||||
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(existingIDs))
|
||||
result.Plan = append(result.Plan, compositeStep{
|
||||
Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
|
||||
Status: "planned", Offset: offset, Count: end - offset,
|
||||
})
|
||||
}
|
||||
if len(existingIDs) == 0 {
|
||||
result.Status = "unchanged"
|
||||
result.Executed = false
|
||||
result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
|
||||
result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(existingIDs))
|
||||
batch := existingIDs[offset:end]
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": batch,
|
||||
})
|
||||
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
step.Status = "unknown"
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = offset
|
||||
result.FailedCount = len(ids) - offset
|
||||
result.FailedCount = len(existingIDs) - offset
|
||||
result.Status = "unknown"
|
||||
if offset > 0 {
|
||||
result.Status = "partial_success"
|
||||
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
result.CompletedCount = end
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
|
||||
}
|
||||
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
|
||||
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
|
||||
result.Verification = map[string]any{
|
||||
"status": "verified_absent", "verifiedCount": len(existingIDs),
|
||||
"missingBeforeWriteCount": len(missingIDs),
|
||||
}
|
||||
result.Result = map[string]any{
|
||||
"deletedCount": len(existingIDs), "missingCount": len(missingIDs),
|
||||
"batchCount": len(result.CompletedSteps),
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
|
||||
records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = true
|
||||
}
|
||||
present := make(map[string]bool, len(records))
|
||||
for index, record := range records {
|
||||
id := recordID(record)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
|
||||
}
|
||||
if !wanted[id] {
|
||||
return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
|
||||
}
|
||||
if present[id] {
|
||||
return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
|
||||
}
|
||||
present[id] = true
|
||||
}
|
||||
existing := make([]string, 0, len(present))
|
||||
for _, id := range ids {
|
||||
if present[id] {
|
||||
existing = append(existing, id)
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func parseRecordIDs(values []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := make([]string, 0, len(values))
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
|
||||
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
|
||||
t.Run("record not found is classified", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
|
||||
t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("table without primary doc field is normalized", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
|
||||
t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
|
||||
{text: `{"data":{"nodeId":null}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
|
||||
t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known helper no-record error is unassociated", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
|
||||
{err: errors.New("no record")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
|
||||
t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("associated doc is resolved through helper", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
|
||||
{text: `{"data":{"nodeId":"node-1"}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
|
||||
t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
|
||||
{name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
|
||||
{name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
|
||||
{name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
|
||||
{name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
|
||||
{name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("primary doc failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []any{
|
||||
map[string]any{"exists": false},
|
||||
map[string]any{"dentryUuid": ""},
|
||||
map[string]any{"status": "NO_RECORD"},
|
||||
map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
|
||||
} {
|
||||
if !knownPrimaryDocUnassociatedData(value) {
|
||||
t.Errorf("known unassociated shape not recognized: %#v", value)
|
||||
}
|
||||
}
|
||||
if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
|
||||
t.Fatal("unrelated shapes must not be unassociated")
|
||||
}
|
||||
for _, value := range []any{
|
||||
map[string]any{"metadata": map[string]any{"exists": false}},
|
||||
map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
|
||||
} {
|
||||
if knownPrimaryDocUnassociatedData(value) {
|
||||
t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
|
||||
}
|
||||
}
|
||||
if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
|
||||
t.Fatalf("unrelated nested nodeId = %q, want empty", got)
|
||||
}
|
||||
if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
|
||||
t.Fatalf("known envelope nodeId = %q, want node-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
records string
|
||||
}{
|
||||
{name: "missing id", records: `[{"cells":{}}]`},
|
||||
{name: "unexpected id", records: `[{"recordId":"other"}]`},
|
||||
{name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
|
||||
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
|
||||
|
||||
|
||||
@@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
if verificationPage == 1 {
|
||||
if _, exists := args["cursor"]; exists {
|
||||
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
|
||||
}
|
||||
@@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || out == "" || len(caller.calls) != 3 {
|
||||
if err != nil || out == "" || len(caller.calls) != 4 {
|
||||
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
if verificationPage == 1 {
|
||||
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
if err == nil || out != "" || len(caller.calls) != 4 {
|
||||
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
@@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
})
|
||||
|
||||
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
|
||||
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 {
|
||||
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
@@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
|
||||
records := updateFixtureRecords(0, size, "完成")
|
||||
caller := &upsertByKeyCaller{}
|
||||
queryCalls := 0
|
||||
writeSeen := false
|
||||
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
writeSeen = true
|
||||
return `{"success":true}`, nil
|
||||
}
|
||||
queryCalls++
|
||||
if operation == "delete" {
|
||||
ids := args["recordIds"].([]string)
|
||||
empty := make([]map[string]any, len(ids))
|
||||
response := pagedRecordQueryResponse(t, empty, args)
|
||||
requested := make([]map[string]any, 0, len(ids))
|
||||
if !writeSeen {
|
||||
for _, id := range ids {
|
||||
requested = append(requested, map[string]any{"recordId": id})
|
||||
}
|
||||
}
|
||||
response := pagedRecordQueryResponse(t, requested, args)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(response), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := payload["data"].(map[string]any)
|
||||
page["records"] = []any{}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw), nil
|
||||
}
|
||||
@@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
|
||||
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
|
||||
}
|
||||
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
|
||||
if operation == "delete" {
|
||||
wantQueries *= 2
|
||||
}
|
||||
if queryCalls != wantQueries {
|
||||
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
|
||||
}
|
||||
@@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string {
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
writes := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool == "delete_records" {
|
||||
writes++
|
||||
return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil
|
||||
}
|
||||
requested := args["recordIds"].([]string)
|
||||
records := make([]map[string]any, 0, len(requested))
|
||||
if writes == 0 {
|
||||
for _, id := range requested {
|
||||
records = append(records, map[string]any{"recordId": id})
|
||||
}
|
||||
}
|
||||
return recordListJSON(t, records), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err != nil {
|
||||
@@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
|
||||
t.Fatalf("delete output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
|
||||
if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" {
|
||||
t.Fatalf("delete call sequence = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) {
|
||||
t.Run("all requested IDs were already absent", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"})
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" ||
|
||||
!strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) {
|
||||
t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only records proven present are counted", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"success":true,"data":{"records":[],"hasMore":false}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"})
|
||||
if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) {
|
||||
t.Fatalf("mixed delete = output:%q err:%v", out, err)
|
||||
}
|
||||
deleted := caller.calls[1].args["recordIds"].([]string)
|
||||
if !slices.Equal(deleted, []string{"r1"}) {
|
||||
t.Fatalf("delete target IDs = %#v", deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
|
||||
t.Run("legal empty collection proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
|
||||
t.Fatalf("delete recovered = output:%q err:%v", out, err)
|
||||
@@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
|
||||
}}
|
||||
@@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
})
|
||||
|
||||
t.Run("missing records contract is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete missing records = output:%q err:%v", out, err)
|
||||
@@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
t.Run("remaining record is not success", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
|
||||
}}
|
||||
@@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
|
||||
writes := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool == "delete_records" {
|
||||
writes++
|
||||
return `{"deletedCount":0}`, nil
|
||||
}
|
||||
requested := args["recordIds"].([]string)
|
||||
records := make([]map[string]any, 0, len(requested))
|
||||
if writes == 0 {
|
||||
for _, id := range requested {
|
||||
records = append(records, map[string]any{"recordId": id})
|
||||
}
|
||||
} else if writes == 2 {
|
||||
records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}})
|
||||
}
|
||||
return recordListJSON(t, records), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err == nil || out != "" {
|
||||
|
||||
@@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *
|
||||
if err != nil || len(workflows) != 1 {
|
||||
t.Fatalf("workflow projection = %#v, %v", workflows, err)
|
||||
}
|
||||
workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}})
|
||||
if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" {
|
||||
t.Fatalf("workflow flowId projection = %#v, %v", workflows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
|
||||
@@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -237,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) {
|
||||
if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) {
|
||||
t.Fatal("top-level projected config must match")
|
||||
}
|
||||
invalid := []map[string]any{
|
||||
{"columns": "bad", "custom": map[string]any{}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}},
|
||||
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}},
|
||||
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}},
|
||||
}
|
||||
for _, view := range invalid {
|
||||
if got, ok := projectedVisibleFieldIDs(view); ok || got != nil {
|
||||
t.Errorf("invalid projection %#v = %#v, %v", view, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"viewId":"v1"}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -7,12 +7,17 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const viewPresetReadbackAttempts = 8
|
||||
|
||||
var viewPresetSleep = time.Sleep
|
||||
|
||||
var ViewPresetApply = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+view-preset-apply",
|
||||
@@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
|
||||
if action == "create" {
|
||||
viewID = findStringByKeys(writeData, "viewId")
|
||||
}
|
||||
readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
|
||||
if verifyErr == nil && !found {
|
||||
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
|
||||
}
|
||||
verifiedMatches := viewsByExactName(verifiedViews, name)
|
||||
if verifyErr == nil && len(verifiedMatches) != 1 {
|
||||
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
|
||||
}
|
||||
if verifyErr == nil {
|
||||
actualID := stringValue(verifiedMatches[0], "viewId", "id")
|
||||
if actualID == "" || (viewID != "" && actualID != viewID) {
|
||||
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
|
||||
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
|
||||
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
|
||||
} else {
|
||||
viewID = actualID
|
||||
var verifiedMatches []map[string]any
|
||||
var verifyErr error
|
||||
for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 12*time.Second {
|
||||
backoff = 12 * time.Second
|
||||
}
|
||||
viewPresetSleep(backoff)
|
||||
}
|
||||
readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
verifyErr = readErr
|
||||
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
|
||||
if verifyErr == nil && !found {
|
||||
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
|
||||
}
|
||||
verifiedMatches = viewsByExactName(verifiedViews, name)
|
||||
if verifyErr == nil && len(verifiedMatches) != 1 {
|
||||
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
|
||||
if len(verifiedMatches) > 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if verifyErr == nil {
|
||||
actualID := stringValue(verifiedMatches[0], "viewId", "id")
|
||||
if actualID == "" || (viewID != "" && actualID != viewID) {
|
||||
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
|
||||
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
|
||||
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
|
||||
} else {
|
||||
viewID = actualID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if verifyErr != nil {
|
||||
@@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a
|
||||
if actualType != "" && actualType != viewType {
|
||||
return false
|
||||
}
|
||||
actualConfig, ok := view["config"].(map[string]any)
|
||||
return ok && mapContains(actualConfig, config)
|
||||
actualConfig := make(map[string]any, len(config))
|
||||
if nested, ok := view["config"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
actualConfig[key] = value
|
||||
}
|
||||
}
|
||||
for key := range config {
|
||||
if value, ok := view[key]; ok {
|
||||
actualConfig[key] = value
|
||||
}
|
||||
}
|
||||
if _, wanted := config["visibleFieldIds"]; wanted {
|
||||
if visible, ok := projectedVisibleFieldIDs(view); ok {
|
||||
actualConfig["visibleFieldIds"] = visible
|
||||
}
|
||||
}
|
||||
return mapContains(actualConfig, config)
|
||||
}
|
||||
|
||||
// get_views projects visible fields as columns plus hiddenFields instead of
|
||||
// echoing create_view's config.visibleFieldIds input. Different deployments
|
||||
// return hiddenFields either as a fieldId-keyed object or a parallel array.
|
||||
func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
|
||||
columns, columnsOK := view["columns"].([]any)
|
||||
custom, customOK := view["custom"].(map[string]any)
|
||||
if !columnsOK || !customOK {
|
||||
return nil, false
|
||||
}
|
||||
if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
|
||||
visible := make([]any, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
fieldID, fieldOK := column.(string)
|
||||
if !fieldOK {
|
||||
return nil, false
|
||||
}
|
||||
isHidden, exists := hidden[fieldID]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
hiddenFlag, hiddenTypeOK := isHidden.(bool)
|
||||
if !hiddenTypeOK {
|
||||
return nil, false
|
||||
}
|
||||
if !hiddenFlag {
|
||||
visible = append(visible, fieldID)
|
||||
}
|
||||
}
|
||||
return visible, true
|
||||
}
|
||||
hidden, hiddenOK := custom["hiddenFields"].([]any)
|
||||
if !hiddenOK || len(columns) != len(hidden) {
|
||||
return nil, false
|
||||
}
|
||||
visible := make([]any, 0, len(columns))
|
||||
for index, column := range columns {
|
||||
fieldID, fieldOK := column.(string)
|
||||
isHidden, hiddenTypeOK := hidden[index].(bool)
|
||||
if !fieldOK || !hiddenTypeOK {
|
||||
return nil, false
|
||||
}
|
||||
if !isHidden {
|
||||
visible = append(visible, fieldID)
|
||||
}
|
||||
}
|
||||
return visible, true
|
||||
}
|
||||
|
||||
func mapContains(actual, expected map[string]any) bool {
|
||||
|
||||
@@ -7,13 +7,21 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
|
||||
|
||||
func disableViewPresetSleep(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &viewPresetSleep, func(time.Duration) {})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
t.Run("create and verify exact config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
@@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) {
|
||||
t.Fatalf("projected view preset = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) {
|
||||
t.Fatalf("keyed projected view preset = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unchanged preset makes no write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
@@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
steps := []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
}
|
||||
for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ {
|
||||
steps = append(steps, upsertByKeyStep{text: `{"views":[]}`})
|
||||
}
|
||||
steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`})
|
||||
caller := &upsertByKeyCaller{steps: steps}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 {
|
||||
t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
t.Run("duplicate name stops before write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
|
||||
@@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e
|
||||
all = append(all, Candidate{ID: id, Name: candidateName})
|
||||
}
|
||||
next, hasMore, hasMoreKnown := pagination(data)
|
||||
if hasMoreKnown && !hasMore {
|
||||
return selectCandidate("base", query, dedupe(all), allowFuzzy)
|
||||
}
|
||||
if next == "" {
|
||||
if hasMoreKnown && hasMore {
|
||||
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
|
||||
|
||||
@@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) {
|
||||
reader := &resolverReader{steps: []resolverStep{
|
||||
{data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}},
|
||||
{data: map[string]any{
|
||||
"bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}},
|
||||
"hasMore": false,
|
||||
"nextCursor": "next",
|
||||
}},
|
||||
}}
|
||||
got, err := ResolveBaseName(reader, "项目管理", false)
|
||||
if err != nil || got.Selected.ID != "b1" {
|
||||
t.Fatalf("terminal cursor resolution = %#v, %v", got, err)
|
||||
}
|
||||
if len(reader.calls) != 2 {
|
||||
t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
|
||||
data := map[string]any{"bases": []any{
|
||||
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) {
|
||||
shortcuts := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
hasResult bool
|
||||
resultSchema string
|
||||
identity string
|
||||
cursor string
|
||||
}{
|
||||
{CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"},
|
||||
{CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""},
|
||||
{SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"},
|
||||
{SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
|
||||
{GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""},
|
||||
{SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
|
||||
}
|
||||
for _, item := range shortcuts {
|
||||
if item.rollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout)
|
||||
}
|
||||
if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" {
|
||||
t.Errorf("%s must publish a non-empty Result", item.name)
|
||||
}
|
||||
if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) {
|
||||
t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema)
|
||||
}
|
||||
if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 {
|
||||
t.Errorf("%s must publish reviewed sensitive_paths", item.name)
|
||||
}
|
||||
if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) {
|
||||
t.Errorf("%s Result must not place pagination controls in business data", item.name)
|
||||
}
|
||||
if item.cursor == "" {
|
||||
if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil {
|
||||
t.Errorf("%s unexpectedly publishes Pagination", item.name)
|
||||
}
|
||||
} else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor {
|
||||
t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceDeclarationByName(name string) *shortcut.Shortcut {
|
||||
for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} {
|
||||
if declaration.Command == name {
|
||||
return declaration
|
||||
}
|
||||
}
|
||||
return &shortcut.Shortcut{}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason {
|
||||
t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" {
|
||||
t.Error("get-class must publish a precise unavailable Interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) {
|
||||
explicitEmpty := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{},
|
||||
"adjustmentList": []any{},
|
||||
"atRuleList": []any{},
|
||||
},
|
||||
}
|
||||
if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err)
|
||||
}
|
||||
|
||||
broken := []map[string]any{
|
||||
{},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"items": "not-an-array"}},
|
||||
{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}},
|
||||
}
|
||||
for index, data := range broken {
|
||||
if got, err := searchClassProject(data); err == nil {
|
||||
t.Errorf("broken class response %d returned success: %v", index, got)
|
||||
}
|
||||
if got, err := searchGroupProject(data); err == nil {
|
||||
t.Errorf("broken group response %d returned success: %v", index, got)
|
||||
}
|
||||
}
|
||||
|
||||
ruleBroken := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}},
|
||||
},
|
||||
}
|
||||
if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil {
|
||||
t.Fatalf("rule row without stable ID returned success: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) {
|
||||
withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}}
|
||||
if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("missing totalCount/totalPage must not be reported as a complete page")
|
||||
}
|
||||
firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}}
|
||||
if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("nonzero total with empty page must fail closed")
|
||||
}
|
||||
complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("valid page evidence: %v", err)
|
||||
}
|
||||
if complete || extra["nextPage"] != 2 {
|
||||
t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra)
|
||||
}
|
||||
lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}}
|
||||
complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1)
|
||||
if err != nil || !complete {
|
||||
t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err)
|
||||
}
|
||||
conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}}
|
||||
if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil {
|
||||
t.Fatal("conflicting totalPage/totalCount evidence must fail closed")
|
||||
}
|
||||
mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}}
|
||||
if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("mismatched currentPage must fail closed")
|
||||
}
|
||||
negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}}
|
||||
if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("negative pagination evidence must fail closed")
|
||||
}
|
||||
wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}}
|
||||
if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil {
|
||||
t.Fatal("wrong pagination type must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) {
|
||||
for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} {
|
||||
if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil {
|
||||
t.Fatalf("invalid integer identity accepted: %#v", items)
|
||||
}
|
||||
}
|
||||
if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil {
|
||||
t.Fatalf("valid integer identities rejected: %v", err)
|
||||
}
|
||||
if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil {
|
||||
t.Fatal("blank string identity accepted")
|
||||
}
|
||||
if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil {
|
||||
t.Fatal("duplicate string identity accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) {
|
||||
valid := []map[string]any{
|
||||
{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"},
|
||||
{"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"},
|
||||
}
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil {
|
||||
t.Fatalf("same approveType with unique processCode rejected: %v", err)
|
||||
}
|
||||
valid[0]["processCode"] = " process-1 "
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" {
|
||||
t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err)
|
||||
}
|
||||
valid[0]["approveType"] = "OUT"
|
||||
valid[1]["approveType"] = "OUT"
|
||||
if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil {
|
||||
t.Fatalf("OUT templates with unique processCode rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
items []map[string]any
|
||||
}{
|
||||
{"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}},
|
||||
{"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}},
|
||||
{"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}},
|
||||
{"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}},
|
||||
{"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}},
|
||||
{"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}},
|
||||
{"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil {
|
||||
t.Fatalf("invalid templates accepted: %#v", tc.items)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any)
|
||||
required := items["required"].([]any)
|
||||
for _, name := range []string{"processCode", "approveType", "submitUrl"} {
|
||||
found := false
|
||||
for _, value := range required {
|
||||
if value == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("template schema required=%#v missing %s", required, name)
|
||||
}
|
||||
}
|
||||
if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want {
|
||||
t.Fatalf("template sensitive paths = %q, want %q", got, want)
|
||||
}
|
||||
properties := items["properties"].(map[string]any)
|
||||
for _, name := range []string{"processCode", "submitUrl"} {
|
||||
property := properties[name].(map[string]any)
|
||||
if property["type"] != "string" || property["minLength"] != float64(1) {
|
||||
t.Errorf("template schema %s = %#v, want nonempty string", name, property)
|
||||
}
|
||||
}
|
||||
approveType := properties["approveType"].(map[string]any)
|
||||
if approveType["type"] != "string" {
|
||||
t.Errorf("template schema approveType = %#v, want string", approveType)
|
||||
}
|
||||
enum := approveType["enum"].([]any)
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
values = append(values, value.(string))
|
||||
}
|
||||
if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want {
|
||||
t.Errorf("template approveType enum = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed."
|
||||
|
||||
func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description, identity, identity,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceApproveTemplateResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec {
|
||||
identityConstraint := `"minLength":1`
|
||||
if identityType == "integer" {
|
||||
identityConstraint = `"minimum":1`
|
||||
}
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, identity, identityType, identityConstraint, identity, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: cursorParameter,
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func hardenPublicAttendanceContracts() {
|
||||
// Composite Shortcut properties are the stable published workflow inputs.
|
||||
// Execute owns the explicit adapters to the differently named MCP fields;
|
||||
// redirecting an existing non-empty Schema property requires a versioned
|
||||
// migration and cannot be folded into this hardening change.
|
||||
CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}}
|
||||
CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}}
|
||||
GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
|
||||
SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}}
|
||||
SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}}
|
||||
SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
|
||||
GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}}
|
||||
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
description string
|
||||
identity string
|
||||
identityType string
|
||||
cursor string
|
||||
}{
|
||||
{&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"},
|
||||
{&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""},
|
||||
{&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""},
|
||||
{&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"},
|
||||
{&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"},
|
||||
{&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"},
|
||||
}
|
||||
for _, item := range collections {
|
||||
item.declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType)
|
||||
if item.cursor != "" {
|
||||
item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor)
|
||||
}
|
||||
}
|
||||
CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"}
|
||||
CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"}
|
||||
ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"}
|
||||
GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive
|
||||
GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult()
|
||||
GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"}
|
||||
SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"}
|
||||
SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"}
|
||||
SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"}
|
||||
GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive
|
||||
GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id")
|
||||
GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"}
|
||||
markAttendanceCompatibilityOnly(&GetSummary)
|
||||
markAttendanceCompatibilityOnly(&ListLeaveTypes)
|
||||
markAttendanceCompatibilityOnly(&GetLeaveRecords)
|
||||
markAttendanceCompatibilityOnly(&GetCheckinRecord)
|
||||
markAttendanceCompatibilityOnly(&GetSchedule)
|
||||
markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。")
|
||||
markAttendanceCompatibilityOnly(&GetSelfSetting)
|
||||
}
|
||||
|
||||
func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: attendanceCompatibilityInterfaceReason,
|
||||
}
|
||||
}
|
||||
|
||||
func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceUnavailable,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCallCollection(
|
||||
rt *shortcut.RuntimeContext,
|
||||
product, tool string,
|
||||
params map[string]any,
|
||||
collection string,
|
||||
complete bool,
|
||||
extra map[string]any,
|
||||
validate func([]map[string]any) error,
|
||||
paths ...string,
|
||||
) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(items); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return attendanceOutputCollection(rt, collection, items, complete, extra, false, "")
|
||||
}
|
||||
|
||||
func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireResult(data, product+"/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
|
||||
data, err := rt.CallMCPData(product, tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any {
|
||||
payload := map[string]any{
|
||||
"count": len(items),
|
||||
collection: items,
|
||||
"complete": complete,
|
||||
}
|
||||
for key, value := range extra {
|
||||
payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error {
|
||||
payload := attendanceCollectionPayload(collection, items, complete, legacyExtra)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
business := map[string]any{"count": len(items), collection: items}
|
||||
meta := &output.Meta{Count: output.NewCount(len(items))}
|
||||
if paginated {
|
||||
pagination, err := output.NewPagination(complete, nextToken)
|
||||
if err != nil {
|
||||
return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
meta.Pagination = pagination
|
||||
}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error {
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := attendanceNestedValue(item, path...)
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
identity, ok := attendancePositiveInteger(value)
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index))
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index))
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error {
|
||||
expected = strings.TrimSpace(expected)
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
value, ok := item[field].(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field))
|
||||
}
|
||||
if expected != "" && value != expected {
|
||||
return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field))
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field))
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error {
|
||||
expectedType = strings.TrimSpace(expectedType)
|
||||
seenProcessCodes := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
processCodeValue, present := item["processCode"]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index))
|
||||
}
|
||||
processCode, ok := processCodeValue.(string)
|
||||
processCode = strings.TrimSpace(processCode)
|
||||
if !ok || processCode == "" {
|
||||
return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index))
|
||||
}
|
||||
item["processCode"] = processCode
|
||||
if _, duplicate := seenProcessCodes[processCode]; duplicate {
|
||||
return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index))
|
||||
}
|
||||
seenProcessCodes[processCode] = struct{}{}
|
||||
|
||||
approveType, ok := item["approveType"].(string)
|
||||
if !ok || approveType != expectedType {
|
||||
return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index))
|
||||
}
|
||||
submitURL, ok := item["submitUrl"].(string)
|
||||
if !ok || strings.TrimSpace(submitURL) == "" {
|
||||
return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceNestedValue(item map[string]any, path ...string) (any, bool) {
|
||||
var current any = item
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func attendancePositiveInteger(value any) (int64, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return int64(number), number > 0
|
||||
case int32:
|
||||
return int64(number), number > 0
|
||||
case int64:
|
||||
return number, number > 0
|
||||
case float64:
|
||||
return int64(number), number > 0 && number == float64(int64(number))
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(string(number), 10, 64)
|
||||
return parsed, err == nil && parsed > 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceValidateUserIDs(values []string, maximum int) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("用户 ID 列表不能为空")
|
||||
}
|
||||
if maximum > 0 && len(values) > maximum {
|
||||
return fmt.Errorf("用户 ID 数量不能超过 %d", maximum)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("用户 ID 不能为空字符串")
|
||||
}
|
||||
if value != trimmed {
|
||||
return fmt.Errorf("用户 ID 不能包含首尾空白")
|
||||
}
|
||||
if _, duplicate := seen[trimmed]; duplicate {
|
||||
return fmt.Errorf("用户 ID 不能重复")
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error {
|
||||
users := make(map[string]struct{}, len(requestedUsers))
|
||||
for _, user := range requestedUsers {
|
||||
users[user] = struct{}{}
|
||||
}
|
||||
for index, item := range items {
|
||||
rawUser, present := item[userField]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index))
|
||||
}
|
||||
user, ok := rawUser.(string)
|
||||
user = strings.TrimSpace(user)
|
||||
if !ok || user == "" {
|
||||
return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index))
|
||||
}
|
||||
if _, requested := users[user]; !requested {
|
||||
return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index))
|
||||
}
|
||||
rawTimestamp, present := item[timeField]
|
||||
if !present {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", 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))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error {
|
||||
users := make(map[string]struct{}, len(requestedUsers))
|
||||
for _, user := range requestedUsers {
|
||||
users[user] = struct{}{}
|
||||
}
|
||||
for index, item := range items {
|
||||
user, ok := item["userId"].(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))
|
||||
}
|
||||
bizType, ok := attendancePositiveInteger(item["bizType"])
|
||||
if !ok {
|
||||
return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index))
|
||||
}
|
||||
if _, requested := requestedTypes[int(bizType)]; !requested {
|
||||
return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index))
|
||||
}
|
||||
begin, beginOK := attendancePositiveInteger(item["beginTime"])
|
||||
end, endOK := attendancePositiveInteger(item["endTime"])
|
||||
if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis {
|
||||
return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidateMonthRange(startText, endText string) error {
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err)
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err)
|
||||
}
|
||||
if end.Before(start) {
|
||||
return fmt.Errorf("--end 不能早于 --start")
|
||||
}
|
||||
if end.After(start.AddDate(0, 1, 0)) {
|
||||
return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error {
|
||||
_, _, err := attendancePageInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) {
|
||||
if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效")
|
||||
}
|
||||
result, err := responsecheck.RequireObjectResult(data, operation)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
extra := map[string]any{"page": page, "limit": limit}
|
||||
if raw, present := result["currentPage"]; present {
|
||||
currentPage, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数")
|
||||
}
|
||||
if currentPage != page {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致")
|
||||
}
|
||||
}
|
||||
var evidence []bool
|
||||
var totalPageValue, totalCountValue *int
|
||||
if raw, present := result["totalPage"]; present {
|
||||
totalPage, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数")
|
||||
}
|
||||
if totalPage < 0 {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数")
|
||||
}
|
||||
extra["totalPage"] = totalPage
|
||||
totalPageValue = &totalPage
|
||||
if totalPage == 0 {
|
||||
if page != 1 || itemCount != 0 {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空")
|
||||
}
|
||||
evidence = append(evidence, true)
|
||||
} else {
|
||||
if page > totalPage {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage")
|
||||
}
|
||||
evidence = append(evidence, page == totalPage)
|
||||
}
|
||||
}
|
||||
if raw, present := result["totalCount"]; present {
|
||||
totalCount, ok := attendanceInt(raw)
|
||||
if !ok {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数")
|
||||
}
|
||||
if totalCount < 0 {
|
||||
return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数")
|
||||
}
|
||||
extra["totalCount"] = totalCount
|
||||
totalCountValue = &totalCount
|
||||
if totalCount > 0 && itemCount == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止")
|
||||
}
|
||||
pageStart := (page - 1) * limit
|
||||
if pageStart > totalCount || pageStart+itemCount > totalCount {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾")
|
||||
}
|
||||
evidence = append(evidence, page*limit >= totalCount)
|
||||
}
|
||||
if len(evidence) == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整")
|
||||
}
|
||||
complete := evidence[0]
|
||||
for _, candidate := range evidence[1:] {
|
||||
if candidate != complete {
|
||||
return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据")
|
||||
}
|
||||
}
|
||||
if totalPageValue != nil && totalCountValue != nil {
|
||||
expectedPages := 0
|
||||
if *totalCountValue > 0 {
|
||||
expectedPages = (*totalCountValue + limit - 1) / limit
|
||||
}
|
||||
if *totalPageValue != expectedPages {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾")
|
||||
}
|
||||
}
|
||||
if !complete && itemCount == 0 {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进")
|
||||
}
|
||||
if !complete && itemCount != limit {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾")
|
||||
}
|
||||
if complete && totalCountValue != nil {
|
||||
pageStart := (page - 1) * limit
|
||||
if pageStart+itemCount != *totalCountValue {
|
||||
return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount")
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
extra["nextPage"] = page + 1
|
||||
}
|
||||
return complete, extra, nil
|
||||
}
|
||||
|
||||
func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) {
|
||||
page, limit := rt.Int("page"), rt.Int("limit")
|
||||
if page < 1 {
|
||||
return 0, 0, fmt.Errorf("--page 必须大于等于 1")
|
||||
}
|
||||
if limit < 1 || limit > 200 {
|
||||
return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间")
|
||||
}
|
||||
return page, limit, nil
|
||||
}
|
||||
|
||||
func attendanceInt(value any) (int, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return number, true
|
||||
case int32:
|
||||
return int(number), true
|
||||
case int64:
|
||||
return int(number), true
|
||||
case float64:
|
||||
if number != float64(int(number)) {
|
||||
return 0, false
|
||||
}
|
||||
return int(number), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||
// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||
// get_class_list nests the list under result.items and wraps each shift's
|
||||
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
|
||||
// silently returns empty despite the backend returning shifts.
|
||||
func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
const raw = `{"result":{"items":[
|
||||
func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
const raw = `{"success":true,"result":{"items":[
|
||||
{"shiftVO":{"id":957395083,"name":"default shift"}},
|
||||
{"shiftVO":{"id":957395084,"name":"morning shift"}}
|
||||
]}}`
|
||||
@@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := searchClassProject(data)
|
||||
got, err := searchClassProject(data)
|
||||
if err != nil {
|
||||
t.Fatalf("project class response: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
@@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||
// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
|
||||
// entityVO. The resolver must probe those keys and unwrap entityVO or
|
||||
// +search-overtime-rule / +search-adjustment-rule silently return empty.
|
||||
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, key string
|
||||
}{
|
||||
{"overtime", "atRuleList"},
|
||||
{"adjustment", "adjustmentList"},
|
||||
} {
|
||||
raw := `{"result":{"` + tc.key + `":[
|
||||
raw := `{"success":true,"result":{"` + tc.key + `":[
|
||||
{"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}},
|
||||
{"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}}
|
||||
]}}`
|
||||
@@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
|
||||
}
|
||||
got := searchRuleProject(data)
|
||||
got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] project rule response: %v", tc.name, err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_attendance.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "attendance" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "attendance" {
|
||||
continue
|
||||
}
|
||||
if _, exists := registered[item.Command]; exists {
|
||||
t.Fatalf("duplicate %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 35 || len(source.Shortcuts) != 35 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public := 0
|
||||
compatibilityVisible := 0
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+get-adjustment-rule": true,
|
||||
"+get-checkin-record": true,
|
||||
"+get-leave-records": true,
|
||||
"+get-self-setting": true,
|
||||
"+get-schedule": true,
|
||||
"+get-summary": true,
|
||||
"+list-leave-types": true,
|
||||
"+my-attendance": true,
|
||||
"+query-report-data": true,
|
||||
"+search-group": true,
|
||||
"+this-month": true,
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s: semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("%s: public available shortcut is not executable/visible", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
|
||||
}
|
||||
} else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible {
|
||||
t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil {
|
||||
wantInterfaceAvailability := "unavailable"
|
||||
if record.CompatibilityVisible {
|
||||
wantInterfaceAvailability = "available"
|
||||
}
|
||||
if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s: runtime compatibility interface is not explicit", command)
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 8 || compatibilityVisible != 11 {
|
||||
t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible)
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct {
|
||||
Service string `json:"service"`
|
||||
Availability shortcut.Availability `json:"default_availability"`
|
||||
Shortcuts map[string]struct {
|
||||
Disposition shortcut.SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk shortcut.Risk `json:"risk"`
|
||||
Availability shortcut.Availability `json:"availability"`
|
||||
Primary string `json:"primary"`
|
||||
Public bool `json:"public"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Disposition shortcut.SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk shortcut.Risk `json:"risk"`
|
||||
Availability shortcut.Availability `json:"availability"`
|
||||
Primary string `json:"primary"`
|
||||
Public bool `json:"public"`
|
||||
CompatibilityVisible bool `json:"compatibility_visible"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
} `json:"shortcuts"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_mail.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "mail" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "mail" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 18 || len(source.Shortcuts) != 18 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
public := 0
|
||||
unavailable := 0
|
||||
compatibilityVisible := 0
|
||||
wantCompatibilityVisible := map[string]bool{
|
||||
"+contact-list": true,
|
||||
"+recent-mail": true,
|
||||
"+tag-list": true,
|
||||
"+template-list": true,
|
||||
"+thread-list": true,
|
||||
"+unread-mail": true,
|
||||
}
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s: semantic review facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("%s: public available shortcut is not executable/visible", command)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
|
||||
}
|
||||
}
|
||||
if availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
|
||||
}
|
||||
wantInterfaceAvailability := "unavailable"
|
||||
if record.CompatibilityVisible {
|
||||
wantInterfaceAvailability = "available"
|
||||
}
|
||||
if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s: runtime compatibility interface is not explicit", command)
|
||||
}
|
||||
if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command)
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible {
|
||||
compatibilityVisible++
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if public != 8 || unavailable != 10 || compatibilityVisible != 6 {
|
||||
t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible)
|
||||
}
|
||||
}
|
||||
@@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{
|
||||
// the clean output projection applied to every list command.
|
||||
// Field names are probed defensively across candidate keys.
|
||||
func searchUserProject(data map[string]any) []map[string]any {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
var raw []any
|
||||
switch result := data["result"].(type) {
|
||||
case []any:
|
||||
raw = result
|
||||
case map[string]any:
|
||||
raw = []any{result}
|
||||
default:
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package contact
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"name": "Alice",
|
||||
"userId": "u1",
|
||||
"flowerName": "A",
|
||||
"openDingTalkId": "open-1",
|
||||
"title": "Engineer",
|
||||
"unrelatedPrivate": "drop-me",
|
||||
},
|
||||
})
|
||||
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
|
||||
t.Fatalf("object envelope projection = %#v, want one user", users)
|
||||
}
|
||||
if _, exists := users[0]["unrelatedPrivate"]; exists {
|
||||
t.Fatalf("projection leaked unrelated field: %#v", users[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}})
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("empty result projected users: %#v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{
|
||||
"result": []any{map[string]any{"name": "Alice", "userId": "u1"}},
|
||||
})
|
||||
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
|
||||
t.Fatalf("array envelope projection = %#v, want one user", users)
|
||||
}
|
||||
if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 {
|
||||
t.Fatalf("missing result projected users: %#v", users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed."
|
||||
|
||||
func mailCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
identity := mailCollectionIdentitySchema(collection)
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
SensitivePaths: mailCollectionSensitivePaths(collection),
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, identity, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func mailCollectionSensitivePaths(collection string) []string {
|
||||
switch collection {
|
||||
case "threads":
|
||||
return []string{"threads.subject", "threads.lastUpdated"}
|
||||
case "folders":
|
||||
return []string{"folders.name"}
|
||||
case "tags":
|
||||
return []string{"tags.name"}
|
||||
case "users":
|
||||
return []string{"users.name", "users.email", "users.employeeNo"}
|
||||
case "templates":
|
||||
return []string{"templates.name", "templates.subject"}
|
||||
case "contacts":
|
||||
return []string{"contacts.contactEmail", "contacts.displayName"}
|
||||
case "messages":
|
||||
return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"}
|
||||
default:
|
||||
panic("unsupported Mail collection sensitive paths: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func mailCollectionIdentitySchema(collection string) string {
|
||||
switch collection {
|
||||
case "threads":
|
||||
return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]`
|
||||
case "folders", "tags", "templates", "contacts", "messages":
|
||||
return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]`
|
||||
case "users":
|
||||
return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]`
|
||||
default:
|
||||
panic("unsupported Mail collection Result identity: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func mailCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func mailObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description,
|
||||
)),
|
||||
SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"},
|
||||
}
|
||||
}
|
||||
|
||||
func mailReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
||||
if !required && !rt.Changed(flag) {
|
||||
return nil
|
||||
}
|
||||
value := rt.Int(flag)
|
||||
if value < 1 || value > 100 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) {
|
||||
if !required && !rt.Changed(flag) {
|
||||
return "", nil
|
||||
}
|
||||
value, err := strconv.Atoi(rt.Str(flag))
|
||||
if err != nil || value < 1 || value > 100 {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
|
||||
}
|
||||
return strconv.Itoa(value), nil
|
||||
}
|
||||
|
||||
func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
||||
_, err := mailStringPageSize(rt, flag, required)
|
||||
return err
|
||||
}
|
||||
|
||||
func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
|
||||
if strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailValidateThreadList(rt *shortcut.RuntimeContext) error {
|
||||
if err := mailValidatePageSize(rt, "limit", true); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed := make(map[string]time.Time, 2)
|
||||
for _, flag := range []string{"start", "end"} {
|
||||
if !rt.Changed(flag) {
|
||||
continue
|
||||
}
|
||||
value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag)))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag))
|
||||
}
|
||||
_, offset := value.Zone()
|
||||
if offset != 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag))
|
||||
}
|
||||
parsed[flag] = value
|
||||
}
|
||||
start, hasStart := parsed["start"]
|
||||
end, hasEnd := parsed["end"]
|
||||
if hasStart && hasEnd && end.Before(start) {
|
||||
return apperrors.NewValidation("--end 不能早于 --start")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailWriteSafety(idempotency string) contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
|
||||
}
|
||||
|
||||
func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "mail " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Result: result,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail", Name: name, CanonicalPath: "mail." + name,
|
||||
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: flags,
|
||||
}
|
||||
}
|
||||
|
||||
func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...)
|
||||
contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"}
|
||||
return contractDecl
|
||||
}
|
||||
|
||||
func mailResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// mailRequireSuccess accepts only the two success encodings observed from the
|
||||
// Mail backend: boolean true and the exact string "true". Missing, false, or
|
||||
// malformed status values fail closed.
|
||||
func mailRequireSuccess(data map[string]any, operation string) error {
|
||||
if len(data) == 0 {
|
||||
return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
|
||||
}
|
||||
switch value := data["success"].(type) {
|
||||
case bool:
|
||||
if value {
|
||||
return nil
|
||||
}
|
||||
case string:
|
||||
if value == "true" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, present := data["success"]; !present {
|
||||
return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态")
|
||||
}
|
||||
message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务未明确返回成功状态"
|
||||
}
|
||||
return mailResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
|
||||
func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := mailLookup(data, path)
|
||||
if !present {
|
||||
return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
|
||||
}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := mailLookup(data, path)
|
||||
if !present || value == nil {
|
||||
return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
|
||||
actual := mailFirstString(object, keys...)
|
||||
if actual == "" {
|
||||
return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID")
|
||||
}
|
||||
if expected != "" && actual != expected {
|
||||
return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error {
|
||||
allowNumericID := false
|
||||
for _, key := range identityKeys {
|
||||
if key == "email" {
|
||||
allowNumericID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for index, item := range items {
|
||||
valid := mailFirstString(item, identityKeys...) != ""
|
||||
if !valid && allowNumericID {
|
||||
valid = mailNumericIdentity(item["id"])
|
||||
}
|
||||
if !valid {
|
||||
return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailNumericIdentity(value any) bool {
|
||||
switch value.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mailProjectValue(operation, outputName string, value any) (any, bool, error) {
|
||||
if value == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
switch outputName {
|
||||
case "email":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串")
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
return text, text != "", nil
|
||||
case "userId":
|
||||
if text, ok := value.(string); ok {
|
||||
text = strings.TrimSpace(text)
|
||||
return text, text != "", nil
|
||||
}
|
||||
if !mailNumericIdentity(value) {
|
||||
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字")
|
||||
}
|
||||
return value, true, nil
|
||||
default:
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
return text, true, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) {
|
||||
items, err := mailRequireCollection(data, operation, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mailValidateRows(items, operation, identityKeys...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projected := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
row := make(map[string]any, len(fields))
|
||||
for outputName, candidates := range fields {
|
||||
for _, candidate := range candidates {
|
||||
if value, present := item[candidate]; present && value != nil {
|
||||
projectedValue, include, err := mailProjectValue(operation, outputName, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
row[outputName] = projectedValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
projected = append(projected, row)
|
||||
}
|
||||
return projected, nil
|
||||
}
|
||||
|
||||
func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
|
||||
container := data
|
||||
if prefix != "" {
|
||||
value, present := mailLookup(data, prefix)
|
||||
if !present {
|
||||
return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象")
|
||||
}
|
||||
var ok bool
|
||||
container, ok = value.(map[string]any)
|
||||
if !ok {
|
||||
return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象")
|
||||
}
|
||||
}
|
||||
nextValue, nextPresent := container["nextCursor"]
|
||||
next, nextOK := nextValue.(string)
|
||||
if !nextPresent || !nextOK {
|
||||
return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整")
|
||||
}
|
||||
if raw, present := container["hasMore"]; present {
|
||||
hasMore, ok := mailBool(raw)
|
||||
if !ok {
|
||||
return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值")
|
||||
}
|
||||
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
|
||||
if hasMore && terminal {
|
||||
return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空")
|
||||
}
|
||||
if !hasMore && !terminal {
|
||||
return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
|
||||
}
|
||||
if !hasMore {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
|
||||
func mailBool(value any) (bool, bool) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
if typed == "true" {
|
||||
return true, true
|
||||
}
|
||||
if typed == "false" {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func mailLookup(object map[string]any, path string) (any, bool) {
|
||||
var current any = object
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
mapped, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = mapped[segment]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func mailFirstString(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
|
||||
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
|
||||
if !complete {
|
||||
payload["nextCursor"] = next
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any {
|
||||
return map[string]any{"count": len(items), collection: items}
|
||||
}
|
||||
|
||||
// mailOutputPage preserves legacy payload compatibility while keeping cursor
|
||||
// controls in the unified envelope's meta.pagination contract.
|
||||
func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
|
||||
payload := mailCollectionPayload(collection, items, complete, next)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
pagination, err := output.NewPagination(complete, next)
|
||||
if err != nil {
|
||||
return mailResponseError("mail/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
business := map[string]any{"count": len(items), collection: items}
|
||||
meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func hardenPublicMailContracts() {
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
description string
|
||||
paginated bool
|
||||
}{
|
||||
{&ThreadList, "threads", "严格校验的邮件会话列表", true},
|
||||
{&FolderList, "folders", "严格校验的邮箱文件夹列表", false},
|
||||
{&TagList, "tags", "严格校验的邮件标签列表", false},
|
||||
{&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true},
|
||||
{&TemplateList, "templates", "严格校验的邮件模板列表", true},
|
||||
{&ContactList, "contacts", "严格校验的邮件联系人列表", true},
|
||||
}
|
||||
for _, item := range collections {
|
||||
item.declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description)
|
||||
if item.paginated {
|
||||
item.declaration.Contract.Pagination = mailCursorPagination()
|
||||
}
|
||||
item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}
|
||||
}
|
||||
markMailCompatibilityOnly(&ThreadList)
|
||||
markMailCompatibilityOnly(&TagList)
|
||||
markMailCompatibilityOnly(&TemplateList)
|
||||
markMailCompatibilityOnly(&ContactList)
|
||||
}
|
||||
|
||||
func markMailCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: mailCompatibilityInterfaceReason,
|
||||
}
|
||||
}
|
||||
|
||||
func markMailUnavailable(declaration *shortcut.Shortcut, reason string) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceUnavailable,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
+132
-399
@@ -18,8 +18,11 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +thread-list",
|
||||
},
|
||||
Description: "列出指定邮箱文件夹下的邮件会话(thread)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "folder", Property: "folder"},
|
||||
{Name: "limit", Property: "limit"},
|
||||
{Name: "start", Property: "start"},
|
||||
{Name: "end", Property: "end"},
|
||||
{Name: "ascending", Property: "ascending"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"},
|
||||
{Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateThreadList(rt)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +thread-list --email user@company.com --folder 104 --limit 20`,
|
||||
},
|
||||
@@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threads := threadListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(threads), "threads": threads})
|
||||
threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
|
||||
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "threads", threads, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// threadListProject reshapes the raw list_mailbox_threads response into a clean
|
||||
// {conversationId, subject, lastUpdated, isRead} thread list —
|
||||
// clean output projection. Both the list container and per-item
|
||||
// field names are probed defensively across candidate keys, so an empty/unknown
|
||||
// shape yields an empty list rather than a crash or fabricated data.
|
||||
func threadListProject(data map[string]any) []map[string]any {
|
||||
raw := threadListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok {
|
||||
row["conversationId"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "subject", "title", "topic"); ok {
|
||||
row["subject"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok {
|
||||
row["lastUpdated"] = v
|
||||
}
|
||||
if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok {
|
||||
row["isRead"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// threadListResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func threadListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// threadListFirst returns the first present candidate key's value.
|
||||
func threadListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Thread 根据会话 ID 获取会话详情。
|
||||
// ThreadUpdate 修改单个邮件会话的状态或标签。
|
||||
// ThreadBatchUpdate 批量修改邮件会话的状态或标签。
|
||||
@@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +folder-list",
|
||||
},
|
||||
Description: "列出顶层文件夹或指定父文件夹下的子文件夹",
|
||||
Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "email")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +folder-list --email user@company.com`,
|
||||
},
|
||||
@@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folders := folderListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(folders), "folders": folders})
|
||||
folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("folders", folders))
|
||||
},
|
||||
}
|
||||
|
||||
// folderListProject reshapes the raw list_folders response into a clean
|
||||
// {id, name, parentId} folder list — clean output projection. Both
|
||||
// the list container and per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func folderListProject(data map[string]any) []map[string]any {
|
||||
raw := folderListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok {
|
||||
row["parentId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// folderListResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func folderListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "folders"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "folders", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// folderListFirst returns the first present candidate key's value.
|
||||
func folderListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// FolderCreate 创建邮件文件夹。
|
||||
// FolderUpdate 更新邮件文件夹名称。
|
||||
// FolderDelete 删除邮件文件夹。
|
||||
@@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags := tagListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(tags), "tags": tags})
|
||||
tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"name"}, "parentId": {"parentId"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("tags", tags))
|
||||
},
|
||||
}
|
||||
|
||||
// tagListProject reshapes the raw list_tags response into a clean
|
||||
// {id, name, parentId} tag list — clean output projection. Both the
|
||||
// list container and per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func tagListProject(data map[string]any) []map[string]any {
|
||||
raw := tagListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := tagListFirst(m, "parentId", "parent_id"); ok {
|
||||
row["parentId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level deeper.
|
||||
func tagListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "tags"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "tags", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// tagListFirst returns the first present candidate key's value.
|
||||
func tagListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TagCreate 创建邮件标签。
|
||||
// TagUpdate 更新邮件标签名称。
|
||||
// TagDelete 删除邮件标签。
|
||||
@@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +user-search",
|
||||
},
|
||||
Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "employee-no", Property: "employeeNo"},
|
||||
{Name: "limit", Property: "limit"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"},
|
||||
{Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"},
|
||||
{Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := mailValidateStringPageSize(rt, "limit", false); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"keyword", "employee-no"} {
|
||||
if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" {
|
||||
return apperrors.NewValidation("--" + name + " 显式提供时不能为空")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" {
|
||||
return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +user-search --keyword "张三"`,
|
||||
`dws mail +user-search --email user@company.com --employee-no "E123456"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{}
|
||||
if rt.Str("keyword") != "" {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
@@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["size"] = rt.Str("limit")
|
||||
params["size"] = size
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_mail_users", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := userSearchProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{
|
||||
"name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "users", users, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// userSearchProject reshapes the raw search_mail_users response into a clean
|
||||
// {name, email, employeeNo, userId} user list — clean output projection.
|
||||
// Both the list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an empty/unknown shape yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func userSearchProject(data map[string]any) []map[string]any {
|
||||
raw := userSearchResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok {
|
||||
row["email"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok {
|
||||
row["employeeNo"] = v
|
||||
}
|
||||
if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok {
|
||||
row["userId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// userSearchResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array container or nesting one level deeper.
|
||||
func userSearchResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "users"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "users", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// userSearchFirst returns the first present candidate key's value.
|
||||
func userSearchFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ── attachment ─────────────────────────────────────────────
|
||||
|
||||
// AttachmentList 列举邮件附件。
|
||||
@@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +template-list",
|
||||
},
|
||||
Description: "列出指定邮箱的所有邮件模板",
|
||||
Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateStringPageSize(rt, "limit", true)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +template-list --email user@company.com --limit 20`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"email": rt.Str("email"),
|
||||
"size": rt.Str("limit"),
|
||||
"size": size,
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
@@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templates := templateListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
|
||||
templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "name": {"name"}, "subject": {"subject"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "templates", templates, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// templateListProject reshapes the raw list_user_message_templates response into
|
||||
// a clean {id, name, subject} template list — clean output projection.
|
||||
// Both the list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an empty/unknown shape yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func templateListProject(data map[string]any) []map[string]any {
|
||||
raw := templateListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := templateListFirst(m, "subject", "title"); ok {
|
||||
row["subject"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// templateListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level deeper.
|
||||
func templateListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "templates"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "templates", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// templateListFirst returns the first present candidate key's value.
|
||||
func templateListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TemplateGet 获取邮件模板详情。
|
||||
// TemplateUpdate 更新邮件模板。
|
||||
// TemplateDelete 删除邮件模板。
|
||||
@@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +contact-list",
|
||||
},
|
||||
Description: "列出指定邮箱的所有邮件联系人",
|
||||
Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateStringPageSize(rt, "limit", true)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +contact-list --email user@company.com --limit 20`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := mailStringPageSize(rt, "limit", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"email": rt.Str("email"),
|
||||
"size": rt.Str("limit"),
|
||||
"size": size,
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
@@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contacts := contactListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts})
|
||||
contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{
|
||||
"id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mailOutputPage(rt, "contacts", contacts, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// contactListProject reshapes the raw list_user_mail_contacts response into a
|
||||
// clean {id, contactEmail, displayName} contact list — output-projection
|
||||
// clean output projection. Both the list container and per-item field names are probed
|
||||
// defensively across candidate keys, so an empty/unknown shape yields an empty
|
||||
// list rather than a crash or fabricated data.
|
||||
func contactListProject(data map[string]any) []map[string]any {
|
||||
raw := contactListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok {
|
||||
row["id"] = v
|
||||
}
|
||||
if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok {
|
||||
row["contactEmail"] = v
|
||||
}
|
||||
if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok {
|
||||
row["displayName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contactListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level deeper.
|
||||
func contactListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "contacts"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "contacts", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// contactListFirst returns the first present candidate key's value.
|
||||
func contactListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ContactUpdate 更新邮件联系人。
|
||||
// ContactBatchDelete 批量删除邮件联系人。
|
||||
// ── auto-reply ─────────────────────────────────────────────
|
||||
@@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
// RuleDelete 删除个人收信规则。
|
||||
// RuleAdjust 调整收信规则排序。
|
||||
func init() {
|
||||
hardenPublicMailContracts()
|
||||
shortcut.Register(
|
||||
ThreadList,
|
||||
FolderList,
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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"
|
||||
)
|
||||
|
||||
func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
errors map[string]error
|
||||
wantErr bool
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1},
|
||||
{name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2},
|
||||
{name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
|
||||
{name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2},
|
||||
{name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1},
|
||||
{name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2},
|
||||
{name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
|
||||
{name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors}
|
||||
if caller.responses == nil {
|
||||
caller.responses = map[string][]string{}
|
||||
}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, tc.args...)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("invalid read unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid read failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := mailValidateMessageIDs(nil); err == nil {
|
||||
t.Fatal("empty ids unexpectedly valid")
|
||||
}
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "})
|
||||
if err := Messages.Execute(rt); err == nil {
|
||||
t.Fatal("defensive empty-id guard unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{
|
||||
name: "top string",
|
||||
data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}},
|
||||
},
|
||||
{
|
||||
name: "top object",
|
||||
data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}},
|
||||
},
|
||||
{
|
||||
name: "result string",
|
||||
data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"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"}}}},
|
||||
},
|
||||
{
|
||||
name: "data string",
|
||||
data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"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"}}}},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := mailMailboxAddresses(tc.data)
|
||||
if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" {
|
||||
t.Fatalf("addresses=%#v err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, data := range map[string]map[string]any{
|
||||
"remote failure": {"success": false},
|
||||
"missing collection": {"success": true},
|
||||
"malformed collection": {"success": true, "emailAccounts": map[string]any{}},
|
||||
"blank string": {"success": true, "emailAccounts": []any{" "}},
|
||||
"missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}},
|
||||
"wrong object email": {"success": true, "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"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailMailboxAddresses(data); err == nil {
|
||||
t.Fatal("invalid mailbox response unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
remoteErr bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`},
|
||||
{name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true},
|
||||
{name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true},
|
||||
{name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`},
|
||||
{name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true},
|
||||
{name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true},
|
||||
{name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true},
|
||||
{name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true},
|
||||
{name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true},
|
||||
{name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true},
|
||||
{name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true},
|
||||
{name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true},
|
||||
{name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true},
|
||||
{name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if tc.remoteErr {
|
||||
caller.errors[tc.tool] = fmt.Errorf("remote failure")
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, tc.args...)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("invalid list response unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid list response failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) {
|
||||
assertPanic := func(name string, fn func()) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
deferred := false
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unsupported declaration did not panic")
|
||||
}
|
||||
deferred = true
|
||||
}()
|
||||
fn()
|
||||
_ = deferred
|
||||
})
|
||||
}
|
||||
assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") })
|
||||
assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") })
|
||||
|
||||
if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil {
|
||||
t.Fatal("object accepted missing success")
|
||||
}
|
||||
if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" {
|
||||
t.Fatalf("valid object rejected: %#v %v", object, err)
|
||||
}
|
||||
if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil {
|
||||
t.Fatal("numeric email unexpectedly projected")
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include {
|
||||
t.Fatalf("blank user id include=%v err=%v", include, err)
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include {
|
||||
t.Fatalf("nil projection include=%v err=%v", include, err)
|
||||
}
|
||||
if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include {
|
||||
t.Fatalf("blank projection include=%v err=%v", include, err)
|
||||
}
|
||||
if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 {
|
||||
t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err)
|
||||
}
|
||||
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing prefix": {},
|
||||
"malformed prefix": {"result": []any{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil {
|
||||
t.Fatal("invalid prefixed page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing cursor": {},
|
||||
"malformed cursor": {"nextCursor": 7},
|
||||
"malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}},
|
||||
"conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := mailPage(data, "mail/page", "", ""); err == nil {
|
||||
t.Fatal("invalid page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" {
|
||||
t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err)
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil {
|
||||
t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
if value, ok := mailBool("true"); !ok || !value {
|
||||
t.Fatal("string true not normalized")
|
||||
}
|
||||
if _, ok := mailBool(7); ok {
|
||||
t.Fatal("invalid boolean unexpectedly normalized")
|
||||
}
|
||||
if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok {
|
||||
t.Fatal("non-object intermediate lookup unexpectedly succeeded")
|
||||
}
|
||||
declaration := UserSearch
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil {
|
||||
t.Fatal("invalid terminal pagination unexpectedly stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) {
|
||||
for name, message := range map[string]map[string]any{
|
||||
"missing addresses": {},
|
||||
"wrong address collection": {"toRecipients": "bad"},
|
||||
"wrong address item": {"toRecipients": []any{7}},
|
||||
"blank address item": {"toRecipients": []any{map[string]any{"email": " "}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil {
|
||||
t.Fatal("malformed recipients unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients")
|
||||
if err != nil || len(addresses) != 2 {
|
||||
t.Fatalf("valid recipients rejected: %v %v", addresses, err)
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient length mismatch accepted")
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient value mismatch accepted")
|
||||
}
|
||||
if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
|
||||
t.Fatal("recipient parser error was not propagated")
|
||||
}
|
||||
if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil {
|
||||
t.Fatal("failed draft receipt unexpectedly succeeded")
|
||||
}
|
||||
for name, message := range map[string]map[string]any{
|
||||
"missing sender": {},
|
||||
"wrong sender": {"from": []any{}},
|
||||
"blank sender": {"from": " "},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil {
|
||||
t.Fatal("malformed sender unexpectedly accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil {
|
||||
t.Fatalf("case-insensitive sender rejected: %v", err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"},
|
||||
{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"},
|
||||
{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"},
|
||||
{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"},
|
||||
} {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, args...); err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
values := map[string]string{"email": "sender@example.invalid", "id": "template-1"}
|
||||
if declaration.Command == "+draft-edit" {
|
||||
values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"}
|
||||
}
|
||||
rt := mailRuntimeForCoverage(t, declaration, values)
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatal("execute-level no-change guard unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("no-change guard made %d calls", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
readback string
|
||||
}{
|
||||
{name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
{name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
{name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {tc.readback},
|
||||
}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil {
|
||||
t.Fatal("draft verification mismatch unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"create_user_message_template": {`{"success":true,"id":"template-1"}`},
|
||||
"get_user_message_template": {`{"success":"false"}`},
|
||||
}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil {
|
||||
t.Fatal("failed template readback unexpectedly succeeded")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
values map[string]string
|
||||
}{
|
||||
{declaration: Message, values: map[string]string{"id": " "}},
|
||||
{declaration: FolderList, values: map[string]string{"email": " "}},
|
||||
} {
|
||||
rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
|
||||
if err := tc.declaration.Validate(rt); err == nil {
|
||||
t.Fatal("direct required-text validator accepted whitespace")
|
||||
}
|
||||
}
|
||||
rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{})
|
||||
if err := UserSearch.Validate(rt); err == nil {
|
||||
t.Fatal("direct user-search validator accepted missing identities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{
|
||||
"get_user_message_template": {`{"id":"template-1"}`},
|
||||
}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{})
|
||||
if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil {
|
||||
t.Fatal("failed template detail response unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%d want=1", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mailConfirmationCaller struct{ calls int }
|
||||
|
||||
func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil
|
||||
}
|
||||
|
||||
func (*mailConfirmationCaller) Format() string { return "json" }
|
||||
func (*mailConfirmationCaller) DryRun() bool { return false }
|
||||
func (*mailConfirmationCaller) Fields() string { return "" }
|
||||
func (*mailConfirmationCaller) JQ() string { return "" }
|
||||
|
||||
func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"},
|
||||
{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"},
|
||||
{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"},
|
||||
{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
caller := &mailConfirmationCaller{}
|
||||
if err := runUnconfirmedMailWrite(t, caller, args...); err == nil {
|
||||
t.Fatalf("unconfirmed %v unexpectedly succeeded", args)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) {
|
||||
declarations := []*struct {
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
}{
|
||||
{FolderList.OutputRollout, FolderList.Contract.Result != nil},
|
||||
{UserSearch.OutputRollout, UserSearch.Contract.Result != nil},
|
||||
{Message.OutputRollout, Message.Contract.Result != nil},
|
||||
{Messages.OutputRollout, Messages.Contract.Result != nil},
|
||||
{Thread.OutputRollout, Thread.Contract.Result != nil},
|
||||
}
|
||||
for index, declaration := range declarations {
|
||||
if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
|
||||
t.Fatalf("mail declaration %d is not unified with Result", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" {
|
||||
t.Errorf("%s missing precise unavailable interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason {
|
||||
t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) {
|
||||
want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false}
|
||||
for _, constraint := range DraftEdit.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintAtLeastOne {
|
||||
continue
|
||||
}
|
||||
for _, flag := range constraint.Flags {
|
||||
if _, ok := want[flag]; ok {
|
||||
want[flag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for flag, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("draft edit missing at-least-one declaration for %s", flag)
|
||||
}
|
||||
}
|
||||
if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil {
|
||||
t.Fatal("messages must declare and execute the 1-100 nonempty ID contract")
|
||||
}
|
||||
userSearchNonempty := false
|
||||
for _, constraint := range UserSearch.Constraints {
|
||||
if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" {
|
||||
userSearchNonempty = true
|
||||
}
|
||||
}
|
||||
if !userSearchNonempty {
|
||||
t.Fatal("user search must declare its explicit nonempty string contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&UserSearch} {
|
||||
if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" {
|
||||
t.Fatalf("%s missing cursor pagination declaration", declaration.Command)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} {
|
||||
if declaration.Contract.Pagination != nil {
|
||||
t.Fatalf("%s must not declare pagination", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
complete bool
|
||||
next string
|
||||
exhausted bool
|
||||
}{
|
||||
{name: "continuing", complete: false, next: "cursor-2", exhausted: false},
|
||||
{name: "terminal", complete: true, next: "", exhausted: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
declaration := UserSearch
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil {
|
||||
t.Fatalf("store pagination: %v", err)
|
||||
}
|
||||
if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
|
||||
t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Pagination *struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
for _, field := range []string{"complete", "hasMore", "nextCursor"} {
|
||||
if _, exists := envelope.Data[field]; exists {
|
||||
t.Fatalf("pagination field %s leaked into data", field)
|
||||
}
|
||||
}
|
||||
if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next {
|
||||
t.Fatalf("pagination=%+v", envelope.Meta.Pagination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) {
|
||||
collections := []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
identity string
|
||||
}{
|
||||
{&FolderList, "folders", "id"},
|
||||
{&UserSearch, "users", "email"},
|
||||
{&Messages, "messages", "id"},
|
||||
}
|
||||
for _, tc := range collections {
|
||||
if len(tc.declaration.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("%s missing sensitive_paths", tc.declaration.Command)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatalf("%s schema: %v", tc.declaration.Command, err)
|
||||
}
|
||||
properties := schema["properties"].(map[string]any)
|
||||
count := properties["count"].(map[string]any)
|
||||
if count["minimum"] != float64(0) {
|
||||
t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"])
|
||||
}
|
||||
items := properties[tc.collection].(map[string]any)["items"].(map[string]any)
|
||||
identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any)
|
||||
if !ok || identity["description"] == "" {
|
||||
t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} {
|
||||
if len(declaration.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("%s missing sensitive_paths", declaration.Command)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatalf("%s schema: %v", declaration.Command, err)
|
||||
}
|
||||
value := schema["properties"].(map[string]any)["value"].(map[string]any)
|
||||
required := value["required"].([]any)
|
||||
if len(required) != 1 || required[0] != "id" {
|
||||
t.Fatalf("%s value does not require id", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) {
|
||||
for _, declaration := range []struct {
|
||||
name string
|
||||
confirmation string
|
||||
}{
|
||||
{DraftCreate.Command, DraftCreate.Safety.Confirmation},
|
||||
{DraftEdit.Command, DraftEdit.Safety.Confirmation},
|
||||
{TemplateCreate.Command, TemplateCreate.Safety.Confirmation},
|
||||
{TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation},
|
||||
} {
|
||||
if declaration.confirmation != "user_required" {
|
||||
t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) {
|
||||
validEmpty := map[string]any{"success": "true", "folders": []any{}}
|
||||
items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err)
|
||||
}
|
||||
for name, fixture := range map[string]map[string]any{
|
||||
"missing success": {"folders": []any{}},
|
||||
"missing collection": {"success": "true"},
|
||||
"wrong collection": {"success": true, "folders": map[string]any{}},
|
||||
"bad item": {"success": "true", "folders": []any{"bad"}},
|
||||
} {
|
||||
if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil {
|
||||
t.Fatalf("%s must fail closed", name)
|
||||
}
|
||||
}
|
||||
if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil {
|
||||
t.Fatal("string false must not be success")
|
||||
}
|
||||
if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil {
|
||||
t.Fatalf("boolean true must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) {
|
||||
complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "")
|
||||
if err != nil || !complete || next != "" {
|
||||
t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err)
|
||||
}
|
||||
complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "")
|
||||
if err != nil || !complete || next != "" {
|
||||
t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err)
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil {
|
||||
t.Fatal("hasMore without a progressing cursor must fail")
|
||||
}
|
||||
if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil {
|
||||
t.Fatal("repeated cursor must fail closed")
|
||||
}
|
||||
if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil {
|
||||
t.Fatal("identity mismatch must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user map[string]any
|
||||
wantKey string
|
||||
absentKey string
|
||||
}{
|
||||
{name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"},
|
||||
{name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"},
|
||||
{name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rows, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{tc.user}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%v err=%v", rows, err)
|
||||
}
|
||||
if _, ok := rows[0][tc.wantKey]; !ok {
|
||||
t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0])
|
||||
}
|
||||
if _, ok := rows[0][tc.absentKey]; ok {
|
||||
t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
); err == nil {
|
||||
t.Fatal("user without a stable identity unexpectedly projected")
|
||||
}
|
||||
for name, user := range map[string]map[string]any{
|
||||
"malformed email with valid id": {"id": "user-1", "email": 7},
|
||||
"malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := mailProjectCollection(
|
||||
map[string]any{"success": true, "users": []any{user}},
|
||||
"mail/search_mail_users", "users", []string{"id", "email"},
|
||||
map[string][]string{"userId": {"id"}, "email": {"email"}},
|
||||
); err == nil {
|
||||
t.Fatal("malformed optional identity unexpectedly passed projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mailWriteCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type mailWriteContractCaller struct {
|
||||
responses map[string][]string
|
||||
errors map[string]error
|
||||
calls []mailWriteCall
|
||||
}
|
||||
|
||||
func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)})
|
||||
if err := caller.errors[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queue := caller.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, fmt.Errorf("unexpected Mail tool call: %s", tool)
|
||||
}
|
||||
text := queue[0]
|
||||
caller.responses[tool] = queue[1:]
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*mailWriteContractCaller) Format() string { return "json" }
|
||||
func (*mailWriteContractCaller) DryRun() bool { return false }
|
||||
func (*mailWriteContractCaller) Fields() string { return "" }
|
||||
func (*mailWriteContractCaller) JQ() string { return "" }
|
||||
|
||||
func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs(append(args, "--yes", "--format", "json"))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func mailDraftReadback(id, subject, body, sender, to, cc string) string {
|
||||
return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantTools []string
|
||||
}{
|
||||
{
|
||||
name: "draft create",
|
||||
args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
},
|
||||
wantTools: []string{"create_draft", "get_email_by_message_id"},
|
||||
},
|
||||
{
|
||||
name: "draft edit",
|
||||
args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
|
||||
"get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
|
||||
},
|
||||
wantTools: []string{"update_draft", "get_email_by_message_id"},
|
||||
},
|
||||
{
|
||||
name: "template create core fields",
|
||||
args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"create_user_message_template": {`{"success":true,"id":"template-1"}`},
|
||||
"get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
|
||||
},
|
||||
wantTools: []string{"create_user_message_template", "get_user_message_template"},
|
||||
},
|
||||
{
|
||||
name: "template update success plus requested id readback",
|
||||
args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"},
|
||||
responses: map[string][]string{
|
||||
"update_user_message_template": {`{"success":true}`},
|
||||
"get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
|
||||
},
|
||||
wantTools: []string{"update_user_message_template", "get_user_message_template"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("write execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != len(tc.wantTools) {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools))
|
||||
}
|
||||
for index, tool := range tc.wantTools {
|
||||
if caller.calls[index].tool != tool {
|
||||
t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
writeBody string
|
||||
readBody string
|
||||
writeErr error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "empty write body", writeBody: "", wantCalls: 1},
|
||||
{name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1},
|
||||
{name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1},
|
||||
{name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2},
|
||||
{name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
|
||||
errors: map[string]error{"create_draft": tc.writeErr},
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
|
||||
if err == nil {
|
||||
t.Fatal("invalid receipt/readback unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1},
|
||||
{name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1},
|
||||
{name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1},
|
||||
{name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
|
||||
{name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
{name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid write unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
}{
|
||||
{name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
|
||||
{name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" {
|
||||
t.Fatalf("cursor was not passed through: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}},
|
||||
errors: map[string]error{},
|
||||
}
|
||||
if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil {
|
||||
t.Fatal("repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) {
|
||||
tooManyIDs := make([]string, 101)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = fmt.Sprintf("message-%d", index)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}},
|
||||
{name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}},
|
||||
{name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}},
|
||||
{name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}},
|
||||
{name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}},
|
||||
{name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}},
|
||||
{name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}},
|
||||
{name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}},
|
||||
{name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}},
|
||||
{name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}},
|
||||
{name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}},
|
||||
{name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}},
|
||||
{name: "message empty id", args: []string{"mail", "+message", "--id", " "}},
|
||||
{name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}},
|
||||
{name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid input unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid input reached remote calls: %d", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) {
|
||||
if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil {
|
||||
t.Fatalf("omitted optional integer limit should remain valid: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
declaration *shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
wantSize string
|
||||
wantPresent bool
|
||||
}{
|
||||
{name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true},
|
||||
{name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true},
|
||||
{name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
found := false
|
||||
for _, flag := range tc.declaration.Flags {
|
||||
if flag.Name != "limit" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if flag.Type != shortcut.FlagString || flag.Default != "" {
|
||||
t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("declaration missing --limit")
|
||||
}
|
||||
cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration))
|
||||
if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" {
|
||||
t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag)
|
||||
}
|
||||
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
|
||||
if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatalf("compatible execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%d, want 1", len(caller.calls))
|
||||
}
|
||||
gotSize, present := caller.calls[0].args["size"]
|
||||
if present != tc.wantPresent || (present && gotSize != tc.wantSize) {
|
||||
t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
values map[string]string
|
||||
}{
|
||||
{name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}},
|
||||
{name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
|
||||
{name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
|
||||
if err := tc.declaration.Execute(rt); err == nil {
|
||||
t.Fatal("direct Execute accepted malformed string limit")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("direct Execute reached %d remote calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) {
|
||||
for _, leaf := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}},
|
||||
{name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}},
|
||||
{name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}},
|
||||
} {
|
||||
for _, invalid := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "blank", value: " "},
|
||||
{name: "nonnumeric", value: "bad"},
|
||||
{name: "zero", value: "0"},
|
||||
{name: "negative", value: "-1"},
|
||||
{name: "too large", value: "101"},
|
||||
} {
|
||||
t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
|
||||
args := append(append([]string{}, leaf.args...), "--limit", invalid.value)
|
||||
if err := runConfirmedMailWriteContract(t, caller, args...); err == nil {
|
||||
t.Fatal("invalid legacy string limit unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) {
|
||||
goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}`
|
||||
goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
writeBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1},
|
||||
{name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1},
|
||||
{name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1},
|
||||
{name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2},
|
||||
{name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
|
||||
{name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2},
|
||||
{name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{
|
||||
responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
|
||||
errors: tc.errors,
|
||||
}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatalf("happy control: %v", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) {
|
||||
goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
createBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1},
|
||||
{name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1},
|
||||
{name: "missing id", createBody: `{"success":true}`, wantCalls: 1},
|
||||
{name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
|
||||
{name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) {
|
||||
goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
updateBody string
|
||||
readBody string
|
||||
errors map[string]error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "missing success", updateBody: `{}`, wantCalls: 1},
|
||||
{name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1},
|
||||
{name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
|
||||
{name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2},
|
||||
{name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
|
||||
{name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
|
||||
{name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
|
||||
if caller.errors == nil {
|
||||
caller.errors = map[string]error{}
|
||||
}
|
||||
err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body")
|
||||
if tc.name == "happy control" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("negative branch unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) {
|
||||
if email := strings.TrimSpace(rt.Str("email")); email != "" {
|
||||
return email, nil
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mailboxes, err := mailMailboxAddresses(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(mailboxes) == 0 {
|
||||
return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定")
|
||||
}
|
||||
return mailboxes[0], nil
|
||||
}
|
||||
|
||||
// mailMailboxAddresses accepts the reviewed mailbox collection paths and item
|
||||
// shapes used by the Mail gateway. The complete selected array is validated
|
||||
// before the first address is returned so a malformed later row cannot be
|
||||
// hidden behind an otherwise usable first mailbox.
|
||||
func mailMailboxAddresses(data map[string]any) ([]string, error) {
|
||||
const operation = "mail/list_user_mailboxes"
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"}
|
||||
var value any
|
||||
selectedPath := ""
|
||||
for _, path := range paths {
|
||||
candidate, present := mailLookup(data, path)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if selectedPath != "" {
|
||||
return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path))
|
||||
}
|
||||
value = candidate
|
||||
selectedPath = path
|
||||
}
|
||||
if selectedPath == "" {
|
||||
return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果")
|
||||
}
|
||||
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, mailResponseError(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:
|
||||
rawEmail, present := typed["email"]
|
||||
if present && rawEmail != nil {
|
||||
var valid bool
|
||||
address, valid = rawEmail.(string)
|
||||
if !valid {
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index))
|
||||
}
|
||||
address = strings.TrimSpace(address)
|
||||
}
|
||||
default:
|
||||
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index))
|
||||
}
|
||||
if address == "" {
|
||||
return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index))
|
||||
}
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
|
||||
data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
var Message = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+message", Product: "mail",
|
||||
Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id <messageId> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "id")
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := mailReadMessage(rt, email, rt.Str("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": message})
|
||||
},
|
||||
}
|
||||
|
||||
var Messages = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+messages", Product: "mail",
|
||||
Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids <id1>,<id2> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: []string{"ids"},
|
||||
Description: "1-100 个且不能为空",
|
||||
}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateMessageIDs(rt.StrSlice("ids"))
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
ids := rt.StrSlice("ids")
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(ids))
|
||||
for index, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
|
||||
}
|
||||
message, err := mailReadMessage(rt, email, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
return rt.Output(mailBusinessCollectionPayload("messages", messages))
|
||||
},
|
||||
}
|
||||
|
||||
func mailValidateMessageIDs(ids []string) error {
|
||||
if len(ids) == 0 || len(ids) > 100 {
|
||||
return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID")
|
||||
}
|
||||
for index, id := range ids {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var Thread = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+thread", Product: "mail",
|
||||
Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。",
|
||||
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
|
||||
Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id <conversationId> --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return mailValidateRequiredText(rt, "id")
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
email, err := mailResolveMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, err := mailRequireObject(data, "mail/get_thread", "conversation")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"value": conversation})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(Message, Messages, Thread)
|
||||
}
|
||||
@@ -21,18 +21,23 @@ import (
|
||||
// TestThreadListProjectConversationsShape guards the exact
|
||||
// list_mailbox_threads response contract, including lastModifiedDateTime.
|
||||
func TestThreadListProjectConversationsShape(t *testing.T) {
|
||||
const raw = `{"result":{"conversations":[{
|
||||
const raw = `{"success":true,"result":{"conversations":[{
|
||||
"id":"thread-1",
|
||||
"subject":"Projection contract",
|
||||
"lastModifiedDateTime":"2026-07-26T10:00:00Z",
|
||||
"isRead":false
|
||||
}]}}`
|
||||
}],"hasMore":false,"nextCursor":""}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
|
||||
got := threadListProject(data)
|
||||
got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
|
||||
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("strict projection: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
func mailRecipients(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailNormalizedAddresses(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized != "" {
|
||||
seen[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) {
|
||||
raw, present := message[field]
|
||||
if !present {
|
||||
return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field))
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field))
|
||||
}
|
||||
addresses := make([]string, 0, len(items))
|
||||
for index, item := range items {
|
||||
var address string
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
address = typed
|
||||
case map[string]any:
|
||||
address = mailFirstString(typed, "email", "address")
|
||||
default:
|
||||
return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index))
|
||||
}
|
||||
if strings.TrimSpace(address) == "" {
|
||||
return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index))
|
||||
}
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
return mailNormalizedAddresses(addresses), nil
|
||||
}
|
||||
|
||||
func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error {
|
||||
actual, err := mailReadAddresses(message, operation, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := mailNormalizedAddresses(requested)
|
||||
if len(actual) != len(expected) {
|
||||
return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field))
|
||||
}
|
||||
for index := range expected {
|
||||
if actual[index] != expected[index] {
|
||||
return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailVerifySender(message map[string]any, operation, requested string) error {
|
||||
raw, present := message["from"]
|
||||
if !present {
|
||||
return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from")
|
||||
}
|
||||
var actual string
|
||||
switch typed := raw.(type) {
|
||||
case string:
|
||||
actual = typed
|
||||
case map[string]any:
|
||||
actual = mailFirstString(typed, "email", "address")
|
||||
default:
|
||||
return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效")
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) {
|
||||
return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) {
|
||||
if err := mailRequireSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := mailLookup(data, "result.message")
|
||||
if !present {
|
||||
return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message")
|
||||
}
|
||||
message, ok := value.(map[string]any)
|
||||
if !ok || len(message) == 0 {
|
||||
return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value))
|
||||
}
|
||||
if err := mailRequireIdentity(message, operation, "", "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
|
||||
data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
template := make(map[string]any, len(data))
|
||||
for key, value := range data {
|
||||
switch key {
|
||||
case "success", "errorCode", "errorMsg", "arguments":
|
||||
continue
|
||||
default:
|
||||
template[key] = value
|
||||
}
|
||||
}
|
||||
if len(template) == 0 {
|
||||
return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空")
|
||||
}
|
||||
if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func mailTemplateSubject(template map[string]any) string {
|
||||
message, _ := template["message"].(map[string]any)
|
||||
return mailFirstString(message, "subject")
|
||||
}
|
||||
|
||||
func mailTemplateBody(template map[string]any) string {
|
||||
message, _ := template["message"].(map[string]any)
|
||||
return mailFirstString(message, "markdownBody", "body")
|
||||
}
|
||||
|
||||
var DraftCreate = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+draft-create", Product: "mail",
|
||||
Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。",
|
||||
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
|
||||
Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
|
||||
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"},
|
||||
{Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"},
|
||||
{Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"},
|
||||
{Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")}
|
||||
if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 {
|
||||
params["toRecipients"] = recipients
|
||||
}
|
||||
if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 {
|
||||
params["ccRecipients"] = recipients
|
||||
}
|
||||
if rt.Changed("body") {
|
||||
params["body"] = rt.Str("body")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receipt, err := mailDraftMessage(written, "mail/create_draft")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := mailFirstString(receipt, "id")
|
||||
verified, err := mailReadMessage(rt, rt.Str("from"), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mailFirstString(verified, "subject") != rt.Str("subject") {
|
||||
return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致")
|
||||
}
|
||||
if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
|
||||
return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致")
|
||||
}
|
||||
if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("to") {
|
||||
if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rt.Changed("cc") {
|
||||
if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"value": verified})
|
||||
},
|
||||
}
|
||||
|
||||
var DraftEdit = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+draft-edit", Product: "mail",
|
||||
Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。",
|
||||
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
|
||||
Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id <messageId> --subject "新主题" --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"},
|
||||
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"},
|
||||
{Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"},
|
||||
{Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
|
||||
{Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") {
|
||||
return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一")
|
||||
}
|
||||
params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")}
|
||||
if rt.Changed("to") {
|
||||
params["toRecipients"] = mailRecipients(rt.StrSlice("to"))
|
||||
}
|
||||
if rt.Changed("cc") {
|
||||
params["ccRecipients"] = mailRecipients(rt.StrSlice("cc"))
|
||||
}
|
||||
if rt.Changed("subject") {
|
||||
params["subject"] = rt.Str("subject")
|
||||
}
|
||||
if rt.Changed("body") {
|
||||
params["body"] = rt.Str("body")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receipt, err := mailDraftMessage(written, "mail/update_draft")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mailFirstString(receipt, "id") != rt.Str("id") {
|
||||
return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致")
|
||||
}
|
||||
verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") {
|
||||
return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致")
|
||||
}
|
||||
if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
|
||||
return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致")
|
||||
}
|
||||
if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("to") {
|
||||
if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rt.Changed("cc") {
|
||||
if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"value": verified})
|
||||
},
|
||||
}
|
||||
|
||||
var TemplateCreate = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+template-create", Product: "mail",
|
||||
Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。",
|
||||
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
|
||||
Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
|
||||
{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"},
|
||||
{Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"},
|
||||
{Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil {
|
||||
return err
|
||||
}
|
||||
id := mailFirstString(written, "id")
|
||||
if id == "" {
|
||||
return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID")
|
||||
}
|
||||
verified, err := mailReadTemplate(rt, rt.Str("email"), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") {
|
||||
return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"value": verified})
|
||||
},
|
||||
}
|
||||
|
||||
var TemplateUpdate = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "mail", Command: "+template-update", Product: "mail",
|
||||
Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。",
|
||||
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
|
||||
Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id <templateId> --subject "新主题" --format json`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
|
||||
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"},
|
||||
{Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
|
||||
{Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") {
|
||||
return apperrors.NewValidation("至少指定 --name、--subject、--body 之一")
|
||||
}
|
||||
params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")}
|
||||
for _, flag := range []string{"name", "subject", "body"} {
|
||||
if rt.Changed(flag) {
|
||||
params[flag] = rt.Str(flag)
|
||||
}
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") {
|
||||
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致")
|
||||
}
|
||||
if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") {
|
||||
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致")
|
||||
}
|
||||
if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") {
|
||||
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"value": verified})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。")
|
||||
markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。")
|
||||
markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。")
|
||||
markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。")
|
||||
shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate)
|
||||
}
|
||||
@@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
|
||||
// RequirePermissionMutationAcknowledgement validates the target-level payload
|
||||
// returned by add/remove_member_permission. The backend's top-level
|
||||
// success=true is not sufficient: historically it also accompanied a
|
||||
// resultMap that merely echoed nonexistent task UUIDs.
|
||||
// resultMap that merely echoed nonexistent task UUIDs, and it does not prove
|
||||
// that every requested task/member pair was changed.
|
||||
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}},
|
||||
} {
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil {
|
||||
t.Fatalf("incomplete permission acknowledgement accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
valid := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}},
|
||||
}
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil {
|
||||
t.Fatalf("valid permission acknowledgement rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
|
||||
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
|
||||
if err != nil || record["cmd"] != "pause" {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) {
|
||||
_, testFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate public catalog test file")
|
||||
}
|
||||
catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json")
|
||||
raw, err := os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read public catalog: %v", err)
|
||||
}
|
||||
var catalog struct {
|
||||
Count int `json:"count"`
|
||||
Results []struct {
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
t.Fatalf("decode public catalog: %v", err)
|
||||
}
|
||||
if catalog.Count != len(catalog.Results) {
|
||||
t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results))
|
||||
}
|
||||
if catalog.Count != len(publicShortcutCatalog) {
|
||||
t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(catalog.Results))
|
||||
for i, row := range catalog.Results {
|
||||
key := publicCatalogKey(row.Service, row.Command)
|
||||
if row.Service == "" || row.Command == "" {
|
||||
t.Fatalf("public catalog result %d has an empty identity", i)
|
||||
}
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
t.Fatalf("public catalog result %d duplicates %q", i, key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, generated := publicShortcutCatalog[key]; !generated {
|
||||
t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key)
|
||||
}
|
||||
}
|
||||
for key := range publicShortcutCatalog {
|
||||
if _, documented := seen[key]; !documented {
|
||||
t.Fatalf("generated Go key is missing from public catalog JSON: %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,23 +101,12 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"aitable\u0000+workflow-list": {},
|
||||
"attendance\u0000+check-record": {},
|
||||
"attendance\u0000+check-result": {},
|
||||
"attendance\u0000+get-adjustment-rule": {},
|
||||
"attendance\u0000+get-approve-template": {},
|
||||
"attendance\u0000+get-checkin-record": {},
|
||||
"attendance\u0000+get-leave-records": {},
|
||||
"attendance\u0000+get-overtime-rule": {},
|
||||
"attendance\u0000+get-schedule": {},
|
||||
"attendance\u0000+get-self-setting": {},
|
||||
"attendance\u0000+get-summary": {},
|
||||
"attendance\u0000+list-approve": {},
|
||||
"attendance\u0000+list-leave-types": {},
|
||||
"attendance\u0000+my-attendance": {},
|
||||
"attendance\u0000+query-report-data": {},
|
||||
"attendance\u0000+search-adjustment-rule": {},
|
||||
"attendance\u0000+search-class": {},
|
||||
"attendance\u0000+search-group": {},
|
||||
"attendance\u0000+search-overtime-rule": {},
|
||||
"attendance\u0000+this-month": {},
|
||||
"calendar\u0000+agenda": {},
|
||||
"calendar\u0000+attendee-list": {},
|
||||
"calendar\u0000+book": {},
|
||||
@@ -353,15 +342,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"drive\u0000+version-get": {},
|
||||
"drive\u0000+version-history": {},
|
||||
"drive\u0000+version-revert": {},
|
||||
"mail\u0000+contact-list": {},
|
||||
"mail\u0000+find-mail-user": {},
|
||||
"mail\u0000+folder-list": {},
|
||||
"mail\u0000+recent-mail": {},
|
||||
"mail\u0000+message": {},
|
||||
"mail\u0000+messages": {},
|
||||
"mail\u0000+search-mail": {},
|
||||
"mail\u0000+tag-list": {},
|
||||
"mail\u0000+template-list": {},
|
||||
"mail\u0000+thread-list": {},
|
||||
"mail\u0000+unread-mail": {},
|
||||
"mail\u0000+thread": {},
|
||||
"mail\u0000+triage": {},
|
||||
"mail\u0000+user-search": {},
|
||||
"minutes\u0000+action-items": {},
|
||||
"minutes\u0000+apply-permission": {},
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package responsecheck contains fail-closed response validators shared by
|
||||
// built-in shortcuts. It deliberately does not guess that a missing collection
|
||||
// means an empty result: callers must name the exact reviewed wire paths.
|
||||
package responsecheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Error returns a non-retryable response-validation error. The remote request
|
||||
// has completed, but the response is not sufficient evidence of success.
|
||||
func Error(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// RequireSuccess validates the common service envelope and returns it. A
|
||||
// missing success field is rejected because the products using this helper
|
||||
// have been observed to publish an explicit boolean success field.
|
||||
func RequireSuccess(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
|
||||
}
|
||||
envelope := data
|
||||
if content, ok := data["content"].(map[string]any); ok {
|
||||
envelope = content
|
||||
}
|
||||
raw, present := envelope["success"]
|
||||
if !present {
|
||||
return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功")
|
||||
}
|
||||
success, ok := raw.(bool)
|
||||
if !ok {
|
||||
return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message := firstString(envelope, "errorMessage", "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, Error(operation, "remote_failure", message)
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
// RequireResult returns the explicit non-null result value from a successful
|
||||
// response. Empty arrays and empty objects remain valid values; absence/null do
|
||||
// not prove an empty business result.
|
||||
func RequireResult(data map[string]any, operation string) (any, error) {
|
||||
envelope, err := RequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, present := envelope["result"]
|
||||
if !present || result == nil {
|
||||
return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RequireObjectResult returns an explicit object result.
|
||||
func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) {
|
||||
result, err := RequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result))
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// RequireSingleObjectResult returns one non-empty business object. It accepts
|
||||
// the two reviewed detail shapes used by product services: result={...} and
|
||||
// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values
|
||||
// cannot prove that the requested unique resource was read.
|
||||
func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) {
|
||||
result, err := RequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var object map[string]any
|
||||
switch typed := result.(type) {
|
||||
case map[string]any:
|
||||
object = typed
|
||||
case []any:
|
||||
if len(typed) != 1 {
|
||||
return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed)))
|
||||
}
|
||||
var ok bool
|
||||
object, ok = typed[0].(map[string]any)
|
||||
if !ok {
|
||||
return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0]))
|
||||
}
|
||||
default:
|
||||
return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result))
|
||||
}
|
||||
if len(object) == 0 {
|
||||
return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功")
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// RequireObjectCollection resolves the first present reviewed path, requires
|
||||
// an array at that exact path, and rejects malformed elements instead of
|
||||
// silently dropping them. A present empty array is a valid empty result.
|
||||
func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) {
|
||||
envelope, err := RequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range paths {
|
||||
value, present := lookup(envelope, path)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
|
||||
}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果")
|
||||
}
|
||||
|
||||
// LookupObject returns the object at a reviewed dotted path.
|
||||
func LookupObject(data map[string]any, operation, path string) (map[string]any, error) {
|
||||
value, present := lookup(data, path)
|
||||
if !present {
|
||||
return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path))
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value))
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func lookup(data map[string]any, path string) (any, bool) {
|
||||
var current any = data
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[segment]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func firstString(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package responsecheck
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1},
|
||||
{name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1},
|
||||
{name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0},
|
||||
{name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true},
|
||||
{name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true},
|
||||
{name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true},
|
||||
{name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true},
|
||||
{name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true},
|
||||
{name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true},
|
||||
{name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true},
|
||||
{name: "empty response", data: map[string]any{}, wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := RequireObjectCollection(tc.data, "test/read", "result")
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if err == nil && len(got) != tc.wantLen {
|
||||
t.Fatalf("len = %d, want %d", len(got), tc.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{map[string]any{"id": "one"}},
|
||||
},
|
||||
}
|
||||
items, err := RequireObjectCollection(data, "test/nested", "result.items")
|
||||
if err != nil {
|
||||
t.Fatalf("RequireObjectCollection: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) {
|
||||
if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil {
|
||||
t.Fatal("expected null result to fail closed")
|
||||
}
|
||||
if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil {
|
||||
t.Fatal("expected array result to fail object validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": map[string]any{"id": "one"}},
|
||||
{"success": true, "result": []any{map[string]any{"id": "one"}}},
|
||||
} {
|
||||
object, err := RequireSingleObjectResult(data, "test/detail")
|
||||
if err != nil || object["id"] != "one" {
|
||||
t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err)
|
||||
}
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": "bad"},
|
||||
} {
|
||||
if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil {
|
||||
t.Fatalf("ambiguous/malformed detail returned success: %v", object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "missing success", data: map[string]any{"result": map[string]any{}}},
|
||||
{name: "malformed success", data: map[string]any{"success": "true"}},
|
||||
{name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}},
|
||||
{name: "failure without message", data: map[string]any{"success": false, "error": 42}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil {
|
||||
t.Fatal("expected fail-closed envelope validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := RequireResult(map[string]any{}, "test/result"); err == nil {
|
||||
t.Fatal("expected upstream envelope failure")
|
||||
}
|
||||
if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil {
|
||||
t.Fatal("expected missing result failure")
|
||||
}
|
||||
if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" {
|
||||
t.Fatalf("valid object result rejected: object=%v err=%v", object, err)
|
||||
}
|
||||
if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil {
|
||||
t.Fatal("expected upstream object envelope failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLookupObject(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": map[string]any{
|
||||
"detail": map[string]any{"id": "one"},
|
||||
"scalar": "bad",
|
||||
},
|
||||
}
|
||||
object, err := LookupObject(data, "test/lookup", "result.detail")
|
||||
if err != nil || object["id"] != "one" {
|
||||
t.Fatalf("valid nested object rejected: object=%v err=%v", object, err)
|
||||
}
|
||||
if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil {
|
||||
t.Fatal("expected missing nested object failure")
|
||||
}
|
||||
if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil {
|
||||
t.Fatal("expected malformed nested object failure")
|
||||
}
|
||||
if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present {
|
||||
t.Fatal("scalar parent must not satisfy a nested lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) {
|
||||
items, err := RequireObjectCollection(
|
||||
map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}},
|
||||
"test/fallback",
|
||||
"result.records",
|
||||
"result.items",
|
||||
)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err)
|
||||
}
|
||||
if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil {
|
||||
t.Fatal("expected collection envelope failure")
|
||||
}
|
||||
if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" {
|
||||
t.Fatalf("firstString = %q, want value", got)
|
||||
}
|
||||
if got := firstString(map[string]any{"only": " "}, "only"); got != "" {
|
||||
t.Fatalf("firstString whitespace = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ var calendarSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_todo.json
|
||||
var todoSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_attendance.json
|
||||
var attendanceSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_mail.json
|
||||
var mailSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -42,13 +48,14 @@ type semanticCatalogFile struct {
|
||||
}
|
||||
|
||||
type semanticCatalogRecord struct {
|
||||
Disposition SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk Risk `json:"risk"`
|
||||
Availability Availability `json:"availability,omitempty"`
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Disposition SemanticDisposition `json:"disposition"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Risk Risk `json:"risk"`
|
||||
Availability Availability `json:"availability,omitempty"`
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
CompatibilityVisible bool `json:"compatibility_visible,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
@@ -60,6 +67,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
wikiSemanticCatalogJSON,
|
||||
calendarSemanticCatalogJSON,
|
||||
todoSemanticCatalogJSON,
|
||||
attendanceSemanticCatalogJSON,
|
||||
mailSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
@@ -120,6 +129,13 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
|
||||
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
|
||||
command, record.Availability))
|
||||
}
|
||||
// A command that was already part of the visible CLI contract cannot be
|
||||
// hidden in the same feature change merely because Agent publication is
|
||||
// withdrawn. This narrow fact preserves historical discovery/argv while
|
||||
// the unavailable Interface and public=false keep it out of Agent routes.
|
||||
if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) {
|
||||
panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command))
|
||||
}
|
||||
key := publicCatalogKey(source.Service, command)
|
||||
if _, exists := out[key]; exists {
|
||||
panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command))
|
||||
@@ -138,6 +154,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
|
||||
s.Availability = record.Availability
|
||||
s.PrimaryCommand = record.Primary
|
||||
s.SemanticReviewed = record.Reviewed
|
||||
s.Hidden = !record.Public
|
||||
s.CompatibilityVisible = record.CompatibilityVisible
|
||||
s.Hidden = !record.Public && !record.CompatibilityVisible
|
||||
return s, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "attendance",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
|
||||
"+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
|
||||
"+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,36 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"compatibility-visible public": `{
|
||||
"version": 1,
|
||||
"service": "chat",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+messages": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "reviewed",
|
||||
"risk": "read",
|
||||
"public": true,
|
||||
"compatibility_visible": true,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"compatibility-visible available": `{
|
||||
"version": 1,
|
||||
"service": "chat",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+messages": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "reviewed",
|
||||
"risk": "read",
|
||||
"public": false,
|
||||
"compatibility_visible": true,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
original := semanticCatalogJSON
|
||||
t.Cleanup(func() { semanticCatalogJSON = original })
|
||||
@@ -172,6 +202,16 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) {
|
||||
item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"})
|
||||
if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable {
|
||||
t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok)
|
||||
}
|
||||
if InPublicCatalog(item.Service, item.Command) {
|
||||
t.Fatal("compatibility-visible unavailable shortcut entered the public catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) {
|
||||
caller := &runtimeReadCoverageCaller{}
|
||||
old := helpers.GetCaller()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "mail",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
func attendanceRecordsResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error {
|
||||
records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(records))
|
||||
for index, record := range records {
|
||||
identity, ok := smartAttendancePositiveInteger(record["id"])
|
||||
if !ok {
|
||||
return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index))
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index))
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(records),
|
||||
"records": records,
|
||||
"complete": true,
|
||||
})
|
||||
}
|
||||
|
||||
func smartAttendancePositiveInteger(value any) (int64, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return int64(number), number > 0
|
||||
case int32:
|
||||
return int64(number), number > 0
|
||||
case int64:
|
||||
return number, number > 0
|
||||
case float64:
|
||||
return int64(number), number > 0 && number == float64(int64(number))
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(string(number), 10, 64)
|
||||
return parsed, err == nil && parsed > 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) {
|
||||
for _, declaration := range []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
hasResult bool
|
||||
availability string
|
||||
}{
|
||||
{MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability},
|
||||
{ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability},
|
||||
} {
|
||||
if declaration.rollout != output.RolloutLegacyOnly {
|
||||
t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout)
|
||||
}
|
||||
if declaration.hasResult || declaration.availability != contract.InterfaceAvailable {
|
||||
t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}
|
||||
if got := strictAttendanceCurrentUserID(valid); got != "u1" {
|
||||
t.Fatalf("valid profile identity = %q", got)
|
||||
}
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"result": map[string]any{"userId": "stale"}},
|
||||
{"success": false, "result": map[string]any{"userId": "stale"}},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}},
|
||||
{"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}},
|
||||
{"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}},
|
||||
}
|
||||
for index, profile := range invalid {
|
||||
if got := strictAttendanceCurrentUserID(profile); got != "" {
|
||||
t.Errorf("invalid profile %d resolved %q", index, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type smartAttendanceProfileCaller struct {
|
||||
profile string
|
||||
attendance string
|
||||
profileErr error
|
||||
attendanceErr error
|
||||
calls []string
|
||||
arguments []map[string]any
|
||||
}
|
||||
|
||||
func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, server+"/"+tool)
|
||||
c.arguments = append(c.arguments, arguments)
|
||||
text := c.profile
|
||||
callErr := c.profileErr
|
||||
if len(c.calls) > 1 {
|
||||
text = c.attendance
|
||||
callErr = c.attendanceErr
|
||||
}
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
func (*smartAttendanceProfileCaller) Format() string { return "json" }
|
||||
func (*smartAttendanceProfileCaller) DryRun() bool { return false }
|
||||
func (*smartAttendanceProfileCaller) Fields() string { return "" }
|
||||
func (*smartAttendanceProfileCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) {
|
||||
caller := &smartAttendanceProfileCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} {
|
||||
caller.calls = nil
|
||||
caller.arguments = nil
|
||||
caller.profile = `{"success":false,"result":{"userId":"stale"}}`
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Errorf("%s accepted success=false profile", declaration.Command)
|
||||
}
|
||||
if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") {
|
||||
t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) {
|
||||
valid := []map[string]any{
|
||||
{"success": true, "result": map[string]any{"userId": " u1 "}},
|
||||
{"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}},
|
||||
{"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}},
|
||||
{"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}},
|
||||
}
|
||||
for index, profile := range valid {
|
||||
if got := strictAttendanceCurrentUserID(profile); got != "u1" {
|
||||
t.Errorf("valid profile %d resolved %q", index, got)
|
||||
}
|
||||
}
|
||||
invalid := []map[string]any{
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"userId": 1}},
|
||||
{"success": true, "result": map[string]any{"userId": " "}},
|
||||
{"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}},
|
||||
{"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}},
|
||||
{"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}},
|
||||
{"success": true, "result": true},
|
||||
}
|
||||
for index, profile := range invalid {
|
||||
if got := strictAttendanceCurrentUserID(profile); got != "" {
|
||||
t.Errorf("invalid profile %d resolved %q", index, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": []any{map[string]any{"id": 0}}},
|
||||
{"success": true, "result": []any{map[string]any{"id": ""}}},
|
||||
{"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}},
|
||||
} {
|
||||
if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil {
|
||||
seen := map[int64]struct{}{}
|
||||
valid := true
|
||||
for _, record := range records {
|
||||
identity, ok := smartAttendancePositiveInteger(record["id"])
|
||||
if !ok {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
if valid {
|
||||
t.Fatalf("invalid records accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) {
|
||||
result := attendanceRecordsResult()
|
||||
if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) {
|
||||
t.Fatalf("invalid strict attendance result declaration: %#v", result)
|
||||
}
|
||||
|
||||
for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} {
|
||||
if _, ok := smartAttendancePositiveInteger(value); !ok {
|
||||
t.Errorf("valid record identity rejected: %#v", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} {
|
||||
if _, ok := smartAttendancePositiveInteger(value); ok {
|
||||
t.Errorf("invalid record identity accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{})
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"id": 0}}},
|
||||
{"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}},
|
||||
}
|
||||
for index, data := range invalid {
|
||||
if err := outputStrictAttendanceRecords(rt, data); err == nil {
|
||||
t.Errorf("invalid strict output %d accepted", index)
|
||||
}
|
||||
}
|
||||
if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil {
|
||||
t.Fatalf("valid strict output rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) {
|
||||
validProfile := `{"success":true,"result":{"userId":"u1"}}`
|
||||
validAttendance := `{"success":true,"result":[{"id":1}]}`
|
||||
for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} {
|
||||
t.Run(declaration.Command+" success", func(t *testing.T) {
|
||||
caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
|
||||
if err := declaration.Execute(rt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any)
|
||||
users, usersOK := request["userIds"].([]string)
|
||||
if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" {
|
||||
t.Fatalf("request=%#v", caller.arguments[1])
|
||||
}
|
||||
})
|
||||
t.Run(declaration.Command+" profile call error", func(t *testing.T) {
|
||||
caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
|
||||
if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 {
|
||||
t.Fatalf("err=%v calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run(declaration.Command+" attendance call error", func(t *testing.T) {
|
||||
caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
|
||||
if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 {
|
||||
t.Fatalf("err=%v calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run(declaration.Command+" malformed attendance", func(t *testing.T) {
|
||||
caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
|
||||
if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 {
|
||||
t.Fatalf("err=%v calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@ package smart
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -71,6 +71,13 @@ var FindMailUser = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +find-mail-user",
|
||||
},
|
||||
Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
// Keep the published composite Shortcut property stable. Execute owns
|
||||
// the explicit query -> keyword adapter for search_mail_users.
|
||||
{Name: "query", Property: "query"},
|
||||
{Name: "limit", Property: "limit"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -87,8 +94,19 @@ var FindMailUser = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := smartMailValidateRequiredText(rt, "query"); err != nil {
|
||||
return err
|
||||
}
|
||||
return smartMailValidatePageSize(rt, "limit", false)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +find-mail-user --query "张三"`,
|
||||
@@ -101,69 +119,109 @@ var FindMailUser = shortcut.Shortcut{
|
||||
if rt.Changed("limit") {
|
||||
args["size"] = strconv.Itoa(rt.Int("limit"))
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
args["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_mail_users", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — project matched users.
|
||||
users := findMailUserUnwrap(data)
|
||||
users, err := smartMailCollection(data, "mail/search_mail_users", "users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
results = append(results, map[string]any{
|
||||
"name": findMailUserString(u, "name", "displayName", "userName", "nick"),
|
||||
"nickname": findMailUserString(u, "nickname", "nick", "displayName"),
|
||||
"email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"),
|
||||
"employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"),
|
||||
"jobTitle": findMailUserString(u, "jobTitle", "title", "position"),
|
||||
"workLocation": findMailUserString(u, "workLocation", "location", "workPlace"),
|
||||
"id": findMailUserAny(u, "id", "userId", "userid"),
|
||||
})
|
||||
projected, err := findMailUserProjection(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results = append(results, projected)
|
||||
}
|
||||
|
||||
// Step 3 — empty result guard.
|
||||
if len(results) == 0 {
|
||||
return apperrors.NewValidation("没搜到邮箱联系人")
|
||||
complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{"users": results, "count": len(results)})
|
||||
return smartMailOutputPage(rt, "users", results, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
// findMailUserUnwrap extracts the user list from a search_mail_users response.
|
||||
// The helper documents the list under "users"; we probe several container keys
|
||||
// at the top level and one level deep, defensively.
|
||||
func findMailUserUnwrap(data map[string]any) []map[string]any {
|
||||
keys := []string{"users", "contacts", "result", "data", "list", "items", "records"}
|
||||
for _, key := range keys {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return findMailUserToMaps(arr)
|
||||
}
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, k2 := range keys {
|
||||
if arr, ok := inner[k2].([]any); ok {
|
||||
return findMailUserToMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
func findMailUserProjection(user map[string]any) (map[string]any, error) {
|
||||
result := map[string]any{
|
||||
"name": findMailUserString(user, "name", "displayName", "userName", "nick"),
|
||||
"nickname": findMailUserString(user, "nickname", "nick", "displayName"),
|
||||
"employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"),
|
||||
"jobTitle": findMailUserString(user, "jobTitle", "title", "position"),
|
||||
"workLocation": findMailUserString(user, "workLocation", "location", "workPlace"),
|
||||
}
|
||||
return nil
|
||||
email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if emailPresent {
|
||||
result["email"] = email
|
||||
}
|
||||
id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idPresent {
|
||||
result["id"] = id
|
||||
}
|
||||
if !emailPresent && !idPresent {
|
||||
return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findMailUserToMaps(arr []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) {
|
||||
for _, key := range keys {
|
||||
value, present := m[key]
|
||||
if !present || value == nil {
|
||||
continue
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串")
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" {
|
||||
return text, true, nil
|
||||
}
|
||||
}
|
||||
return out
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) {
|
||||
for _, key := range keys {
|
||||
value, present := m[key]
|
||||
if !present || value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" {
|
||||
return text, true, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch value.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return value, true, nil
|
||||
default:
|
||||
return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字")
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func findMailUserString(m map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -172,8 +230,11 @@ func findMailUserString(m map[string]any, keys ...string) string {
|
||||
func findMailUserAny(m map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
if s, isStr := v.(string); isStr && s == "" {
|
||||
continue
|
||||
if s, isStr := v.(string); isStr {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -182,5 +243,6 @@ func findMailUserAny(m map[string]any, keys ...string) any {
|
||||
}
|
||||
|
||||
func init() {
|
||||
hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果")
|
||||
shortcut.Register(FindMailUser)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"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"
|
||||
)
|
||||
|
||||
func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) {
|
||||
for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} {
|
||||
rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "})
|
||||
if err := declaration.Validate(rt); err == nil {
|
||||
t.Fatal("direct required query validator accepted whitespace")
|
||||
}
|
||||
}
|
||||
if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil {
|
||||
t.Fatal("empty response unexpectedly succeeded")
|
||||
}
|
||||
if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil {
|
||||
t.Fatal("explicit failure unexpectedly succeeded")
|
||||
}
|
||||
if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil {
|
||||
t.Fatalf("string success rejected: %v", err)
|
||||
}
|
||||
if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok {
|
||||
t.Fatal("non-object lookup unexpectedly succeeded")
|
||||
}
|
||||
if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil {
|
||||
t.Fatal("rows accepted invalid response")
|
||||
}
|
||||
if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil {
|
||||
t.Fatal("rows accepted missing identity")
|
||||
}
|
||||
|
||||
items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty search rejected: %v %v", items, err)
|
||||
}
|
||||
if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil {
|
||||
t.Fatal("negative total unexpectedly succeeded")
|
||||
}
|
||||
if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil {
|
||||
t.Fatal("search row without id unexpectedly succeeded")
|
||||
}
|
||||
if smartMailEmptySearchSentinel(map[string]any{}) {
|
||||
t.Fatal("empty object treated as reviewed sentinel")
|
||||
}
|
||||
if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) {
|
||||
t.Fatal("reviewed null-only sentinel rejected")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{value: 7, want: 7, ok: true},
|
||||
{value: float64(8), want: 8, ok: true},
|
||||
{value: 1.5, ok: false},
|
||||
{value: "9", want: 9, ok: true},
|
||||
{value: []any{}, ok: false},
|
||||
} {
|
||||
got, ok := smartMailInt(tc.value)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing prefix": {},
|
||||
"malformed prefix": {"result": []any{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil {
|
||||
t.Fatal("invalid prefixed page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing cursor": {},
|
||||
"wrong cursor": {"nextCursor": 7},
|
||||
"wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}},
|
||||
"conflicting true": {"nextCursor": "", "hasMore": true},
|
||||
"conflicting false": {"nextCursor": "cursor-2", "hasMore": false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil {
|
||||
t.Fatal("invalid page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" {
|
||||
t.Fatalf("terminal page rejected: %v %q %v", complete, next, err)
|
||||
}
|
||||
if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" {
|
||||
t.Fatalf("continuing page rejected: %v %q %v", complete, next, err)
|
||||
}
|
||||
if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil {
|
||||
t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
if value, ok := smartMailBool("true"); !ok || !value {
|
||||
t.Fatal("string true not normalized")
|
||||
}
|
||||
if value, ok := smartMailBool("false"); !ok || value {
|
||||
t.Fatal("string false not normalized")
|
||||
}
|
||||
if _, ok := smartMailBool(7); ok {
|
||||
t.Fatal("invalid boolean unexpectedly normalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) {
|
||||
assertPanic := func(name string, fn func()) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unsupported schema did not panic")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
})
|
||||
}
|
||||
assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") })
|
||||
assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") })
|
||||
|
||||
declaration := SearchMail
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil {
|
||||
t.Fatal("invalid terminal pagination unexpectedly stored")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}{
|
||||
{message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"},
|
||||
{message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender <sender@example.invalid>"},
|
||||
{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{"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": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} {
|
||||
if _, err := searchMailFrom(message); err == nil {
|
||||
t.Fatal("malformed sender unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" {
|
||||
t.Fatalf("string normalization=%q", got)
|
||||
}
|
||||
if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" {
|
||||
t.Fatalf("any string normalization=%#v", got)
|
||||
}
|
||||
if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 {
|
||||
t.Fatalf("numeric projection=%#v", got)
|
||||
}
|
||||
|
||||
projected, err := findMailUserProjection(map[string]any{
|
||||
"id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ",
|
||||
"employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ",
|
||||
})
|
||||
if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" {
|
||||
t.Fatalf("normalized user projection=%#v err=%v", projected, err)
|
||||
}
|
||||
if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 {
|
||||
t.Fatalf("find user any=%#v", got)
|
||||
}
|
||||
if got := findMailUserAny(map[string]any{}, "missing"); got != nil {
|
||||
t.Fatalf("missing optional value=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
responses map[string]string
|
||||
errors map[string]error
|
||||
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},
|
||||
{name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
|
||||
{name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1},
|
||||
{name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1},
|
||||
{name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1},
|
||||
{name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1},
|
||||
{name: "triage sender malformed", declaration: TriageMail, 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},
|
||||
{name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
|
||||
{name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1},
|
||||
{name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1},
|
||||
{name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1},
|
||||
{name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1},
|
||||
{name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1},
|
||||
{name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1},
|
||||
{name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1},
|
||||
{name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors}
|
||||
if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid leaf response unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
message map[string]any
|
||||
want any
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender <sender@example.invalid>"}},
|
||||
{name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}},
|
||||
{name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}},
|
||||
{name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true},
|
||||
{name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := recentMailSenders(tc.message)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("malformed senders unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid senders failed: %v", err)
|
||||
}
|
||||
if !tc.wantErr && !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("senders=%#v want=%#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
responses map[string]string
|
||||
wantErr bool
|
||||
wantCalls int
|
||||
}{
|
||||
{
|
||||
name: "triage resolves mailbox and inbox", declaration: TriageMail,
|
||||
responses: map[string]string{
|
||||
"list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"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: "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{
|
||||
"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`,
|
||||
"list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`,
|
||||
}, wantCalls: 2,
|
||||
},
|
||||
{
|
||||
name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"},
|
||||
responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`},
|
||||
wantErr: true, wantCalls: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: tc.responses}
|
||||
err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("invalid inbox resolution unexpectedly succeeded")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("valid inbox resolution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != tc.wantCalls {
|
||||
t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,34 +31,6 @@ import (
|
||||
|
||||
var errStubTool = errors.New("stub tool error")
|
||||
|
||||
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
|
||||
// an email field and bare string arrays, each at the top level or one level
|
||||
// under a result/data wrapper. The nested bare-string case regressed silently
|
||||
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
|
||||
// demand an explicit --email.
|
||||
func TestSearchMailPickMailbox(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
|
||||
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
|
||||
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
|
||||
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
|
||||
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := searchMailPickMailbox(data); got != tc.want {
|
||||
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
|
||||
// container is probed — otherwise +resolve-space silently finds no candidates.
|
||||
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
|
||||
@@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
|
||||
// "provide --email" validation error rather than silently proceeding.
|
||||
func TestSmartSearchMailNoMailbox(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
|
||||
"list_user_mailboxes": `{"success":"true","emailAccounts":[]}`,
|
||||
}}
|
||||
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
|
||||
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
|
||||
@@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) {
|
||||
// the no-mailbox validation error because emailAccounts was not probed.
|
||||
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
|
||||
"search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`,
|
||||
"list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`,
|
||||
"search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`,
|
||||
}}
|
||||
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json")
|
||||
if strings.Contains(out, "未找到可用邮箱") || out == "" {
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
func smartMailError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
|
||||
if !required && !rt.Changed(flag) {
|
||||
return nil
|
||||
}
|
||||
value := rt.Int(flag)
|
||||
if value < 1 || value > 100 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) {
|
||||
if !rt.Changed(flag) {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(rt.Str(flag))
|
||||
if err != nil || value < 1 || value > 100 {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
|
||||
}
|
||||
return strconv.Itoa(value), nil
|
||||
}
|
||||
|
||||
func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error {
|
||||
_, err := smartMailStringPageSize(rt, flag, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
|
||||
if strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smartMailSuccess(data map[string]any, operation string) error {
|
||||
if len(data) == 0 {
|
||||
return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空")
|
||||
}
|
||||
switch value := data["success"].(type) {
|
||||
case bool:
|
||||
if value {
|
||||
return nil
|
||||
}
|
||||
case string:
|
||||
if value == "true" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, present := data["success"]; !present {
|
||||
return smartMailError(operation, "missing_success", "响应缺少 success 业务状态")
|
||||
}
|
||||
return smartMailError(operation, "remote_failure", "服务未明确返回成功状态")
|
||||
}
|
||||
|
||||
func smartMailLookup(data map[string]any, path string) (any, bool) {
|
||||
var current any = data
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[segment]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
|
||||
if err := smartMailSuccess(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, present := smartMailLookup(data, path)
|
||||
if !present {
|
||||
return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
|
||||
}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) {
|
||||
items, err := smartMailCollection(data, operation, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, item := range items {
|
||||
if searchMailFirstString(item, identityKeys...) == "" {
|
||||
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire
|
||||
// shape observed from search_emails: total="0", terminal nextCursor="$", and
|
||||
// exactly one placeholder whose only fields are null recipient metadata. It is
|
||||
// deliberately narrower than a general "drop bad item" rule.
|
||||
func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) {
|
||||
items, err := smartMailCollection(data, operation, "messages")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total, totalOK := smartMailInt(data["total"])
|
||||
next, nextOK := data["nextCursor"].(string)
|
||||
if totalOK && total == 0 && nextOK && (next == "" || next == "$") {
|
||||
if len(items) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象")
|
||||
}
|
||||
if totalOK && total < 0 {
|
||||
return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数")
|
||||
}
|
||||
for index, item := range items {
|
||||
if searchMailFirstString(item, "id") == "" {
|
||||
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func smartMailEmptySearchSentinel(item map[string]any) bool {
|
||||
if len(item) == 0 {
|
||||
return false
|
||||
}
|
||||
allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true}
|
||||
for key, value := range item {
|
||||
if !allowed[key] || value != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func smartMailInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case float64:
|
||||
if typed != float64(int(typed)) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(typed)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
|
||||
container := data
|
||||
if prefix != "" {
|
||||
value, present := smartMailLookup(data, prefix)
|
||||
if !present {
|
||||
return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象")
|
||||
}
|
||||
var ok bool
|
||||
container, ok = value.(map[string]any)
|
||||
if !ok {
|
||||
return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误")
|
||||
}
|
||||
}
|
||||
raw, present := container["nextCursor"]
|
||||
next, ok := raw.(string)
|
||||
if !present || !ok {
|
||||
return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor")
|
||||
}
|
||||
if rawMore, present := container["hasMore"]; present {
|
||||
hasMore, ok := smartMailBool(rawMore)
|
||||
if !ok {
|
||||
return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值")
|
||||
}
|
||||
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
|
||||
if hasMore == terminal {
|
||||
return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾")
|
||||
}
|
||||
if !hasMore {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
|
||||
return true, "", nil
|
||||
}
|
||||
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
|
||||
return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
|
||||
}
|
||||
return false, next, nil
|
||||
}
|
||||
|
||||
func smartMailBool(value any) (bool, bool) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
if typed == "true" {
|
||||
return true, true
|
||||
}
|
||||
if typed == "false" {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func smartMailResult(collection, description string) *contract.ResultSpec {
|
||||
identity := smartMailIdentitySchema(collection)
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
SensitivePaths: smartMailSensitivePaths(collection),
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, identity, collection)),
|
||||
}
|
||||
}
|
||||
|
||||
func smartMailSensitivePaths(collection string) []string {
|
||||
switch collection {
|
||||
case "messages":
|
||||
return []string{"messages.subject", "messages.from", "messages.date"}
|
||||
case "mails":
|
||||
return []string{"mails.subject", "mails.from", "mails.date"}
|
||||
case "users":
|
||||
return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"}
|
||||
default:
|
||||
panic("unsupported smart Mail sensitive paths: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func smartMailIdentitySchema(collection string) string {
|
||||
switch collection {
|
||||
case "messages":
|
||||
return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]`
|
||||
case "mails":
|
||||
return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]`
|
||||
case "users":
|
||||
return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]`
|
||||
default:
|
||||
panic("unsupported smart Mail Result identity: " + collection)
|
||||
}
|
||||
}
|
||||
|
||||
func smartMailCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) {
|
||||
declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
declaration.Contract.Result = smartMailResult(collection, description)
|
||||
declaration.Contract.Pagination = smartMailCursorPagination()
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
|
||||
Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.",
|
||||
}
|
||||
}
|
||||
|
||||
func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.",
|
||||
}
|
||||
}
|
||||
|
||||
func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
|
||||
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
|
||||
if !complete {
|
||||
payload["nextCursor"] = next
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
|
||||
payload := smartMailPayload(collection, items, complete, next)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
pagination, err := output.NewPagination(complete, next)
|
||||
if err != nil {
|
||||
return smartMailError("mail/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
business := map[string]any{"count": len(items), collection: items}
|
||||
meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type smartMailCursorCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type smartMailCursorCaller struct {
|
||||
responses map[string]string
|
||||
errors map[string]error
|
||||
calls []smartMailCursorCall
|
||||
}
|
||||
|
||||
func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)})
|
||||
if err := caller.errors[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, ok := caller.responses[tool]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool)
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*smartMailCursorCaller) Format() string { return "json" }
|
||||
func (*smartMailCursorCaller) DryRun() bool { return false }
|
||||
func (*smartMailCursorCaller) Fields() string { return "" }
|
||||
func (*smartMailCursorCaller) JQ() string { return "" }
|
||||
|
||||
func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) {
|
||||
for _, declaration := range []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
}{
|
||||
{"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil},
|
||||
{"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil},
|
||||
{"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil},
|
||||
} {
|
||||
if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
|
||||
t.Fatalf("%s is not unified with Result", declaration.name)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} {
|
||||
if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" {
|
||||
t.Fatalf("%s missing cursor Pagination", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} {
|
||||
if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" {
|
||||
t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) {
|
||||
for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} {
|
||||
found := false
|
||||
for _, flag := range declaration.Flags {
|
||||
if flag.Name != "size" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if flag.Type != shortcut.FlagString || flag.Default != "" {
|
||||
t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s missing --size", declaration.Command)
|
||||
}
|
||||
cmd := corecmd.New(shortcut.FromShortcut(*declaration))
|
||||
if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" {
|
||||
t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
wantSize string
|
||||
}{
|
||||
{name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"},
|
||||
{name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"},
|
||||
{name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"},
|
||||
{name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}}
|
||||
if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatalf("compatible execution failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize {
|
||||
t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} {
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
values := map[string]string{"email": "mail@example.invalid", "size": "bad"}
|
||||
if declaration.Command == "+search-mail" {
|
||||
values["query"] = "fixture"
|
||||
}
|
||||
rt := smartMailRuntimeForCoverage(t, declaration, values)
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) {
|
||||
fixture := map[string]any{
|
||||
"success": "true", "total": "0", "nextCursor": "$",
|
||||
"messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}},
|
||||
}
|
||||
items, err := smartMailSearchRows(fixture, "mail/search_emails")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err)
|
||||
}
|
||||
for name, bad := range map[string]map[string]any{
|
||||
"unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}},
|
||||
"non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}},
|
||||
"multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}},
|
||||
} {
|
||||
if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil {
|
||||
t.Fatalf("%s must fail closed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) {
|
||||
for name, fixture := range map[string]map[string]any{
|
||||
"missing success": {"messages": []any{}},
|
||||
"missing collection": {"success": "true", "total": "0", "nextCursor": "$"},
|
||||
"wrong type": {"success": "true", "messages": map[string]any{}},
|
||||
"bad item": {"success": "true", "messages": []any{"bad"}},
|
||||
} {
|
||||
if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil {
|
||||
t.Fatalf("%s must fail closed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) {
|
||||
if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil {
|
||||
t.Fatal("malformed message from field must fail")
|
||||
}
|
||||
if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil {
|
||||
t.Fatal("non-object conversation sender must fail")
|
||||
}
|
||||
if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil {
|
||||
t.Fatal("sender without identity must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) {
|
||||
declaration := SearchMail
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
|
||||
t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Pagination *struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{"complete", "hasMore", "nextCursor"} {
|
||||
if _, exists := envelope.Data[field]; exists {
|
||||
t.Fatalf("pagination field %s leaked into business data", field)
|
||||
}
|
||||
}
|
||||
if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" {
|
||||
t.Fatalf("pagination=%+v", envelope.Meta.Pagination)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
declaration *shortcut.Shortcut
|
||||
collection string
|
||||
identity string
|
||||
}{
|
||||
{&SearchMail, "messages", "messageId"},
|
||||
{&TriageMail, "messages", "messageId"},
|
||||
{&FindMailUser, "users", "email"},
|
||||
} {
|
||||
if len(tc.declaration.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("%s missing sensitive_paths", tc.declaration.Command)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil {
|
||||
t.Fatalf("%s schema: %v", tc.declaration.Command, err)
|
||||
}
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) {
|
||||
t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum)
|
||||
}
|
||||
items := properties[tc.collection].(map[string]any)["items"].(map[string]any)
|
||||
identity := items["properties"].(map[string]any)[tc.identity].(map[string]any)
|
||||
if identity["description"] == "" {
|
||||
t.Fatalf("%s missing stable identity", tc.declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
}{
|
||||
{name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
|
||||
{name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}}
|
||||
if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" {
|
||||
t.Fatalf("cursor not passed through: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}
|
||||
if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil {
|
||||
t.Fatal("repeated cursor unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}},
|
||||
{name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}},
|
||||
{name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}},
|
||||
{name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}},
|
||||
{name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}},
|
||||
{name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}},
|
||||
{name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}},
|
||||
{name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}},
|
||||
{name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}},
|
||||
{name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}},
|
||||
{name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}},
|
||||
{name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}},
|
||||
{name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}},
|
||||
{name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}},
|
||||
{name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{}}
|
||||
if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid limit unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid limit reached remote: %d", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user map[string]any
|
||||
wantKey string
|
||||
absentKey string
|
||||
}{
|
||||
{name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"},
|
||||
{name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"},
|
||||
{name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
projected, err := findMailUserProjection(tc.user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := projected[tc.wantKey]; !ok {
|
||||
t.Fatalf("projection missing %s: %#v", tc.wantKey, projected)
|
||||
}
|
||||
if _, ok := projected[tc.absentKey]; ok {
|
||||
t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{
|
||||
"search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`,
|
||||
}}
|
||||
if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil {
|
||||
t.Fatal("identity-free user unexpectedly satisfied runtime Result contract")
|
||||
}
|
||||
for name, user := range map[string]map[string]any{
|
||||
"malformed email with valid id": {"id": "user-1", "email": 7},
|
||||
"malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := findMailUserProjection(user); err == nil {
|
||||
t.Fatal("malformed optional identity unexpectedly passed projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response string
|
||||
}{
|
||||
{name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}}
|
||||
if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("whitespace stable identity unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%d, want one response validation call", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) {
|
||||
for _, parameter := range FindMailUser.Contract.Parameters {
|
||||
if parameter.Name == "query" {
|
||||
if parameter.Property != "query" {
|
||||
t.Fatalf("query property = %q, want stable composite property query", parameter.Property)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("FindMailUser query parameter declaration is missing")
|
||||
}
|
||||
@@ -14,12 +14,14 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -41,10 +43,11 @@ import (
|
||||
//
|
||||
// dws attendance +my-attendance
|
||||
var MyAttendance = shortcut.Shortcut{
|
||||
Service: "attendance",
|
||||
Command: "+my-attendance",
|
||||
Product: "attendance",
|
||||
Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Service: "attendance",
|
||||
Command: "+my-attendance",
|
||||
Product: "attendance",
|
||||
Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " +
|
||||
"再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," +
|
||||
"最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。",
|
||||
@@ -63,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{
|
||||
},
|
||||
Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
@@ -84,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID := myAttendanceCurrentUserID(profile)
|
||||
userID := strictAttendanceCurrentUserID(profile)
|
||||
if userID == "" {
|
||||
return apperrors.NewValidation(
|
||||
"没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。")
|
||||
@@ -110,15 +113,89 @@ var MyAttendance = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
return outputStrictAttendanceRecords(rt, data)
|
||||
},
|
||||
}
|
||||
|
||||
// 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": ...}} ]}
|
||||
// - {"result": {"userId": ...}}
|
||||
func strictAttendanceCurrentUserID(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
success, ok := data["success"].(bool)
|
||||
if !ok || !success {
|
||||
return ""
|
||||
}
|
||||
identities := map[string]struct{}{}
|
||||
collect := func(item map[string]any) bool {
|
||||
var direct, nested string
|
||||
if value, present := item["userId"]; present {
|
||||
text, valid := value.(string)
|
||||
if !valid || strings.TrimSpace(text) == "" {
|
||||
return false
|
||||
}
|
||||
direct = strings.TrimSpace(text)
|
||||
}
|
||||
if value, present := item["orgEmployeeModel"]; present {
|
||||
object, valid := value.(map[string]any)
|
||||
if !valid || object == nil {
|
||||
return false
|
||||
}
|
||||
text, valid := object["userId"].(string)
|
||||
if !valid || strings.TrimSpace(text) == "" {
|
||||
return false
|
||||
}
|
||||
nested = strings.TrimSpace(text)
|
||||
}
|
||||
if direct == "" && nested == "" {
|
||||
return false
|
||||
}
|
||||
if direct != "" && nested != "" && direct != nested {
|
||||
return false
|
||||
}
|
||||
if direct != "" {
|
||||
identities[direct] = struct{}{}
|
||||
} else {
|
||||
identities[nested] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch result := data["result"].(type) {
|
||||
case []any:
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range result {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok || !collect(m) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if !collect(result) {
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if len(identities) != 1 {
|
||||
return ""
|
||||
}
|
||||
var identity string
|
||||
for candidate := range identities {
|
||||
identity = candidate
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// myAttendanceCurrentUserID preserves the compatibility resolver used by
|
||||
// older non-Attendance smart workflows. New Attendance shortcuts must use the
|
||||
// strict resolver above so a missing business success flag cannot consume a
|
||||
// stale result.
|
||||
func myAttendanceCurrentUserID(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -71,6 +74,12 @@ var RecentMail = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "mail +recent-mail",
|
||||
},
|
||||
Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)",
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit"},
|
||||
{Name: "email", Property: "email"},
|
||||
{Name: "folder", Property: "folder"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -90,6 +99,11 @@ var RecentMail = shortcut.Shortcut{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return smartMailValidatePageSize(rt, "limit", false)
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +recent-mail`,
|
||||
@@ -119,40 +133,47 @@ var RecentMail = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — list threads. email/folderId/size mirror the helpers.mail
|
||||
// thread-list call; size is an int in 1..100.
|
||||
size := rt.Int("limit")
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
limit := 20
|
||||
if rt.Changed("limit") {
|
||||
limit = rt.Int("limit")
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{
|
||||
args := map[string]any{
|
||||
"email": email,
|
||||
"folderId": folderID,
|
||||
"size": size,
|
||||
})
|
||||
"size": limit,
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
args["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "list_mailbox_threads", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4 — project each conversation to a compact record.
|
||||
threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records")
|
||||
threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := make([]map[string]any, 0, len(threads))
|
||||
for _, t := range threads {
|
||||
from, err := recentMailSenders(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results = append(results, map[string]any{
|
||||
"subject": searchMailFirstString(t, "subject", "title", "topic"),
|
||||
"from": recentMailSenders(t),
|
||||
"from": from,
|
||||
"date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"),
|
||||
"threadId": searchMailFirstString(t, "threadId", "id", "conversationId"),
|
||||
})
|
||||
}
|
||||
|
||||
// Empty result guard.
|
||||
if len(results) == 0 {
|
||||
return apperrors.NewValidation("最近没有邮件")
|
||||
complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{"count": len(results), "mails": results})
|
||||
return smartMailOutputPage(rt, "mails", results, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,12 +187,15 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records")
|
||||
folders, err := smartMailRows(data, "mail/list_folders", "folders", "id")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
inboxNames := map[string]bool{
|
||||
"收件箱": true, "inbox": true,
|
||||
}
|
||||
for _, f := range folders {
|
||||
name := searchMailFirstString(f, "displayName", "name", "folderName")
|
||||
name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName")))
|
||||
if inboxNames[name] {
|
||||
if id := searchMailFirstString(f, "id", "folderId"); id != "" {
|
||||
return id, nil
|
||||
@@ -184,16 +208,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
|
||||
// recentMailSenders reads a conversation's sender list. The helper documents a
|
||||
// "senders" array of {email, name}; we tolerate a plain string, a single object,
|
||||
// and fall back to the shared searchMailFrom logic.
|
||||
func recentMailSenders(t map[string]any) any {
|
||||
if arr, ok := t["senders"].([]any); ok && len(arr) > 0 {
|
||||
func recentMailSenders(t map[string]any) (any, error) {
|
||||
raw, present := t["senders"]
|
||||
if arr, ok := raw.([]any); ok && len(arr) > 0 {
|
||||
names := make([]string, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
for index, it := range arr {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := it.(string); ok && s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
continue
|
||||
if !ok || len(m) == 0 {
|
||||
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index))
|
||||
}
|
||||
name := searchMailFirstString(m, "name", "displayName")
|
||||
addr := searchMailFirstString(m, "email", "emailAddress", "address")
|
||||
@@ -204,15 +226,23 @@ func recentMailSenders(t map[string]any) any {
|
||||
names = append(names, addr)
|
||||
case name != "":
|
||||
names = append(names, name)
|
||||
default:
|
||||
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index))
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return names
|
||||
return names, nil
|
||||
}
|
||||
} else if present {
|
||||
if _, ok := raw.([]any); !ok {
|
||||
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组")
|
||||
}
|
||||
}
|
||||
return searchMailFrom(t)
|
||||
}
|
||||
|
||||
func init() {
|
||||
hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要")
|
||||
markSmartMailCompatibilityOnly(&RecentMail)
|
||||
shortcut.Register(RecentMail)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -79,9 +82,20 @@ var SearchMail = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
|
||||
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
|
||||
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := smartMailValidateRequiredText(rt, "query"); err != nil {
|
||||
return err
|
||||
}
|
||||
return smartMailValidateStringPageSize(rt, "size")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +search-mail --query "subject:周报"`,
|
||||
@@ -92,6 +106,10 @@ var SearchMail = shortcut.Shortcut{
|
||||
if err := rt.RequireAll("query"); err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := smartMailStringPageSize(rt, "size", "20")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1 — resolve the mailbox address.
|
||||
email := rt.Str("email")
|
||||
@@ -105,31 +123,42 @@ var SearchMail = shortcut.Shortcut{
|
||||
|
||||
// Step 2 — search that mailbox. email/query/size mirror the search_emails
|
||||
// call in helpers.messageSearch; size is passed as a string.
|
||||
size := rt.Str("size")
|
||||
if size == "" {
|
||||
size = "20"
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
|
||||
args := map[string]any{
|
||||
"email": email,
|
||||
"query": rt.Str("query"),
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
args["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_emails", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — project each message to a compact record.
|
||||
messages := searchMailMessages(data)
|
||||
messages, err := smartMailSearchRows(data, "mail/search_emails")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
from, err := searchMailFrom(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"subject": searchMailFirstString(m, "subject", "title", "topic"),
|
||||
"from": searchMailFrom(m),
|
||||
"from": from,
|
||||
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
|
||||
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
|
||||
})
|
||||
}
|
||||
return rt.Output(map[string]any{"messages": out, "email": email})
|
||||
complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smartMailOutputPage(rt, "messages", out, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,115 +171,106 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if addr := searchMailPickMailbox(data); addr != "" {
|
||||
return addr, nil
|
||||
mailboxes, err := searchMailMailboxAddresses(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(mailboxes) > 0 {
|
||||
return mailboxes[0], nil
|
||||
}
|
||||
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
|
||||
}
|
||||
|
||||
// searchMailPickMailbox resolves the first usable mailbox address from a
|
||||
// list_user_mailboxes response. It tolerates every observed shape: objects with
|
||||
// an email field (result.emailAccounts[].email) and bare string arrays
|
||||
// (result.emailAccounts[]), each at the top level or one level under a
|
||||
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
|
||||
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
|
||||
// report that no mailbox exists.
|
||||
func searchMailPickMailbox(data map[string]any) string {
|
||||
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
|
||||
for _, m := range searchMailUnwrapList(data, containers...) {
|
||||
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
|
||||
return addr
|
||||
}
|
||||
// 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
|
||||
}
|
||||
scanStrings := func(m map[string]any) string {
|
||||
for _, key := range containers {
|
||||
if arr, ok := m[key].([]any); ok {
|
||||
for _, it := range arr {
|
||||
if s, ok := it.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"}
|
||||
var value any
|
||||
selectedPath := ""
|
||||
for _, path := range paths {
|
||||
candidate, present := smartMailLookup(data, path)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if s := scanStrings(data); s != "" {
|
||||
return s
|
||||
}
|
||||
for _, wrap := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrap].(map[string]any); ok {
|
||||
if s := scanStrings(inner); s != "" {
|
||||
return s
|
||||
}
|
||||
if selectedPath != "" {
|
||||
return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path))
|
||||
}
|
||||
value = candidate
|
||||
selectedPath = path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// searchMailMessages extracts the message list from a search_emails response.
|
||||
// The helper documents the list under "messages".
|
||||
func searchMailMessages(data map[string]any) []map[string]any {
|
||||
return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records")
|
||||
}
|
||||
|
||||
// searchMailUnwrapList probes the given container keys at the top level, and one
|
||||
// level deep, returning the first list found as []map[string]any.
|
||||
func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any {
|
||||
for _, key := range keys {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return searchMailToMaps(arr)
|
||||
}
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, k2 := range keys {
|
||||
if arr, ok := inner[k2].([]any); ok {
|
||||
return searchMailToMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if selectedPath == "" {
|
||||
return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchMailToMaps(arr []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value))
|
||||
}
|
||||
return out
|
||||
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 {
|
||||
switch v := m["from"].(type) {
|
||||
func searchMailFrom(m map[string]any) (any, error) {
|
||||
raw, present := m["from"]
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
return v
|
||||
if normalized := strings.TrimSpace(v); normalized != "" {
|
||||
return normalized, nil
|
||||
}
|
||||
case map[string]any:
|
||||
name := searchMailFirstString(v, "name", "displayName")
|
||||
addr := searchMailFirstString(v, "email", "emailAddress", "address")
|
||||
switch {
|
||||
case name != "" && addr != "":
|
||||
return name + " <" + addr + ">"
|
||||
return name + " <" + addr + ">", nil
|
||||
case addr != "":
|
||||
return addr
|
||||
return addr, nil
|
||||
case name != "":
|
||||
return name
|
||||
return name, nil
|
||||
}
|
||||
return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱")
|
||||
case nil:
|
||||
if present {
|
||||
return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null")
|
||||
}
|
||||
default:
|
||||
return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw))
|
||||
}
|
||||
if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" {
|
||||
return s
|
||||
return s, nil
|
||||
}
|
||||
return ""
|
||||
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 {
|
||||
for _, key := range keys {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -259,8 +279,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string {
|
||||
func searchMailFirstAny(m map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
if s, isStr := v.(string); isStr && s == "" {
|
||||
continue
|
||||
if s, isStr := v.(string); isStr {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -269,5 +292,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any {
|
||||
}
|
||||
|
||||
func init() {
|
||||
hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要")
|
||||
shortcut.Register(SearchMail)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -41,10 +42,11 @@ import (
|
||||
//
|
||||
// dws attendance +this-month
|
||||
var ThisMonthAttendance = shortcut.Shortcut{
|
||||
Service: "attendance",
|
||||
Command: "+this-month",
|
||||
Product: "attendance",
|
||||
Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Service: "attendance",
|
||||
Command: "+this-month",
|
||||
Product: "attendance",
|
||||
Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " +
|
||||
"再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," +
|
||||
"最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。",
|
||||
@@ -63,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{
|
||||
},
|
||||
Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
|
||||
@@ -84,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID := myAttendanceCurrentUserID(profile)
|
||||
userID := strictAttendanceCurrentUserID(profile)
|
||||
if userID == "" {
|
||||
return apperrors.NewValidation(
|
||||
"没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。")
|
||||
@@ -110,7 +112,7 @@ var ThisMonthAttendance = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
return outputStrictAttendanceRecords(rt, data)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// TriageMail aligns the Lark task-level triage entry while preserving DWS's
|
||||
// easier mailbox and inbox resolution. It returns summaries only; callers use
|
||||
// +message or +messages for full bodies.
|
||||
var TriageMail = shortcut.Shortcut{
|
||||
Service: "mail", Command: "+triage", Product: "mail",
|
||||
Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage",
|
||||
CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
|
||||
UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"},
|
||||
AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"},
|
||||
Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"},
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return smartMailValidatePageSize(rt, "limit", true)
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
email := rt.Str("email")
|
||||
if email == "" {
|
||||
resolved, err := searchMailFirstMailbox(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email = resolved
|
||||
}
|
||||
query := rt.Str("query")
|
||||
if query == "" {
|
||||
folderID, err := recentMailInboxFolder(rt, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query = "folderId:" + folderID
|
||||
}
|
||||
args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))}
|
||||
if rt.Changed("cursor") {
|
||||
args["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_emails", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages, err := smartMailSearchRows(data, "mail/search_emails")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
from, err := searchMailFrom(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, map[string]any{
|
||||
"messageId": searchMailFirstString(message, "id"),
|
||||
"subject": searchMailFirstString(message, "subject"),
|
||||
"from": from,
|
||||
"date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"),
|
||||
})
|
||||
}
|
||||
complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smartMailOutputPage(rt, "messages", rows, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据")
|
||||
shortcut.Register(TriageMail)
|
||||
}
|
||||
@@ -80,7 +80,12 @@ var UnreadMail = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
|
||||
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
|
||||
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return smartMailValidateStringPageSize(rt, "size")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws mail +unread-mail`,
|
||||
@@ -88,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{
|
||||
`dws mail +unread-mail --size 50`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
size, err := smartMailStringPageSize(rt, "size", "20")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Step 1 — resolve the mailbox address.
|
||||
email := rt.Str("email")
|
||||
if email == "" {
|
||||
@@ -101,34 +110,47 @@ var UnreadMail = shortcut.Shortcut{
|
||||
// Step 2 — search unread mail. email/query/size mirror the search_emails
|
||||
// call in helpers.messageSearch; the isRead:false KQL filter matches the
|
||||
// isRead field documented there; size is passed as a string.
|
||||
size := rt.Str("size")
|
||||
if size == "" {
|
||||
size = "20"
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
|
||||
args := map[string]any{
|
||||
"email": email,
|
||||
"query": "isRead:false",
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
args["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("mail", "search_emails", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — project each message to a compact record (shared helpers).
|
||||
messages := searchMailMessages(data)
|
||||
messages, err := smartMailSearchRows(data, "mail/search_emails")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
from, err := searchMailFrom(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"subject": searchMailFirstString(m, "subject", "title", "topic"),
|
||||
"from": searchMailFrom(m),
|
||||
"from": from,
|
||||
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
|
||||
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
|
||||
})
|
||||
}
|
||||
return rt.Output(map[string]any{"messages": out, "email": email})
|
||||
complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smartMailOutputPage(rt, "messages", out, complete, next)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要")
|
||||
markSmartMailCompatibilityOnly(&UnreadMail)
|
||||
shortcut.Register(UnreadMail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type todoPageSizeCaller struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil
|
||||
}
|
||||
|
||||
func (*todoPageSizeCaller) Format() string { return "json" }
|
||||
func (*todoPageSizeCaller) DryRun() bool { return false }
|
||||
func (*todoPageSizeCaller) Fields() string { return "" }
|
||||
func (*todoPageSizeCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) {
|
||||
caller := &todoPageSizeCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--size") {
|
||||
t.Fatalf("size 21 error = %v, want local --size validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("size 21 made %d remote calls, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,10 @@ type Shortcut struct {
|
||||
Tips []string
|
||||
// Hidden hides the command from listings while keeping it invocable.
|
||||
Hidden bool
|
||||
// CompatibilityVisible preserves a historically visible CLI command while
|
||||
// keeping it out of the Agent/public Shortcut catalog. Such a command is
|
||||
// shown only by `dws shortcut list --all` and must remain unavailable.
|
||||
CompatibilityVisible bool
|
||||
// Disposition is the reviewed semantic relation to the Runtime Schema leaf
|
||||
// surface. It determines default Agent discovery independently from live
|
||||
// fixture evidence.
|
||||
|
||||
@@ -56,7 +56,7 @@ func newListCommand() *cobra.Command {
|
||||
if svc != "" && s.Service != svc {
|
||||
continue
|
||||
}
|
||||
if s.Hidden && !includeHidden {
|
||||
if (s.Hidden || s.CompatibilityVisible) && !includeHidden {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, newShortcutListRow(s))
|
||||
@@ -75,23 +75,24 @@ func newListCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
type shortcutListRow struct {
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
CLIPath string `json:"cli_path"`
|
||||
Product string `json:"product"`
|
||||
Risk string `json:"risk"`
|
||||
Confirmation string `json:"confirmation"`
|
||||
Description string `json:"description"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Flags []shortcut.Flag `json:"flags"`
|
||||
Constraints []shortcut.Constraint `json:"constraints"`
|
||||
Examples []string `json:"examples"`
|
||||
Public bool `json:"public"`
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
SemanticDelta string `json:"semantic_delta,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
CLIPath string `json:"cli_path"`
|
||||
Product string `json:"product"`
|
||||
Risk string `json:"risk"`
|
||||
Confirmation string `json:"confirmation"`
|
||||
Description string `json:"description"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Flags []shortcut.Flag `json:"flags"`
|
||||
Constraints []shortcut.Constraint `json:"constraints"`
|
||||
Examples []string `json:"examples"`
|
||||
Public bool `json:"public"`
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
SemanticDelta string `json:"semantic_delta,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
CompatibilityVisible bool `json:"compatibility_visible,omitempty"`
|
||||
}
|
||||
|
||||
func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
|
||||
@@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
|
||||
constraints := append([]shortcut.Constraint{}, s.Constraints...)
|
||||
examples := append([]string{}, s.Tips...)
|
||||
return shortcutListRow{
|
||||
Service: s.Service,
|
||||
Command: s.Command,
|
||||
CLIPath: s.Service + " " + s.Command,
|
||||
Product: product,
|
||||
Risk: risk,
|
||||
Confirmation: confirmation,
|
||||
Description: s.Description,
|
||||
Intent: s.Intent,
|
||||
Flags: flags,
|
||||
Constraints: constraints,
|
||||
Examples: examples,
|
||||
Public: !s.Hidden,
|
||||
Disposition: string(s.Disposition),
|
||||
SemanticDelta: s.SemanticDelta,
|
||||
Availability: string(s.Availability),
|
||||
Primary: s.PrimaryCommand,
|
||||
Reviewed: s.SemanticReviewed,
|
||||
Service: s.Service,
|
||||
Command: s.Command,
|
||||
CLIPath: s.Service + " " + s.Command,
|
||||
Product: product,
|
||||
Risk: risk,
|
||||
Confirmation: confirmation,
|
||||
Description: s.Description,
|
||||
Intent: s.Intent,
|
||||
Flags: flags,
|
||||
Constraints: constraints,
|
||||
Examples: examples,
|
||||
Public: !s.Hidden && !s.CompatibilityVisible,
|
||||
Disposition: string(s.Disposition),
|
||||
SemanticDelta: s.SemanticDelta,
|
||||
Availability: string(s.Availability),
|
||||
Primary: s.PrimaryCommand,
|
||||
Reviewed: s.SemanticReviewed,
|
||||
CompatibilityVisible: s.CompatibilityVisible,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user