Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01476727e9 | ||
|
|
8d34acbb38 | ||
|
|
9401f9921a | ||
|
|
ed951ff0e1 | ||
|
|
b42596214d | ||
|
|
f684c412ea | ||
|
|
a4813f78bc | ||
|
|
415d962371 | ||
|
|
26253a4eeb | ||
|
|
46fdaed90b | ||
|
|
9ea19315e9 | ||
|
|
8549a90402 | ||
|
|
a5dbd2e049 | ||
|
|
0ebdcb455d | ||
|
|
b1a6f9e20c | ||
|
|
29a0dde9d4 | ||
|
|
0fae9dd8b3 | ||
|
|
6096227511 | ||
|
|
aa265acd50 | ||
|
|
7210e5677c | ||
|
|
1c90623e31 | ||
|
|
e7236e27d4 | ||
|
|
e73ebab2db | ||
|
|
1b6a592387 | ||
|
|
ce7f66ff8e | ||
|
|
e58f255476 | ||
|
|
79f4f66d34 | ||
|
|
66061bb0b0 | ||
|
|
1d14258121 | ||
|
|
cbbc9a9f90 | ||
|
|
723d43f660 | ||
|
|
12d60d264d | ||
|
|
7bb2019dfe | ||
|
|
9e692ce0bf | ||
|
|
e2b390217a | ||
|
|
09ecaaa7a1 | ||
|
|
95ecc99c41 | ||
|
|
6df6b3f8dd | ||
|
|
227861a71d | ||
|
|
20a01aaeff | ||
|
|
7844b59a8c | ||
|
|
2a875eee1d | ||
|
|
c75eda1a2b | ||
|
|
662352202c | ||
|
|
fa2d54fd42 | ||
|
|
c5d58ec49d | ||
|
|
adbc9aac3e | ||
|
|
82a87bf9ce | ||
|
|
0bfb1ff1de | ||
|
|
426ad50927 | ||
|
|
b57cecb62d | ||
|
|
8dc52d052c | ||
|
|
f8e3f0b12a | ||
|
|
aab67202a0 | ||
|
|
2e60b8e121 | ||
|
|
6147021ae8 | ||
|
|
f84fc1d684 | ||
|
|
ae31d371de | ||
|
|
c20a53cd8a | ||
|
|
2dc52afd60 | ||
|
|
21bca3025f | ||
|
|
874b5b091a | ||
|
|
1bb56364c2 | ||
|
|
cd93b7fdc2 | ||
|
|
758b0f875e | ||
|
|
fdb5fc19f5 | ||
|
|
583d31141d | ||
|
|
19be571574 | ||
|
|
38360969e7 | ||
|
|
f9277ae1d3 | ||
|
|
232d9dee6e | ||
|
|
c2528f1fc8 | ||
|
|
1b22b79fa9 | ||
|
|
f3e98d55e8 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
@@ -544,7 +544,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -692,7 +693,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -1597,7 +1599,10 @@ jobs:
|
||||
name: Policy
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Full policy regenerates and validates the runtime Schema several times.
|
||||
# Keep job-level headroom for large reviewed command-surface additions;
|
||||
# individual policy gates retain their own fail-closed checks.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.2] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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.60-beta.1"
|
||||
version "1.0.60-beta.2"
|
||||
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.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
{
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"generated_at": "2026-08-24T20:14:49.172788",
|
||||
"count": 437,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+contract-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+obj-template-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-statistics-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-submit-detail",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+user-rules",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
@@ -2473,137 +2523,254 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+credentials-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+disable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+enable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-subscribe",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+permission-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-config",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-check-approval",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-config",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
@@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
@@ -231,12 +231,113 @@ 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
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for
|
||||
// reviewed parameter-concept product drafts. Keeping candidate-only commands
|
||||
// in a separate map lets a test change land before a 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{
|
||||
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
|
||||
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
|
||||
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
|
||||
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
|
||||
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
|
||||
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
|
||||
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
|
||||
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
|
||||
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
|
||||
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
|
||||
"contact +org": {"contact", "+org", "--name", "Fixture User"},
|
||||
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
|
||||
"contact +team": {"contact", "+team", "--name", "Fixture User"},
|
||||
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
|
||||
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
|
||||
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
|
||||
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
|
||||
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
|
||||
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
|
||||
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
|
||||
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
|
||||
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
|
||||
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
|
||||
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
|
||||
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
|
||||
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
|
||||
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
|
||||
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
|
||||
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
|
||||
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
|
||||
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
|
||||
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
|
||||
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
|
||||
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
|
||||
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
|
||||
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
|
||||
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
|
||||
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
|
||||
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
|
||||
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
|
||||
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
@@ -296,6 +397,20 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"markdown create": {
|
||||
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
},
|
||||
"markdown diff": {
|
||||
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
|
||||
},
|
||||
"markdown overwrite": {
|
||||
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
|
||||
},
|
||||
"markdown patch": {
|
||||
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
|
||||
},
|
||||
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
@@ -969,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// reviewed product expansions. The fixture gate already proves every
|
||||
// alias resolves through PreParse; this gate removes the confirmation flag
|
||||
// from one active alias invocation per distinct template and requires the
|
||||
// runtime boundary to stop it before the first transport call. An explicit
|
||||
// --dry-run is a reviewed preview path and must not carry a bypass flag.
|
||||
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
requiredTemplates := make(map[string]bool)
|
||||
coveredTemplates := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, yesCount := removeExactArg(complete, "--yes")
|
||||
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
|
||||
confirmationCount := yesCount + userSayYesCount
|
||||
confirmationArg := "--yes"
|
||||
if userSayYesCount == 1 {
|
||||
confirmationArg = "--user-say-yes"
|
||||
}
|
||||
_, dryRunCount := removeExactArg(complete, "--dry-run")
|
||||
if dryRunCount > 1 {
|
||||
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
|
||||
switch meta.Safety.Confirmation {
|
||||
case "user_required":
|
||||
if dryRunCount == 1 {
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
case "not_required":
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if confirmationCount == 0 {
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
|
||||
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
|
||||
requiredTemplates[templateKey] = true
|
||||
if coveredTemplates[templateKey] {
|
||||
continue
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
|
||||
continue
|
||||
}
|
||||
coveredTemplates[templateKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
if len(requiredTemplates) == 0 {
|
||||
t.Fatal("reviewed complete-command templates contain no confirmation cases")
|
||||
}
|
||||
if len(coveredTemplates) != len(requiredTemplates) {
|
||||
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
|
||||
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 425
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 482
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -20,18 +20,50 @@ import (
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
operation string
|
||||
retryable *bool
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
combined := text + " " + detail
|
||||
|
||||
if code == "999" &&
|
||||
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
|
||||
classified := serverFailureClass{
|
||||
message: message,
|
||||
reason: "upstream_internal_error",
|
||||
origin: "dingtalk_api",
|
||||
stage: "upstream_execution",
|
||||
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
|
||||
actions: []string{
|
||||
"检查目标资源的当前状态,确认本次操作是否已经生效",
|
||||
"状态未确认前不要直接重试写操作",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
|
||||
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
|
||||
retryable := false
|
||||
classified.operation = "todo/create_personal_todo"
|
||||
classified.retryable = &retryable
|
||||
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
|
||||
classified.actions = []string{
|
||||
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
|
||||
"确认没有创建成功后再重新提交",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
}
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
tool string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
if classified.operation != "" {
|
||||
opts = append(opts, apperrors.WithOperation(classified.operation))
|
||||
}
|
||||
if classified.retryable != nil {
|
||||
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
|
||||
}
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
"send_message",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
serverSaysRetryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-todo-create",
|
||||
ServerErrorCode: "999",
|
||||
ServerRetryable: &serverSaysRetryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
|
||||
t.Fatalf("hint = %q", typed.Hint)
|
||||
}
|
||||
for _, action := range typed.Actions {
|
||||
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
|
||||
t.Fatalf("misleading action = %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "upstream_internal_error",
|
||||
"origin": "dingtalk_api",
|
||||
"stage": "upstream_execution",
|
||||
"retryable": false,
|
||||
"trace_id": "trace-todo-create",
|
||||
"server_error_code": "999",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "999",
|
||||
"trace_id": "trace-todo-replay",
|
||||
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
CanonicalPath: "todo.create_personal_todo",
|
||||
Params: map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": "fixture",
|
||||
"executorIds": []string{"user-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
// The organization mirror may be absent for long-lived local logins
|
||||
// that predate mirror publication. Recover from the legacy global
|
||||
// slot before giving up.
|
||||
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
|
||||
return recovered, nil
|
||||
}
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
return nil, refreshErr
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
|
||||
"corp_id", strings.TrimSpace(selected.CorpID),
|
||||
"user_id", strings.TrimSpace(selected.UserID),
|
||||
"refresh_error_code", exchangeErr.Code,
|
||||
)
|
||||
legacy, loadErr := tokenLoadKeychain()
|
||||
if loadErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
if legacy == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
|
||||
"step", "candidate_mismatch",
|
||||
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
|
||||
"legacy_user_id", strings.TrimSpace(legacy.UserID),
|
||||
)
|
||||
return nil, refreshErr
|
||||
}
|
||||
recovered := *legacy
|
||||
if strings.TrimSpace(recovered.UserID) == "" {
|
||||
recovered.UserID = strings.TrimSpace(selected.UserID)
|
||||
}
|
||||
if strings.TrimSpace(recovered.UserName) == "" {
|
||||
recovered.UserName = strings.TrimSpace(selected.UserName)
|
||||
}
|
||||
if recovered.IsAccessTokenValid() {
|
||||
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
|
||||
return &recovered, nil
|
||||
}
|
||||
if !recovered.IsRefreshTokenValid() {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
|
||||
if recoverErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
|
||||
if selected == nil || legacy == nil {
|
||||
return false
|
||||
}
|
||||
selectedCorpID := strings.TrimSpace(selected.CorpID)
|
||||
legacyCorpID := strings.TrimSpace(legacy.CorpID)
|
||||
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
|
||||
return false
|
||||
}
|
||||
selectedUserID := strings.TrimSpace(selected.UserID)
|
||||
legacyUserID := strings.TrimSpace(legacy.UserID)
|
||||
if legacyUserID != "" {
|
||||
return legacyUserID == selectedUserID
|
||||
}
|
||||
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
|
||||
}
|
||||
|
||||
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil || cfg == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
|
||||
return false
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "multi_profile",
|
||||
"corp_id", strings.TrimSpace(corpID),
|
||||
"profile_count", len(profiles),
|
||||
)
|
||||
return false
|
||||
}
|
||||
profile := profiles[0]
|
||||
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
|
||||
return true
|
||||
}
|
||||
profileUserID := ""
|
||||
if profile != nil {
|
||||
profileUserID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "identity_mismatch",
|
||||
"selected_user_id", strings.TrimSpace(userID),
|
||||
"profile_user_id", profileUserID,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
// external host, exchanges it for tokens, and persists them.
|
||||
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "V1039 User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
UserName: selected.UserName,
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
var saved *TokenData
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
||||
copy := *data
|
||||
saved = ©
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
||||
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
||||
}
|
||||
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
||||
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
||||
}
|
||||
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
||||
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-multi",
|
||||
UserID: "user-v1039-a",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
||||
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-blank-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
||||
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-user-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token owned by a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-single-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "Other User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-legacy-refresh",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
refreshed := &TokenData{
|
||||
AccessToken: "refreshed-legacy-access",
|
||||
RefreshToken: "rotated-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
refreshCalls := 0
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls++
|
||||
if refreshCalls == 1 {
|
||||
return nil, rejection
|
||||
}
|
||||
return refreshed, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if refreshCalls != 2 {
|
||||
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
||||
}
|
||||
if recovered.AccessToken != refreshed.AccessToken {
|
||||
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
||||
}
|
||||
if recovered.RefreshToken != refreshed.RefreshToken {
|
||||
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-profiles-error",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "shared-rejected-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-same-refresh",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: selected.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
// nil selected must be rejected before any dereference.
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil selected error = %v, want original rejection", err)
|
||||
}
|
||||
|
||||
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-nil-legacy",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
||||
|
||||
plainErr := errors.New("plain refresh failure")
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
||||
t.Fatalf("plain error = %v, want original plain failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-recovery-steps",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
t.Run("save_failure", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("save failure error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_expired", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "expired-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(-time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, errors.New("legacy refresh failed")
|
||||
})
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("preflight error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
||||
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
||||
t.Fatal("nil legacy accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
||||
t.Fatal("different corp accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
||||
t.Fatal("blank selected corp accepted")
|
||||
}
|
||||
|
||||
blankSelected := &TokenData{CorpID: selected.CorpID}
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
||||
})
|
||||
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
||||
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
||||
t.Fatal("blank selected with non-blank legacy accepted")
|
||||
}
|
||||
|
||||
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
||||
t.Fatal("blank corp accepted by single-profile check")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1085
-644
File diff suppressed because one or more lines are too long
@@ -96,7 +96,14 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -554,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -646,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
|
||||
cmd := newCollegeContactCommand()
|
||||
|
||||
if cmd.Name() != "college-contact" {
|
||||
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
|
||||
}
|
||||
if !cmd.Hidden {
|
||||
t.Error("extension root command should be Hidden")
|
||||
}
|
||||
|
||||
// 分组 → 叶子命令映射
|
||||
groups := map[string][]string{
|
||||
"dept": {
|
||||
"get-standard-structure", "get-detail", "get-chain", "search",
|
||||
"create", "update", "delete", "batch-update-type", "overview",
|
||||
},
|
||||
"employee": {
|
||||
"get-detail", "add", "remove", "change-type", "change-dept",
|
||||
"send-active-sms", "list-employees", "list-unaccepted",
|
||||
"list-unactive", "upgrade-status", "start-upgrade",
|
||||
},
|
||||
"alumni": {
|
||||
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
|
||||
},
|
||||
"graduate": {
|
||||
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
|
||||
},
|
||||
"group": {
|
||||
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
|
||||
},
|
||||
}
|
||||
|
||||
for groupName, leaves := range groups {
|
||||
var groupCmd *cobra.Command
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == groupName {
|
||||
groupCmd = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if groupCmd == nil {
|
||||
t.Fatalf("subcommand group %q not found", groupName)
|
||||
}
|
||||
for _, leaf := range leaves {
|
||||
found := false
|
||||
for _, c := range groupCmd.Commands() {
|
||||
if c.Name() == leaf {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("leaf command %q not found under %q", leaf, groupName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stats 分组已移除
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == "stats" {
|
||||
t.Error("subcommand group 'stats' should be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
|
||||
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
|
||||
if err != nil {
|
||||
t.Fatalf("command path not found: %v", err)
|
||||
}
|
||||
if c.Name() != "get-standard-structure" {
|
||||
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
|
||||
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newCollegeContactTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
|
||||
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
|
||||
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
|
||||
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
|
||||
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
|
||||
t.Helper()
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(args)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
err = root.Execute()
|
||||
return err, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if panicked {
|
||||
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") {
|
||||
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if !panicked {
|
||||
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
|
||||
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactCaller installs a dry-run capture caller so happy-path
|
||||
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
|
||||
// without requiring a live MCP transport. In dry-run mode destructive
|
||||
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
|
||||
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactHappyPaths runs every leaf command with required flags only
|
||||
// and with all optional flags populated, expecting a nil error (dry-run preview).
|
||||
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure"},
|
||||
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-detail", "--dept-id", "123"},
|
||||
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-chain", "--dept-id", "123"},
|
||||
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "delete", "--dept-id", "123"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
|
||||
{"dept", "overview"},
|
||||
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail", "--staff-id", "S1"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "remove", "--staff-ids", "S1"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-employees", "--dept-id", "123"},
|
||||
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unactive", "--dept-id", "123"},
|
||||
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "upgrade-status"},
|
||||
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"employee", "start-upgrade"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "123"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "query", "--staff-id", "S1"},
|
||||
{"alumni", "search", "--keyword", "x"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "get-alumni-org-from-graduate"},
|
||||
{"alumni", "create-alumni-org", "--org-name", "O"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-years"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
|
||||
{"graduate", "get-alumni-org"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "search-graduate", "--keyword", "x"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule"},
|
||||
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
|
||||
{"group", "get-group-rule-schedule"},
|
||||
{"group", "query-preview-data"},
|
||||
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
|
||||
{"group", "delete-group-rule", "--rule-id", "1"},
|
||||
{"group", "enable-group-rule", "--rule-id", "1"},
|
||||
{"group", "disable-group-rule", "--rule-id", "1"},
|
||||
{"group", "set-group-rule-schedule"},
|
||||
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
|
||||
{"group", "execute-group-rule"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err != nil {
|
||||
t.Errorf("%v: expected nil error, got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollegeContactValidationErrors exercises every validation-error branch:
|
||||
// missing required flags, non-integer int flags, invalid bool flags, and
|
||||
// empty-after-split CSV lists. Each case must return a non-nil error.
|
||||
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure", "--dept-id", "abc"},
|
||||
{"dept", "get-standard-structure", "--offset", "abc"},
|
||||
{"dept", "get-standard-structure", "--size", "abc"},
|
||||
{"dept", "get-detail"},
|
||||
{"dept", "get-detail", "--dept-id", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "get-chain"},
|
||||
{"dept", "get-chain", "--dept-id", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "search"},
|
||||
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "1"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
|
||||
{"dept", "create"},
|
||||
{"dept", "create", "--super-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
|
||||
{"dept", "update"},
|
||||
{"dept", "update", "--dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "delete"},
|
||||
{"dept", "delete", "--dept-id", "abc"},
|
||||
{"dept", "batch-update-type"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "1,2"},
|
||||
{"dept", "overview", "--dept-id", "abc"},
|
||||
{"dept", "overview", "--offset", "abc"},
|
||||
{"dept", "overview", "--size", "abc"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "add"},
|
||||
{"employee", "add", "--emp-type", "x"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
|
||||
{"employee", "remove"},
|
||||
{"employee", "remove", "--staff-ids", ",,"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-type"},
|
||||
{"employee", "change-type", "--staff-id", "S1"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-dept"},
|
||||
{"employee", "change-dept", "--staff-id", "S1"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
|
||||
{"employee", "send-active-sms"},
|
||||
{"employee", "send-active-sms", "--dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-employees"},
|
||||
{"employee", "list-employees", "--dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unaccepted"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unactive"},
|
||||
{"employee", "list-unactive", "--dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "upgrade-status", "--dept-id", "abc"},
|
||||
{"employee", "upgrade-status", "--offset", "abc"},
|
||||
{"employee", "upgrade-status", "--size", "abc"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree"},
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "get-info"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list"},
|
||||
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
|
||||
{"alumni", "query"},
|
||||
{"alumni", "search"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
|
||||
{"alumni", "list-unaccepted"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
|
||||
{"alumni", "get-group"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-dept"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-dept"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "delete-dept"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "update-managers"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
|
||||
{"alumni", "add-alumnus"},
|
||||
{"alumni", "add-alumnus", "--name", "X"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
|
||||
{"alumni", "update-alumnus"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
|
||||
{"alumni", "remove-alumnus"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "cancel-invite"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
|
||||
{"alumni", "create-group"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "disband-group"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-alumni-org"},
|
||||
{"alumni", "add-alumni-org-main-admins"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-depts"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
|
||||
{"graduate", "query-graduate-sub-depts"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "get-task-result"},
|
||||
{"graduate", "query-restore-sub-depts"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "search-graduate"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
|
||||
{"graduate", "commit-graduate"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
|
||||
{"graduate", "all-graduate"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "abc"},
|
||||
{"graduate", "batch-graduate"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "delete-and-graduate"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-delete-pending"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
|
||||
{"graduate", "commit-restore"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule", "--offset", "abc"},
|
||||
{"group", "query-group-rule", "--size", "abc"},
|
||||
{"group", "query-preview-data", "--offset", "abc"},
|
||||
{"group", "query-preview-data", "--size", "abc"},
|
||||
{"group", "create-group-rule"},
|
||||
{"group", "create-group-rule", "--name", "X"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
|
||||
{"group", "delete-group-rule"},
|
||||
{"group", "delete-group-rule", "--rule-id", "abc"},
|
||||
{"group", "enable-group-rule"},
|
||||
{"group", "enable-group-rule", "--rule-id", "abc"},
|
||||
{"group", "disable-group-rule"},
|
||||
{"group", "disable-group-rule", "--rule-id", "abc"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err == nil {
|
||||
t.Errorf("%v: expected non-nil error, got nil", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
|
||||
// user_required destructive leaf in a paired manner:
|
||||
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
|
||||
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
|
||||
// productID, tool name, and complete argument payload.
|
||||
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
|
||||
type destructiveCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}
|
||||
|
||||
cases := []destructiveCase{
|
||||
{
|
||||
"dept delete",
|
||||
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
|
||||
"delete_college_contact_dept",
|
||||
map[string]any{"deptId": int64(123)},
|
||||
},
|
||||
{
|
||||
"employee remove",
|
||||
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
|
||||
"remove_employee",
|
||||
map[string]any{"staffIds": []string{"S1", "S2"}},
|
||||
},
|
||||
{
|
||||
"alumni delete-dept",
|
||||
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
"delete_alumni_dept",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni remove-alumnus",
|
||||
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
"delete_alumnus",
|
||||
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni cancel-invite",
|
||||
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_alumni_invite_record",
|
||||
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"alumni disband-group",
|
||||
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
"disband_alumni_group",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"graduate commit-graduate",
|
||||
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
"commit_graduate",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate all-graduate",
|
||||
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
"all_graduate",
|
||||
map[string]any{"graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate batch-graduate",
|
||||
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate delete-and-graduate",
|
||||
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_and_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-delete-pending",
|
||||
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_delete_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-update-pending",
|
||||
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
"batch_update_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate commit-restore",
|
||||
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
"commit_restore",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}},
|
||||
},
|
||||
{
|
||||
"group delete-group-rule",
|
||||
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
|
||||
"delete_group_rule",
|
||||
map[string]any{"ruleId": int64(1)},
|
||||
},
|
||||
{
|
||||
"group execute-group-rule",
|
||||
[]string{"college-contact", "group", "execute-group-rule"},
|
||||
"execute_group_rule",
|
||||
map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
argsWithYes := append(append([]string{}, tc.args...), "--yes")
|
||||
root.SetArgs(argsWithYes)
|
||||
err := root.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "college-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
|
||||
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
|
||||
// verify the productID, tool name, and args passed to callMCPToolOnServer.
|
||||
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactDispatch verifies that representative commands from each
|
||||
// group dispatch to the correct MCP tool with the expected productID and args.
|
||||
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
|
||||
type dispatchCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantProd string
|
||||
checkArgs func(t *testing.T, args map[string]any)
|
||||
}
|
||||
|
||||
cases := []dispatchCase{
|
||||
{
|
||||
name: "dept get-standard-structure",
|
||||
args: []string{"college-contact", "dept", "get-standard-structure"},
|
||||
wantTool: "get_college_standard_structure",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept get-detail",
|
||||
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
|
||||
wantTool: "get_college_dept_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dept create",
|
||||
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
wantTool: "create_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "employee get-detail",
|
||||
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
|
||||
wantTool: "get_employee_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["staffId"] != "S1" {
|
||||
t.Errorf("staffId = %v, want S1", input["staffId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alumni get-dept-tree",
|
||||
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
wantTool: "get_alumni_dept_tree",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["alumniDeptId"] != int64(123) {
|
||||
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graduate query-graduate-years",
|
||||
args: []string{"college-contact", "graduate", "query-graduate-years"},
|
||||
wantTool: "query_graduate_years",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "group query-group-rule",
|
||||
args: []string{"college-contact", "group", "query-group-rule"},
|
||||
wantTool: "query_group_rule",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept delete with --yes",
|
||||
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
|
||||
wantTool: "delete_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := withCollegeContactDispatchCaller(t)
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
root.SetArgs(tc.args)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if caller.productID != tc.wantProd {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
|
||||
}
|
||||
if caller.tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
|
||||
}
|
||||
if tc.checkArgs != nil {
|
||||
tc.checkArgs(t, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
|
||||
if os.Getenv(helpersShellStubEnv) == "1" {
|
||||
os.Exit(runHelpersShellStub())
|
||||
}
|
||||
// Cobra's Windows pre-exec hook walks the process table on every Execute*
|
||||
// call to detect Explorer launches. Helpers tests execute command trees
|
||||
// thousands of times, and none of those in-process invocations can be an
|
||||
// Explorer launch, so keep that production-only check out of the test
|
||||
// process. This also prevents every new exhaustive harness from having to
|
||||
// remember a test-local override.
|
||||
originalMousetrapHelpText := cobra.MousetrapHelpText
|
||||
cobra.MousetrapHelpText = ""
|
||||
code := m.Run()
|
||||
cobra.MousetrapHelpText = originalMousetrapHelpText
|
||||
if helpersShellStubBaseDir != "" {
|
||||
_ = os.RemoveAll(helpersShellStubBaseDir)
|
||||
}
|
||||
|
||||
+123
-2
@@ -2336,6 +2336,127 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
drivePermGetSettingCmd := &cobra.Command{
|
||||
Use: "get-setting",
|
||||
Short: "查询节点权限设置",
|
||||
Long: `查询文档空间节点的权限设置,返回三部分配置:
|
||||
|
||||
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
|
||||
- shareScope: 分享范围(可见范围、链接分享设置)
|
||||
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
|
||||
|
||||
查询协作者列表请改用 permission list。`,
|
||||
Example: ` dws drive permission get-setting --node DOC_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "get_permission_setting",
|
||||
CanonicalPath: "drive.get_permission_setting",
|
||||
CLIPath: "drive permission get-setting",
|
||||
PrimaryCLIPath: "drive permission get-setting",
|
||||
},
|
||||
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"节点权限设置(权限模式/分享范围/权限策略)",
|
||||
"properties":{
|
||||
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
|
||||
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
|
||||
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
|
||||
"shareScope":{
|
||||
"type":"object",
|
||||
"description":"分享范围设置",
|
||||
"properties":{
|
||||
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
|
||||
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
|
||||
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
|
||||
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
|
||||
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
|
||||
"linkShare":{
|
||||
"type":"object",
|
||||
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
|
||||
"properties":{
|
||||
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
|
||||
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
|
||||
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
|
||||
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
|
||||
},
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"additionalProperties":true
|
||||
},
|
||||
"policies":{
|
||||
"type":"array",
|
||||
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"description":"权限策略项",
|
||||
"properties":{
|
||||
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
|
||||
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
|
||||
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
|
||||
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
|
||||
"disabledValues":{
|
||||
"type":"array",
|
||||
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
|
||||
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
|
||||
},
|
||||
"required":["value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
|
||||
},
|
||||
"required":["code","name","description","disabledValues"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["docUrl","nodeId","shareScope","policies"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
|
||||
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
|
||||
AvoidWhen: []string{
|
||||
"查协作者清单用 permission list",
|
||||
"查可申请角色与审批人用 permission apply-info",
|
||||
},
|
||||
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
|
||||
drivePermRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
@@ -2429,7 +2550,7 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} {
|
||||
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
|
||||
c.Flags().String("url", "", "")
|
||||
c.Flags().String("id", "", "")
|
||||
c.Flags().String("node-id", "", "")
|
||||
@@ -2661,7 +2782,7 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
|
||||
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
|
||||
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ──
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting", "--node", "node-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want exactly one", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "drive" || call.toolName != "get_permission_setting" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
if len(call.args) != 1 || call.args["nodeId"] != "node-1" {
|
||||
t.Fatalf("args = %#v, want only nodeId=node-1", call.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) {
|
||||
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting", "--"+alias, "node-alias")
|
||||
if err != nil {
|
||||
t.Fatalf("alias --%s: %v", alias, err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "drive" || call.toolName != "get_permission_setting" {
|
||||
t.Fatalf("alias --%s call = %#v", alias, call)
|
||||
}
|
||||
if call.args["nodeId"] != "node-alias" {
|
||||
t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting")
|
||||
if err == nil || !strings.Contains(err.Error(), "flag --node is required") {
|
||||
t.Fatalf("err = %v, want flag --node is required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── drive permission get-setting:ResultSpec 返回值契约 ──
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) {
|
||||
drive := newDriveCommand()
|
||||
leaf, _, err := drive.Find([]string{"permission", "get-setting"})
|
||||
if err != nil || leaf == nil {
|
||||
t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" {
|
||||
t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok)
|
||||
}
|
||||
if final.Result == nil {
|
||||
t.Fatal("get-setting final Result is nil")
|
||||
}
|
||||
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
|
||||
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
|
||||
}
|
||||
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil {
|
||||
t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema)
|
||||
}
|
||||
assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies")
|
||||
properties := resultSchemaProperties(t, final.Result.DataSchema)
|
||||
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) {
|
||||
t.Fatalf("result properties = %#v", got)
|
||||
}
|
||||
|
||||
permissionMode, ok := properties["permissionMode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"])
|
||||
}
|
||||
if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", "<null>"}) {
|
||||
t.Fatalf("permissionMode enum = %#v", got)
|
||||
}
|
||||
if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
|
||||
t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"])
|
||||
}
|
||||
|
||||
shareScope := schemaObjectProperty(t, properties, "shareScope")
|
||||
shareScopeProperties := schemaProperties(t, shareScope)
|
||||
if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) {
|
||||
t.Fatalf("shareScope properties = %#v", got)
|
||||
}
|
||||
if _, ok := shareScope["required"]; ok {
|
||||
t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"])
|
||||
}
|
||||
if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", "<null>"}) {
|
||||
t.Fatalf("visibility enum = %#v", got)
|
||||
}
|
||||
if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", "<null>"}) {
|
||||
t.Fatalf("defaultRole enum = %#v", got)
|
||||
}
|
||||
linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare"))
|
||||
if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) {
|
||||
t.Fatalf("linkShare properties = %#v", got)
|
||||
}
|
||||
|
||||
policies, ok := properties["policies"].(map[string]any)
|
||||
if !ok || policies["type"] != "array" {
|
||||
t.Fatalf("policies = %#v, want array schema", properties["policies"])
|
||||
}
|
||||
policiesDescription, _ := policies["description"].(string)
|
||||
for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} {
|
||||
if !strings.Contains(policiesDescription, fragment) {
|
||||
t.Fatalf("policies description missing %q: %s", fragment, policiesDescription)
|
||||
}
|
||||
}
|
||||
items, ok := policies["items"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("policies items = %#v", policies["items"])
|
||||
}
|
||||
assertSchemaRequired(t, items, "code", "name", "description", "disabledValues")
|
||||
itemProperties := schemaProperties(t, items)
|
||||
if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) {
|
||||
t.Fatalf("policy item properties = %#v", got)
|
||||
}
|
||||
code, ok := itemProperties["code"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("code = %#v", itemProperties["code"])
|
||||
}
|
||||
if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{
|
||||
"external_share", "external_share_manager_only", "member_invite", "member_invite_org_only",
|
||||
"comment", "permission_apply", "external_permission_apply", "watermark", "node_spread",
|
||||
"online_content_copy", "node_move_forbidden", "node_spread_scope",
|
||||
}) {
|
||||
t.Fatalf("code enum = %#v", got)
|
||||
}
|
||||
value, ok := itemProperties["value"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("value = %#v", itemProperties["value"])
|
||||
}
|
||||
valueDescription, _ := value["description"].(string)
|
||||
for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} {
|
||||
if !strings.Contains(valueDescription, fragment) {
|
||||
t.Fatalf("value description missing %q: %s", fragment, valueDescription)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"name", "description"} {
|
||||
entry, ok := itemProperties[field].(map[string]any)
|
||||
if !ok || entry["type"] != "string" {
|
||||
t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field])
|
||||
}
|
||||
entryDescription, _ := entry["description"].(string)
|
||||
if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") {
|
||||
t.Fatalf("policy item %s description = %q", field, entryDescription)
|
||||
}
|
||||
}
|
||||
disabledValues, ok := itemProperties["disabledValues"].(map[string]any)
|
||||
if !ok || disabledValues["type"] != "array" {
|
||||
t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"])
|
||||
}
|
||||
disabledValuesDescription, _ := disabledValues["description"].(string)
|
||||
for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} {
|
||||
if !strings.Contains(disabledValuesDescription, fragment) {
|
||||
t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription)
|
||||
}
|
||||
}
|
||||
disabledItems, ok := disabledValues["items"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("disabledValues items = %#v", disabledValues["items"])
|
||||
}
|
||||
assertSchemaRequired(t, disabledItems, "value")
|
||||
disabledItemProperties := schemaProperties(t, disabledItems)
|
||||
if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) {
|
||||
t.Fatalf("disabledValues item properties = %#v", got)
|
||||
}
|
||||
if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" {
|
||||
t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"])
|
||||
}
|
||||
reason, ok := disabledItemProperties["reason"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"])
|
||||
}
|
||||
if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
|
||||
t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"])
|
||||
}
|
||||
allowedValues, ok := itemProperties["allowedValues"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"])
|
||||
}
|
||||
if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") {
|
||||
t.Fatalf("allowedValues description = %q", allowedValues["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func schemaEnumValues(t *testing.T, property map[string]any) []string {
|
||||
t.Helper()
|
||||
raw, ok := property["enum"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema enum = %#v, want array", property["enum"])
|
||||
}
|
||||
values := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
case nil:
|
||||
values = append(values, "<null>")
|
||||
default:
|
||||
t.Fatalf("schema enum value = %#v, want string or null", value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,503 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func runEduApp(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
|
||||
// populated, so all optional-field branches and the dispatch line are covered.
|
||||
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
|
||||
|
||||
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
|
||||
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
|
||||
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
|
||||
|
||||
{"report", "get", "--ids", "1001,1002"},
|
||||
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
|
||||
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
|
||||
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
|
||||
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
|
||||
|
||||
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
|
||||
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
|
||||
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
|
||||
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
|
||||
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
|
||||
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
|
||||
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
|
||||
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
|
||||
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
|
||||
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
|
||||
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
|
||||
|
||||
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
|
||||
{"card", "end", "--card-id", "1"},
|
||||
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
|
||||
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
|
||||
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
|
||||
|
||||
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
|
||||
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
|
||||
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
|
||||
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
|
||||
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
|
||||
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
|
||||
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
|
||||
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
|
||||
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
|
||||
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
|
||||
|
||||
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
|
||||
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
|
||||
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
|
||||
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
|
||||
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
|
||||
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
|
||||
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
|
||||
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
|
||||
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
|
||||
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
|
||||
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
|
||||
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
|
||||
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
if err := runEduApp(t, args...); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
|
||||
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
|
||||
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"report", "by-teacher", "--page", "0", "--limit", "0"},
|
||||
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
|
||||
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
|
||||
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
|
||||
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
|
||||
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
|
||||
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
|
||||
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
|
||||
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
|
||||
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
|
||||
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
if err := runEduApp(t, args...); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
|
||||
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
|
||||
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
|
||||
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
|
||||
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
|
||||
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
|
||||
|
||||
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
|
||||
{"student-list missing students", []string{"task", "student-list"}, "students"},
|
||||
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
|
||||
|
||||
{"report get missing ids", []string{"report", "get"}, "ids"},
|
||||
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
|
||||
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
|
||||
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
|
||||
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
|
||||
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
|
||||
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
|
||||
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
|
||||
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
|
||||
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
|
||||
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
|
||||
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
|
||||
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
|
||||
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
|
||||
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
|
||||
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
|
||||
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
|
||||
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
|
||||
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
|
||||
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
|
||||
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
|
||||
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
|
||||
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
|
||||
|
||||
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
|
||||
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
|
||||
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
|
||||
|
||||
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
|
||||
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
|
||||
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
|
||||
{"card end missing card-id", []string{"card", "end"}, "card-id"},
|
||||
{"card list missing status", []string{"card", "list"}, "status"},
|
||||
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
|
||||
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
|
||||
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
|
||||
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
|
||||
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
|
||||
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
|
||||
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
|
||||
|
||||
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
|
||||
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
|
||||
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
|
||||
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
|
||||
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
|
||||
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
|
||||
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
|
||||
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
|
||||
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
|
||||
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
|
||||
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
|
||||
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
|
||||
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
|
||||
|
||||
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
|
||||
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
|
||||
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
|
||||
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
|
||||
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
|
||||
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
|
||||
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
|
||||
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
|
||||
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
|
||||
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
|
||||
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
|
||||
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
|
||||
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
|
||||
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
|
||||
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
|
||||
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
|
||||
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
|
||||
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
|
||||
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
|
||||
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
|
||||
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
|
||||
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
err := runEduApp(t, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
|
||||
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("eduAppParseCSV = %#v", got)
|
||||
}
|
||||
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
|
||||
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
|
||||
}
|
||||
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
|
||||
t.Fatalf("eduAppParseIntCSV invalid = nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
|
||||
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "get_ai_message_summary_list" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["classId"] != int64(100) {
|
||||
t.Fatalf("classId = %v, want 100", input["classId"])
|
||||
}
|
||||
if input["cid"] != "cidxxx" {
|
||||
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
|
||||
}
|
||||
if input["targetRole"] != "guardian" {
|
||||
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
|
||||
}
|
||||
if input["status"] != int64(1) {
|
||||
t.Fatalf("status = %v, want 1", input["status"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report get dispatches get_report", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "get_report" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
ids, ok := input["schoolReportIdList"].([]int64)
|
||||
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
|
||||
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "query_student_circle_posts" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["classId"] != "12345" {
|
||||
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
|
||||
}
|
||||
if input["studentId"] != "stu1" {
|
||||
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
|
||||
}
|
||||
if input["targetRole"] != "guardian" {
|
||||
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card end dispatches end_card", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "end_card" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["cardId"] != int64(999) {
|
||||
t.Fatalf("cardId = %v, want 999", input["cardId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card update dispatches update_card", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "update_card" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["cardId"] != int64(77) {
|
||||
t.Fatalf("cardId = %v, want 77", input["cardId"])
|
||||
}
|
||||
if input["identifier"] != "org1-staff1-uuid" {
|
||||
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
|
||||
}
|
||||
if input["title"] != "新标题" {
|
||||
t.Fatalf("title = %v, want %q", input["title"], "新标题")
|
||||
}
|
||||
if input["shouldSendUpdateMsg"] != true {
|
||||
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduAppConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduAppCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"notice delete",
|
||||
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
|
||||
"delete_notice",
|
||||
map[string]any{"noticeId": int64(12345)},
|
||||
},
|
||||
{
|
||||
"notice delete with user-name",
|
||||
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
|
||||
"delete_notice",
|
||||
map[string]any{"noticeId": int64(12345), "userName": "张三"},
|
||||
},
|
||||
{
|
||||
"homework delete",
|
||||
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
|
||||
"delete_homework",
|
||||
map[string]any{"homeworkId": int64(12345)},
|
||||
},
|
||||
{
|
||||
"diploma delete",
|
||||
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
|
||||
"delete_diploma",
|
||||
map[string]any{"diplomaId": int64(12345)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduAppConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduAppConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-app" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,672 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestEduContactRoot() *cobra.Command {
|
||||
return newEduContactCommand()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 命令注册测试 — 验证所有子命令路径是否正确注册
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
|
||||
paths := [][]string{
|
||||
// school
|
||||
{"school", "roles"},
|
||||
{"school", "structure"},
|
||||
{"school", "periods"},
|
||||
{"school", "type"},
|
||||
{"school", "stats"},
|
||||
{"school", "class-list"},
|
||||
// class — 原有
|
||||
{"class", "detail"},
|
||||
{"class", "students"},
|
||||
{"class", "teachers"},
|
||||
{"class", "same-name"},
|
||||
{"class", "user-role"},
|
||||
{"class", "search-by-name"},
|
||||
{"class", "headmaster"},
|
||||
// class — 新增
|
||||
{"class", "search-by-teacher"},
|
||||
{"class", "add-student"},
|
||||
{"class", "add-teachers"},
|
||||
{"class", "add-unofficial-student"},
|
||||
{"class", "delete-students"},
|
||||
{"class", "delete-teacher"},
|
||||
{"class", "modify-student-info"},
|
||||
{"class", "move-student"},
|
||||
{"class", "update-info"},
|
||||
{"class", "update-student"},
|
||||
{"class", "update-student-mobile"},
|
||||
{"class", "update-student-number"},
|
||||
// family
|
||||
{"family", "children"},
|
||||
{"family", "parents"},
|
||||
// teacher
|
||||
{"teacher", "classes"},
|
||||
{"teacher", "update-course"},
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if _, _, err := root.Find(path); err != nil {
|
||||
t.Errorf("command path %v not found: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 参数校验测试 — 验证必填参数缺失时返回错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func executeCommand(root *cobra.Command, args ...string) error {
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "search-by-teacher")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --is-adviser, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --origin-class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --mobile, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-staff-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-number, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --group-name without --conversation-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing mobile (student or parent), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --mother JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-class-infos, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
|
||||
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// withEduContactCaller installs a dry-run capture caller so happy-path command
|
||||
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
|
||||
// requiring a live MCP transport.
|
||||
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
|
||||
withEduContactCaller(t)
|
||||
cases := [][]string{
|
||||
// school
|
||||
{"school", "roles"},
|
||||
{"school", "structure"},
|
||||
{"school", "periods"},
|
||||
{"school", "type"},
|
||||
{"school", "stats"},
|
||||
{"school", "stats", "--statistics-type", "1"},
|
||||
{"school", "class-list"},
|
||||
// class — 读操作
|
||||
{"class", "detail", "--dept-id", "123"},
|
||||
{"class", "students", "--dept-id", "123"},
|
||||
{"class", "teachers", "--dept-id", "123"},
|
||||
{"class", "same-name", "--dept-id", "123"},
|
||||
{"class", "user-role", "--dept-id", "123"},
|
||||
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
|
||||
{"class", "headmaster", "--class-name", "一年级1班"},
|
||||
{"class", "search-by-teacher", "--name", "张老师"},
|
||||
// class — update-student 各分支
|
||||
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
|
||||
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
|
||||
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
|
||||
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
|
||||
// class — add-student 各手机号来源分支
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--student-mobile", "13800138000", "--student-user-id", "u1",
|
||||
"--student-number", "S1", "--virtual-account-id", "v1"},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--mother", `{"mobile":"13800138000","relation":"M"}`},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--father", `{"mobile":"13900139000","relation":"F"}`},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
|
||||
// class — modify-student-info 两个分支
|
||||
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
|
||||
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
|
||||
"--patriarch-user-id", "p1", "--relation", "父亲"},
|
||||
// class — delete-teacher
|
||||
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
|
||||
// class — update-info 三个可选分支
|
||||
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
|
||||
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
|
||||
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
|
||||
// class — update-student-number
|
||||
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
|
||||
// class — add-unofficial-student
|
||||
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
|
||||
// class — delete-students
|
||||
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
|
||||
// class — update-student-mobile
|
||||
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
|
||||
// class — move-student
|
||||
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
|
||||
// class — add-teachers 默认/班主任
|
||||
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
|
||||
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
|
||||
// family
|
||||
{"family", "children"},
|
||||
{"family", "parents"},
|
||||
// teacher
|
||||
{"teacher", "classes"},
|
||||
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
|
||||
}
|
||||
for _, args := range cases {
|
||||
root := newTestEduContactRoot()
|
||||
if err := executeCommand(root, args...); err != nil {
|
||||
t.Errorf("happy path %v returned error: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
|
||||
// the existing error tests, ensuring 100% changed-code coverage.
|
||||
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
// eduRequiredIntFlag:空值 + 非整数
|
||||
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
|
||||
{"detail-missing-dept", []string{"class", "detail"}},
|
||||
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
|
||||
{"students-missing-dept", []string{"class", "students"}},
|
||||
{"teachers-missing-dept", []string{"class", "teachers"}},
|
||||
{"samename-missing-dept", []string{"class", "same-name"}},
|
||||
{"userrole-missing-dept", []string{"class", "user-role"}},
|
||||
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
|
||||
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
|
||||
{"headmaster-missing-classname", []string{"class", "headmaster"}},
|
||||
// update-student
|
||||
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
|
||||
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
|
||||
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
|
||||
// add-student
|
||||
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
|
||||
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
|
||||
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
|
||||
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
|
||||
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
|
||||
// modify-student-info
|
||||
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
|
||||
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
|
||||
// delete-teacher
|
||||
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
|
||||
// update-info
|
||||
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
|
||||
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
|
||||
// update-student-number
|
||||
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
|
||||
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
|
||||
// add-unofficial-student
|
||||
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
|
||||
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
|
||||
// delete-students
|
||||
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
|
||||
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
|
||||
// update-student-mobile
|
||||
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
|
||||
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
|
||||
// move-student
|
||||
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
|
||||
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
|
||||
// add-teachers
|
||||
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
|
||||
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
root := newTestEduContactRoot()
|
||||
if err := executeCommand(root, tc.args...); err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
|
||||
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
|
||||
// the dispatched productID, tool name, and args.
|
||||
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
|
||||
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "get_class_detail" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "query_class_by_guardian_name" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["queryType"] != "student" {
|
||||
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
|
||||
}
|
||||
if input["name"] != "张三" {
|
||||
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "statistics_school" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["statisticsType"] != int64(2) {
|
||||
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "batch_add_class_teacher" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["deptId"] != int64(789) {
|
||||
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
|
||||
}
|
||||
teacherUserIds, ok := input["teacherUserIds"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
|
||||
}
|
||||
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
|
||||
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
|
||||
}
|
||||
if input["isAdviser"] != int64(1) {
|
||||
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "move_student" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
studentUserIds, ok := input["studentUserIds"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
|
||||
}
|
||||
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
|
||||
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
|
||||
}
|
||||
if input["originClassId"] != int64(100) {
|
||||
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
|
||||
}
|
||||
if input["targetClassId"] != int64(200) {
|
||||
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduContactConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduContactCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"class delete-teacher",
|
||||
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
|
||||
"delete_teacher",
|
||||
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
|
||||
},
|
||||
{
|
||||
"class delete-students",
|
||||
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
|
||||
"delete_students",
|
||||
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduContactConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduContactConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws edu-familygroup — 家庭群管理
|
||||
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
|
||||
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newEduFamilyGroupCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "edu-familygroup",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
|
||||
UseWhen: []string{
|
||||
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
Use: "edu-familygroup",
|
||||
Short: "家庭群",
|
||||
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
|
||||
Hidden: true,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// group 子命令组 — 家庭群读操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}
|
||||
|
||||
groupCheckExistsCmd := &cobra.Command{
|
||||
Use: "check-exists",
|
||||
Short: "检查家庭群是否存在",
|
||||
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
|
||||
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
|
||||
面向家长(GUARDIAN)角色。`,
|
||||
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
|
||||
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
|
||||
"input": map[string]any{"uid": uid, "groupName": groupName},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "check_family_group_exists",
|
||||
CanonicalPath: "edu-familygroup.check_family_group_exists",
|
||||
CLIPath: "edu-familygroup group check-exists",
|
||||
PrimaryCLIPath: "edu-familygroup group check-exists",
|
||||
},
|
||||
Description: "检查家庭群是否存在",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查家庭群是否存在",
|
||||
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
|
||||
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
groupListChildrenCmd := &cobra.Command{
|
||||
Use: "list-children",
|
||||
Short: "查询家长绑定的孩子列表",
|
||||
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
|
||||
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
|
||||
面向家长(GUARDIAN)角色。`,
|
||||
Example: ` dws edu-familygroup group list-children --uid 12345
|
||||
dws edu-familygroup group list-children --uid 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
|
||||
"input": map[string]any{"uid": uid},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "listBoundChildren",
|
||||
CanonicalPath: "edu-familygroup.listBoundChildren",
|
||||
CLIPath: "edu-familygroup group list-children",
|
||||
PrimaryCLIPath: "edu-familygroup group list-children",
|
||||
},
|
||||
Description: "查询家长绑定的孩子列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询家长绑定的孩子列表",
|
||||
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
|
||||
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup group list-children --uid 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// manage 子命令组 — 家庭群写操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}
|
||||
|
||||
manageCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建家庭群",
|
||||
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
|
||||
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
|
||||
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
|
||||
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
|
||||
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
|
||||
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
|
||||
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childrenRaw, _ := cmd.Flags().GetString("children")
|
||||
if strings.TrimSpace(childrenRaw) == "" {
|
||||
return fmt.Errorf("--children 为必填参数")
|
||||
}
|
||||
var children []any
|
||||
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
|
||||
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
|
||||
}
|
||||
if err := eduFamilyGroupValidateChildren(children); err != nil {
|
||||
return err
|
||||
}
|
||||
input := map[string]any{"uid": uid, "children": children}
|
||||
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
|
||||
var addGroup map[string]any
|
||||
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
|
||||
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
|
||||
}
|
||||
if addGroup == nil {
|
||||
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
|
||||
}
|
||||
input["addGroup"] = addGroup
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
|
||||
input["source"] = v
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
|
||||
"input": input,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "create_family_group",
|
||||
CanonicalPath: "edu-familygroup.create_family_group",
|
||||
CLIPath: "edu-familygroup manage create",
|
||||
PrimaryCLIPath: "edu-familygroup manage create",
|
||||
},
|
||||
Description: "创建家庭群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建家庭群并同步创建家庭组织",
|
||||
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
|
||||
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "children", Property: "input.children", Required: boolPtr(true)},
|
||||
{Name: "add-group", Property: "input.addGroup"},
|
||||
{Name: "source", Property: "input.source"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageInviteParentCmd := &cobra.Command{
|
||||
Use: "invite-parent",
|
||||
Short: "短信邀请家长加入家庭群",
|
||||
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
|
||||
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
|
||||
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
|
||||
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "invite_parent_to_familygroup",
|
||||
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
|
||||
CLIPath: "edu-familygroup manage invite-parent",
|
||||
PrimaryCLIPath: "edu-familygroup manage invite-parent",
|
||||
},
|
||||
Description: "短信邀请家长加入家庭群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "通过短信邀请家长加入家庭群",
|
||||
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
|
||||
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageAddChildCmd := &cobra.Command{
|
||||
Use: "add-child",
|
||||
Short: "为家庭群添加孩子",
|
||||
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
|
||||
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
|
||||
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
|
||||
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
|
||||
mobile 与 students 至少传一个。
|
||||
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
|
||||
仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
|
||||
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mobile, _ := cmd.Flags().GetString("mobile")
|
||||
mobile = strings.TrimSpace(mobile)
|
||||
studentsRaw, _ := cmd.Flags().GetString("students")
|
||||
studentsRaw = strings.TrimSpace(studentsRaw)
|
||||
if mobile == "" && studentsRaw == "" {
|
||||
return fmt.Errorf("--mobile 与 --students 至少传一个")
|
||||
}
|
||||
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
|
||||
if mobile != "" {
|
||||
input["mobile"] = mobile
|
||||
}
|
||||
if studentsRaw != "" {
|
||||
var students []any
|
||||
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
|
||||
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
|
||||
}
|
||||
if err := eduFamilyGroupValidateStudents(students); err != nil {
|
||||
return err
|
||||
}
|
||||
input["students"] = students
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
|
||||
"input": input,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "add_child_to_family_group",
|
||||
CanonicalPath: "edu-familygroup.add_child_to_family_group",
|
||||
CLIPath: "edu-familygroup manage add-child",
|
||||
PrimaryCLIPath: "edu-familygroup manage add-child",
|
||||
},
|
||||
Description: "为家庭群添加孩子",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为已有家庭群添加孩子",
|
||||
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
|
||||
AvoidWhen: []string{"创建全新家庭群用 create"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "input.name", Required: boolPtr(true)},
|
||||
{Name: "mobile", Property: "input.mobile"},
|
||||
{Name: "students", Property: "input.students"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageToggleAppCmd := &cobra.Command{
|
||||
Use: "toggle-app",
|
||||
Short: "开启或关闭学生应用权限",
|
||||
Long: `为指定学生号开启或关闭应用权限。
|
||||
支持的应用类型(--app-type):
|
||||
- XIAOTIANDI:小天地(学生圈)
|
||||
- LEARNING_VIDEO:学习视频
|
||||
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
|
||||
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
|
||||
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
|
||||
}
|
||||
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var open bool
|
||||
switch strings.ToLower(openStr) {
|
||||
case "true":
|
||||
open = true
|
||||
case "false":
|
||||
open = false
|
||||
default:
|
||||
return fmt.Errorf("--open 须为 true 或 false")
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
|
||||
"input": map[string]any{
|
||||
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
|
||||
"appType": appType, "open": open,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "toggle_student_app",
|
||||
CanonicalPath: "edu-familygroup.toggle_student_app",
|
||||
CLIPath: "edu-familygroup manage toggle-app",
|
||||
PrimaryCLIPath: "edu-familygroup manage toggle-app",
|
||||
},
|
||||
Description: "开启或关闭学生应用权限",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "开启或关闭学生应用权限",
|
||||
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
|
||||
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
|
||||
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
|
||||
{Name: "open", Property: "input.open", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// flags + 构建命令树
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// group(读操作)flags
|
||||
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
|
||||
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
|
||||
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
|
||||
|
||||
// manage(写操作)flags
|
||||
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
|
||||
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
|
||||
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
|
||||
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
|
||||
|
||||
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
|
||||
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
|
||||
|
||||
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
|
||||
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
|
||||
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
|
||||
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
|
||||
|
||||
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
|
||||
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
|
||||
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
|
||||
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
|
||||
|
||||
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
|
||||
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
|
||||
|
||||
root.AddCommand(groupCmd, manageCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// eduFamilyGroupValidateChildren validates the --children payload: the array
|
||||
// must be non-empty, each child must carry a non-empty name and a non-empty
|
||||
// students array, and each student must carry corpId + staffId.
|
||||
func eduFamilyGroupValidateChildren(children []any) error {
|
||||
if len(children) == 0 {
|
||||
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
|
||||
}
|
||||
for i, c := range children {
|
||||
child, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
|
||||
}
|
||||
name, _ := child["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("--children[%d].name 为必填字段", i)
|
||||
}
|
||||
students, ok := child["students"].([]any)
|
||||
if !ok || len(students) == 0 {
|
||||
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
|
||||
}
|
||||
for j, s := range students {
|
||||
student, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
|
||||
}
|
||||
corpID, _ := student["corpId"].(string)
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
|
||||
}
|
||||
staffID, _ := student["staffId"].(string)
|
||||
if strings.TrimSpace(staffID) == "" {
|
||||
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupValidateStudents validates the --students payload: the array
|
||||
// must be non-empty and each element must carry a numeric schoolOrgId and a
|
||||
// non-empty studentStaffId.
|
||||
func eduFamilyGroupValidateStudents(students []any) error {
|
||||
if len(students) == 0 {
|
||||
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
|
||||
}
|
||||
for i, s := range students {
|
||||
student, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
|
||||
}
|
||||
switch student["schoolOrgId"].(type) {
|
||||
case float64, int, int64, json.Number:
|
||||
default:
|
||||
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
|
||||
}
|
||||
staffID, _ := student["studentStaffId"].(string)
|
||||
if strings.TrimSpace(staffID) == "" {
|
||||
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
|
||||
// error if the flag is empty or not a valid integer.
|
||||
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
|
||||
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
|
||||
{"group", "list-children", "--uid", "1"},
|
||||
{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
|
||||
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
|
||||
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
|
||||
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
|
||||
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
|
||||
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
|
||||
"--app-type", "XIAOTIANDI", "--open", "true"},
|
||||
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
|
||||
"--app-type", "LEARNING_VIDEO", "--open", "false"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduFamilyGroupCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
|
||||
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
|
||||
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
|
||||
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
|
||||
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
|
||||
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
|
||||
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
|
||||
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
|
||||
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", "{"}, "add-group"},
|
||||
{"create add-group null", []string{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", "null"}, "null"},
|
||||
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
|
||||
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
|
||||
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
|
||||
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
|
||||
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
|
||||
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
|
||||
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
|
||||
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
|
||||
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
|
||||
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
|
||||
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
|
||||
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
|
||||
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
|
||||
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
|
||||
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
|
||||
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withEduFamilyGroupCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
|
||||
valid := []any{map[string]any{
|
||||
"name": "小明",
|
||||
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
|
||||
}}
|
||||
if err := eduFamilyGroupValidateChildren(valid); err != nil {
|
||||
t.Fatalf("valid children error = %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
children []any
|
||||
want string
|
||||
}{
|
||||
{"empty", []any{}, "不能为空数组"},
|
||||
{"non-object", []any{1}, "须为 JSON 对象"},
|
||||
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
|
||||
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
|
||||
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
|
||||
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
|
||||
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
|
||||
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
|
||||
if err := eduFamilyGroupValidateStudents(valid); err != nil {
|
||||
t.Fatalf("valid students error = %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
students []any
|
||||
want string
|
||||
}{
|
||||
{"empty", []any{}, "不能为空数组"},
|
||||
{"non-object", []any{1}, "须为 JSON 对象"},
|
||||
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
|
||||
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
|
||||
t.Run("check-exists", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "check_family_group_exists" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(123) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
|
||||
}
|
||||
if input["groupName"] != "测试家庭" {
|
||||
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-children", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "listBoundChildren" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(456) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "create_family_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(789) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
|
||||
}
|
||||
if input["children"] == nil {
|
||||
t.Fatalf("input[\"children\"] is nil, want non-nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invite-parent", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "invite_parent_to_familygroup" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["orgId"] != int64(1) {
|
||||
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
|
||||
}
|
||||
if input["uid"] != int64(2) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
|
||||
}
|
||||
if input["mobile"] != "13800138000" {
|
||||
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle-app", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
|
||||
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "toggle_student_app" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["appType"] != "XIAOTIANDI" {
|
||||
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
|
||||
}
|
||||
if input["open"] != true {
|
||||
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add-child", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
|
||||
"--name", "小明", "--mobile", "13900139000",
|
||||
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "add_child_to_family_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
|
||||
}
|
||||
want := map[string]any{
|
||||
"input": map[string]any{
|
||||
"orgId": int64(12345),
|
||||
"uid": int64(67890),
|
||||
"name": "小明",
|
||||
"mobile": "13900139000",
|
||||
"students": []any{
|
||||
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws edu-group — 家校群管理
|
||||
// 共 14 个工具,按 student-group / class-group / batch 分组
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newEduGroupCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "edu-group",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
|
||||
UseWhen: []string{
|
||||
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
Use: "edu-group",
|
||||
Short: "家校群",
|
||||
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
|
||||
Hidden: true,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// student-group 子命令组 — 师生群管理
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}
|
||||
|
||||
studentGroupInfoCmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "查询班级师生群信息",
|
||||
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group info --dept-id 12345
|
||||
dws edu-group student-group info --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_group_info",
|
||||
CanonicalPath: "edu-group.get_class_group_info",
|
||||
CLIPath: "edu-group student-group info",
|
||||
PrimaryCLIPath: "edu-group student-group info",
|
||||
},
|
||||
Description: "查询班级师生群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询班级师生群信息",
|
||||
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
|
||||
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group info --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupExistsCmd := &cobra.Command{
|
||||
Use: "exists",
|
||||
Short: "检查组织是否已创建师生群",
|
||||
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group student-group exists --dept-id 12345
|
||||
dws edu-group student-group exists --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "check_class_group_exists",
|
||||
CanonicalPath: "edu-group.check_class_group_exists",
|
||||
CLIPath: "edu-group student-group exists",
|
||||
PrimaryCLIPath: "edu-group student-group exists",
|
||||
},
|
||||
Description: "检查组织是否已创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查组织是否已创建师生群",
|
||||
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
|
||||
AvoidWhen: []string{"查询师生群成员用 student-group members"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group exists --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupMembersCmd := &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "查询师生群成员列表",
|
||||
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group members --dept-id 12345
|
||||
dws edu-group student-group members --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_group_members",
|
||||
CanonicalPath: "edu-group.get_group_members",
|
||||
CLIPath: "edu-group student-group members",
|
||||
PrimaryCLIPath: "edu-group student-group members",
|
||||
},
|
||||
Description: "查询师生群成员列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询师生群成员列表",
|
||||
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
|
||||
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group members --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupIsInCmd := &cobra.Command{
|
||||
Use: "is-in",
|
||||
Short: "判断用户是否在师生群中",
|
||||
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group is-in --dept-id 12345
|
||||
dws edu-group student-group is-in --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "is_in_class_group",
|
||||
CanonicalPath: "edu-group.is_in_class_group",
|
||||
CLIPath: "edu-group student-group is-in",
|
||||
PrimaryCLIPath: "edu-group student-group is-in",
|
||||
},
|
||||
Description: "判断用户是否在师生群中",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "判断用户是否在师生群中",
|
||||
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
|
||||
AvoidWhen: []string{"查询群成员列表用 student-group members"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group is-in --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupConversationCmd := &cobra.Command{
|
||||
Use: "conversation",
|
||||
Short: "查询班级群会话详情",
|
||||
Long: `查询指定班级师生群的会话详情。
|
||||
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group conversation --dept-id 12345
|
||||
dws edu-group student-group conversation --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_group_conversation_info",
|
||||
CanonicalPath: "edu-group.get_group_conversation_info",
|
||||
CLIPath: "edu-group student-group conversation",
|
||||
PrimaryCLIPath: "edu-group student-group conversation",
|
||||
},
|
||||
Description: "查询班级群会话详情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询班级师生群会话详情",
|
||||
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
|
||||
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group conversation --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建班级师生群",
|
||||
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
|
||||
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
|
||||
Example: ` dws edu-group student-group create --dept-id 12345
|
||||
dws edu-group student-group create --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "create_class_group",
|
||||
CanonicalPath: "edu-group.create_class_group",
|
||||
CLIPath: "edu-group student-group create",
|
||||
PrimaryCLIPath: "edu-group student-group create",
|
||||
},
|
||||
Description: "创建班级师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定班级创建师生群",
|
||||
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
|
||||
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group create --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupDisbandCmd := &cobra.Command{
|
||||
Use: "disband",
|
||||
Short: "解散班级师生群",
|
||||
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
|
||||
Example: ` dws edu-group student-group disband --dept-id 12345
|
||||
dws edu-group student-group disband --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "disband_class_group",
|
||||
CanonicalPath: "edu-group.disband_class_group",
|
||||
CLIPath: "edu-group student-group disband",
|
||||
PrimaryCLIPath: "edu-group student-group disband",
|
||||
},
|
||||
Description: "解散班级师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "解散班级师生群并删除关联关系",
|
||||
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
|
||||
AvoidWhen: []string{"查询师生群信息用 student-group info"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group disband --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// class-group 子命令组 — 班级群(家校群)会话管理
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}
|
||||
|
||||
classGroupConversationIDCmd := &cobra.Command{
|
||||
Use: "conversation-id",
|
||||
Short: "获取班级群会话ID",
|
||||
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group conversation-id --dept-id 12345
|
||||
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_conversation_id",
|
||||
CanonicalPath: "edu-group.get_class_conversation_id",
|
||||
CLIPath: "edu-group class-group conversation-id",
|
||||
PrimaryCLIPath: "edu-group class-group conversation-id",
|
||||
},
|
||||
Description: "获取班级群会话ID",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取班级群会话ID",
|
||||
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
|
||||
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupConversationCmd := &cobra.Command{
|
||||
Use: "conversation",
|
||||
Short: "获取班级群完整会话信息",
|
||||
Long: `获取指定班级的班级群完整会话信息。
|
||||
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group conversation --dept-id 12345
|
||||
dws edu-group class-group conversation --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_conversation",
|
||||
CanonicalPath: "edu-group.get_class_conversation",
|
||||
CLIPath: "edu-group class-group conversation",
|
||||
PrimaryCLIPath: "edu-group class-group conversation",
|
||||
},
|
||||
Description: "获取班级群完整会话信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取班级群完整会话信息",
|
||||
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
|
||||
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group conversation --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupExistsCmd := &cobra.Command{
|
||||
Use: "exists",
|
||||
Short: "检查班级群是否存在",
|
||||
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group exists --dept-id 12345
|
||||
dws edu-group class-group exists --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "check_class_conversation_exists",
|
||||
CanonicalPath: "edu-group.check_class_conversation_exists",
|
||||
CLIPath: "edu-group class-group exists",
|
||||
PrimaryCLIPath: "edu-group class-group exists",
|
||||
},
|
||||
Description: "检查班级群是否存在",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查班级群是否存在",
|
||||
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
|
||||
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group exists --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupListByConversationIDsCmd := &cobra.Command{
|
||||
Use: "list-by-cids",
|
||||
Short: "根据会话ID列表批量查询群信息",
|
||||
Long: `根据群会话ID列表批量查询群的详细信息。
|
||||
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
|
||||
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("conversation-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--conversation-ids 为必填参数")
|
||||
}
|
||||
conversationIDs := eduGroupParseCSV(raw)
|
||||
if len(conversationIDs) == 0 {
|
||||
return fmt.Errorf("--conversation-ids 不能为空")
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
|
||||
"input": map[string]any{"conversationIds": conversationIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "list_groups_by_conversation_ids",
|
||||
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
|
||||
CLIPath: "edu-group class-group list-by-cids",
|
||||
PrimaryCLIPath: "edu-group class-group list-by-cids",
|
||||
},
|
||||
Description: "根据会话ID列表批量查询群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "根据会话ID列表批量查询群信息",
|
||||
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
|
||||
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// batch 子命令组 — 批量操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}
|
||||
|
||||
batchCheckClassGroupCmd := &cobra.Command{
|
||||
Use: "check-student-group",
|
||||
Short: "批量检查班级是否已创建师生群",
|
||||
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
|
||||
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("class-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--class-ids 为必填参数")
|
||||
}
|
||||
classIDs, err := eduGroupParseIntCSV(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
|
||||
"input": map[string]any{"classIds": classIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_check_class_group",
|
||||
CanonicalPath: "edu-group.batch_check_class_group",
|
||||
CLIPath: "edu-group batch check-student-group",
|
||||
PrimaryCLIPath: "edu-group batch check-student-group",
|
||||
},
|
||||
Description: "批量检查班级是否已创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量检查班级是否已创建师生群",
|
||||
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
|
||||
AvoidWhen: []string{"单个班级检查用 student-group exists"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
batchGetClassConversationsCmd := &cobra.Command{
|
||||
Use: "get-class-groups",
|
||||
Short: "批量获取班级群信息",
|
||||
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
|
||||
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("class-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--class-ids 为必填参数")
|
||||
}
|
||||
classIDs := eduGroupParseCSV(raw)
|
||||
if len(classIDs) == 0 {
|
||||
return fmt.Errorf("--class-ids 不能为空")
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
|
||||
"input": map[string]any{"classIds": classIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_get_class_conversations",
|
||||
CanonicalPath: "edu-group.batch_get_class_conversations",
|
||||
CLIPath: "edu-group batch get-class-groups",
|
||||
PrimaryCLIPath: "edu-group batch get-class-groups",
|
||||
},
|
||||
Description: "批量获取班级群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量获取班级群会话信息",
|
||||
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
|
||||
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
batchCreateClassGroupCmd := &cobra.Command{
|
||||
Use: "create-student-groups",
|
||||
Short: "批量创建师生群",
|
||||
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
|
||||
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch create-student-groups
|
||||
dws edu-group batch create-student-groups -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
|
||||
"input": map[string]any{},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_create_class_group",
|
||||
CanonicalPath: "edu-group.batch_create_class_group",
|
||||
CLIPath: "edu-group batch create-student-groups",
|
||||
PrimaryCLIPath: "edu-group batch create-student-groups",
|
||||
},
|
||||
Description: "批量创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为组织下符合条件的班级批量创建师生群",
|
||||
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
|
||||
AvoidWhen: []string{"单个班级创建用 student-group create"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch create-student-groups --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// flags + 构建命令树
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// student-group flags
|
||||
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
|
||||
// class-group flags
|
||||
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
|
||||
|
||||
// batch flags
|
||||
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
|
||||
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
|
||||
|
||||
studentGroupCmd.AddCommand(
|
||||
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
|
||||
studentGroupIsInCmd, studentGroupConversationCmd,
|
||||
studentGroupCreateCmd, studentGroupDisbandCmd,
|
||||
)
|
||||
classGroupCmd.AddCommand(
|
||||
classGroupConversationIDCmd, classGroupConversationCmd,
|
||||
classGroupExistsCmd, classGroupListByConversationIDsCmd,
|
||||
)
|
||||
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
|
||||
|
||||
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
|
||||
// if the flag is empty or not a valid integer.
|
||||
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
|
||||
func eduGroupParseCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v != "" {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
|
||||
func eduGroupParseIntCSV(raw string) ([]int64, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// withEduGroupCaller installs a dry-run capture caller so happy-path command
|
||||
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
|
||||
// requiring a live MCP transport.
|
||||
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"student-group", "info", "--dept-id", "123"},
|
||||
{"student-group", "exists", "--dept-id", "123"},
|
||||
{"student-group", "members", "--dept-id", "123"},
|
||||
{"student-group", "is-in", "--dept-id", "123"},
|
||||
{"student-group", "conversation", "--dept-id", "123"},
|
||||
{"student-group", "create", "--dept-id", "123"},
|
||||
{"student-group", "disband", "--dept-id", "123"},
|
||||
{"class-group", "conversation-id", "--dept-id", "123"},
|
||||
{"class-group", "conversation", "--dept-id", "123"},
|
||||
{"class-group", "exists", "--dept-id", "123"},
|
||||
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
|
||||
{"batch", "check-student-group", "--class-ids", "1,2"},
|
||||
{"batch", "get-class-groups", "--class-ids", "1,2"},
|
||||
{"batch", "create-student-groups"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
|
||||
// Each dept-id command must reject an absent --dept-id, covering its own
|
||||
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
|
||||
deptIDCommands := [][]string{
|
||||
{"student-group", "info"},
|
||||
{"student-group", "exists"},
|
||||
{"student-group", "members"},
|
||||
{"student-group", "is-in"},
|
||||
{"student-group", "conversation"},
|
||||
{"student-group", "create"},
|
||||
{"student-group", "disband"},
|
||||
{"class-group", "conversation-id"},
|
||||
{"class-group", "conversation"},
|
||||
{"class-group", "exists"},
|
||||
}
|
||||
for _, args := range deptIDCommands {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
|
||||
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
|
||||
t.Run("non-integer dept-id", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
|
||||
t.Fatalf("non-integer dept-id error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
|
||||
t.Fatalf("missing conversation-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-by-cids only separators", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
|
||||
t.Fatalf("empty conversation-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check missing class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("missing class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
|
||||
t.Fatalf("invalid integer class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch get missing class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "get-class-groups"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("missing class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch get only separators", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("empty class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
|
||||
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("eduGroupParseCSV = %#v", got)
|
||||
}
|
||||
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
|
||||
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
|
||||
}
|
||||
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
|
||||
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
|
||||
// exercise the full dispatch path through deps.Caller.CallTool.
|
||||
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
|
||||
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "get_class_group_info" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "create_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(456) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "")
|
||||
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "disband_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(789) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "list_groups_by_conversation_ids" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
cids, ok := input["conversationIds"].([]string)
|
||||
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
|
||||
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "batch_check_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
classIDs, ok := input["classIds"].([]int64)
|
||||
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
|
||||
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduGroupConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduGroupCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"student-group disband",
|
||||
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
|
||||
"disband_class_group",
|
||||
map[string]any{"deptId": int64(12345)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduGroupConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduGroupConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-group" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// 家校/高校系列(家校通讯录、家校群、家校应用、家庭群、高校通讯录)是开源库
|
||||
// 显式维护的公开命令,不依赖生成式产品注册表(register_products.go)。
|
||||
// 其 MCP server 端点由运行时宿主按 serverID 解析。
|
||||
func init() {
|
||||
eduCommands := []struct {
|
||||
name string
|
||||
buildFn func() *cobra.Command
|
||||
}{
|
||||
{"edu-contact", newEduContactCommand},
|
||||
{"edu-group", newEduGroupCommand},
|
||||
{"edu-app", newEduAppCommand},
|
||||
{"edu-familygroup", newEduFamilyGroupCommand},
|
||||
{"college-contact", newCollegeContactCommand},
|
||||
}
|
||||
for _, c := range eduCommands {
|
||||
c := c
|
||||
RegisterPublic(func() Handler {
|
||||
return wukongHandler{name: c.name, buildFn: c.buildFn}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 156 条叶子命令(dry-run 模式),
|
||||
// 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws <cmd> --dry-run。
|
||||
func TestRunAllEduCommands(t *testing.T) {
|
||||
type cmdCase struct {
|
||||
product string
|
||||
args []string
|
||||
}
|
||||
|
||||
allCmds := []cmdCase{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-contact: 29 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// school (6)
|
||||
{"edu-contact", []string{"school", "roles"}},
|
||||
{"edu-contact", []string{"school", "structure"}},
|
||||
{"edu-contact", []string{"school", "periods"}},
|
||||
{"edu-contact", []string{"school", "type"}},
|
||||
{"edu-contact", []string{"school", "stats", "--statistics-type", "1"}},
|
||||
{"edu-contact", []string{"school", "class-list"}},
|
||||
// class (19)
|
||||
{"edu-contact", []string{"class", "detail", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "students", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "same-name", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "user-role", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}},
|
||||
{"edu-contact", []string{"class", "headmaster", "--class-name", "一年级1班"}},
|
||||
{"edu-contact", []string{"class", "search-by-teacher", "--name", "张老师"}},
|
||||
{"edu-contact", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}},
|
||||
{"edu-contact", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--student-mobile", "13800138000"}},
|
||||
{"edu-contact", []string{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "小明"}},
|
||||
{"edu-contact", []string{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}},
|
||||
{"edu-contact", []string{"class", "update-info", "--class-id", "123", "--nick", "火箭班"}},
|
||||
{"edu-contact", []string{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S001"}},
|
||||
{"edu-contact", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}},
|
||||
{"edu-contact", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}},
|
||||
{"edu-contact", []string{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}},
|
||||
{"edu-contact", []string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}},
|
||||
{"edu-contact", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}},
|
||||
// family (2)
|
||||
{"edu-contact", []string{"family", "children"}},
|
||||
{"edu-contact", []string{"family", "parents"}},
|
||||
// teacher (2)
|
||||
{"edu-contact", []string{"teacher", "classes"}},
|
||||
{"edu-contact", []string{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"MATH","courseName":"数学"}]`}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-group: 14 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// student-group (7)
|
||||
{"edu-group", []string{"student-group", "info", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "exists", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "members", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "is-in", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "conversation", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "create", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "disband", "--dept-id", "12345"}},
|
||||
// class-group (4)
|
||||
{"edu-group", []string{"class-group", "conversation-id", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "conversation", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "exists", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}},
|
||||
// batch (3)
|
||||
{"edu-group", []string{"batch", "check-student-group", "--class-ids", "12345,67890"}},
|
||||
{"edu-group", []string{"batch", "get-class-groups", "--class-ids", "12345,67890"}},
|
||||
{"edu-group", []string{"batch", "create-student-groups"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-app: 42 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// message (1)
|
||||
{"edu-app", []string{"message", "summary-list", "--class-id", "1", "--cid", "c1", "--target-role", "guardian", "--status", "0"}},
|
||||
// task (3)
|
||||
{"edu-app", []string{"task", "publish-list"}},
|
||||
{"edu-app", []string{"task", "all-list", "--biz-id", "1"}},
|
||||
{"edu-app", []string{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`}},
|
||||
// report (5)
|
||||
{"edu-app", []string{"report", "get", "--ids", "1001,1002"}},
|
||||
{"edu-app", []string{"report", "by-teacher"}},
|
||||
{"edu-app", []string{"report", "by-class", "--report-id", "1001", "--class-id", "12345"}},
|
||||
{"edu-app", []string{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1"}},
|
||||
{"edu-app", []string{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}},
|
||||
// notice (7)
|
||||
{"edu-app", []string{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1"}},
|
||||
{"edu-app", []string{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假"}},
|
||||
{"edu-app", []string{"notice", "delete", "--notice-id", "12345"}},
|
||||
{"edu-app", []string{"notice", "list-by-teacher"}},
|
||||
{"edu-app", []string{"notice", "get", "--notice-id", "12345"}},
|
||||
{"edu-app", []string{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
|
||||
// circle (1)
|
||||
{"edu-app", []string{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}},
|
||||
// card (5)
|
||||
{"edu-app", []string{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题"}},
|
||||
{"edu-app", []string{"card", "end", "--card-id", "1"}},
|
||||
{"edu-app", []string{"card", "list", "--status", "UNFINISH"}},
|
||||
{"edu-app", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1"}},
|
||||
{"edu-app", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1"}},
|
||||
// diploma (9)
|
||||
{"edu-app", []string{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "三好学生", "--user-name", "张三"}},
|
||||
{"edu-app", []string{"diploma", "read", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "list-by-teacher"}},
|
||||
{"edu-app", []string{"diploma", "get", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "statistics", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "detail", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"diploma", "delete", "--diploma-id", "1"}},
|
||||
// homework (11)
|
||||
{"edu-app", []string{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习册第3页"}},
|
||||
{"edu-app", []string{"homework", "delete", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "submit", "--hw-content-detail-id", "1"}},
|
||||
{"edu-app", []string{"homework", "get", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "class-by-homework", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c1", "--user-name", "张老师"}},
|
||||
{"edu-app", []string{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c1", "--user-name", "张三"}},
|
||||
{"edu-app", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"homework", "list-by-teacher"}},
|
||||
{"edu-app", []string{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-familygroup: 6 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// group (2)
|
||||
{"edu-familygroup", []string{"group", "check-exists", "--uid", "12345", "--group-name", "小明一家"}},
|
||||
{"edu-familygroup", []string{"group", "list-children", "--uid", "12345"}},
|
||||
// manage (4)
|
||||
{"edu-familygroup", []string{"manage", "create", "--uid", "12345", "--children", `[{"name":"小明","students":[{"corpId":"corp1","staffId":"staff1"}]}]`}},
|
||||
{"edu-familygroup", []string{"manage", "invite-parent", "--org-id", "12345", "--uid", "67890", "--mobile", "13800138000"}},
|
||||
{"edu-familygroup", []string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", "--name", "小红", "--mobile", "13900139000"}},
|
||||
{"edu-familygroup", []string{"manage", "toggle-app", "--org-id", "12345", "--uid", "67890", "--child-staff-id", "staff1", "--app-type", "XIAOTIANDI", "--open", "true"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// college-contact: 65 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// dept (9)
|
||||
{"college-contact", []string{"dept", "get-standard-structure"}},
|
||||
{"college-contact", []string{"dept", "get-detail", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "get-chain", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "search", "--dept-id", "12345", "--keyword", "计算机"}},
|
||||
{"college-contact", []string{"dept", "create", "--super-id", "1", "--stru-dept-id", "2", "--name", "计算机学院", "--dept-type", "COLLEGE", "--create-dept-group", "true"}},
|
||||
{"college-contact", []string{"dept", "update", "--dept-id", "12345", "--dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"dept", "delete", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "batch-update-type", "--dept-ids", "1,2,3", "--target-dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"dept", "overview"}},
|
||||
// employee (11)
|
||||
{"college-contact", []string{"employee", "get-detail", "--staff-id", "staff001"}},
|
||||
{"college-contact", []string{"employee", "add", "--emp-type", "TEACHER", "--main-dept-id", "12345", "--exclusive-account", "false"}},
|
||||
{"college-contact", []string{"employee", "remove", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"employee", "change-type", "--staff-id", "staff001", "--emp-type", "STUDENT"}},
|
||||
{"college-contact", []string{"employee", "change-dept", "--staff-id", "staff001", "--target-dept-id", "67890"}},
|
||||
{"college-contact", []string{"employee", "send-active-sms", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-employees", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-unaccepted", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-unactive", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "upgrade-status"}},
|
||||
{"college-contact", []string{"employee", "start-upgrade"}},
|
||||
// alumni (20)
|
||||
{"college-contact", []string{"alumni", "get-dept-tree", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-info", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "list", "--alumni-dept-id", "12345", "--order-field", "NAME", "--ordering", "ASC"}},
|
||||
{"college-contact", []string{"alumni", "query", "--staff-id", "staff001"}},
|
||||
{"college-contact", []string{"alumni", "search", "--keyword", "张三"}},
|
||||
{"college-contact", []string{"alumni", "list-unaccepted", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "create-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届"}},
|
||||
{"college-contact", []string{"alumni", "update-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届计算机"}},
|
||||
{"college-contact", []string{"alumni", "delete-dept", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "update-managers", "--alumni-dept-id", "12345", "--admin-user-ids", "u1,u2"}},
|
||||
{"college-contact", []string{"alumni", "add-alumnus", "--dept-ids", "1,2", "--name", "张三", "--mobile", "13800138000"}},
|
||||
{"college-contact", []string{"alumni", "update-alumnus", "--dept-ids", "1,2", "--staff-id", "s1", "--name", "张三"}},
|
||||
{"college-contact", []string{"alumni", "remove-alumnus", "--staff-id", "s1", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "cancel-invite", "--alumni-dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"alumni", "create-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "disband-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-alumni-org-from-graduate"}},
|
||||
{"college-contact", []string{"alumni", "create-alumni-org", "--org-name", "计算机校友会"}},
|
||||
{"college-contact", []string{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}},
|
||||
// graduate (16)
|
||||
{"college-contact", []string{"graduate", "query-graduate-years"}},
|
||||
{"college-contact", []string{"graduate", "query-graduate-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-graduate-sub-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-page-graduate-users", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "get-task-result", "--request-no", "req001"}},
|
||||
{"college-contact", []string{"graduate", "get-alumni-org"}},
|
||||
{"college-contact", []string{"graduate", "query-restore-sub-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-dept-deleted-emps", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "search-graduate", "--keyword", "张三"}},
|
||||
{"college-contact", []string{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "all-graduate", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "batch-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "delete-and-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "batch-delete-pending", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "batch-update-pending", "--dept-id", "12345", "--staff-ids", "s1,s2", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}},
|
||||
// group (9)
|
||||
{"college-contact", []string{"group", "query-group-rule"}},
|
||||
{"college-contact", []string{"group", "get-group-rule-schedule"}},
|
||||
{"college-contact", []string{"group", "query-preview-data"}},
|
||||
{"college-contact", []string{"group", "create-group-rule", "--name", "自动分组", "--tag-code", "TAG1", "--dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"group", "delete-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "enable-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "disable-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "set-group-rule-schedule"}},
|
||||
{"college-contact", []string{"group", "execute-group-rule"}},
|
||||
}
|
||||
|
||||
// 按产品分组的命令构建器
|
||||
builders := map[string]func() *cobra.Command{
|
||||
"edu-contact": newEduContactCommand,
|
||||
"edu-group": newEduGroupCommand,
|
||||
"edu-app": newEduAppCommand,
|
||||
"edu-familygroup": newEduFamilyGroupCommand,
|
||||
"college-contact": newCollegeContactCommand,
|
||||
}
|
||||
|
||||
// 安装 dry-run caller
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
passed := 0
|
||||
failed := 0
|
||||
currentProduct := ""
|
||||
|
||||
for i, c := range allCmds {
|
||||
if c.product != currentProduct {
|
||||
currentProduct = c.product
|
||||
fmt.Printf("\n══════════════════════════════════════════════════════\n")
|
||||
fmt.Printf(" %s\n", strings.ToUpper(currentProduct))
|
||||
fmt.Printf("══════════════════════════════════════════════════════\n")
|
||||
}
|
||||
|
||||
buildFn, ok := builders[c.product]
|
||||
if !ok {
|
||||
t.Fatalf("unknown product: %s", c.product)
|
||||
}
|
||||
root := buildFn()
|
||||
root.SetArgs(c.args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
|
||||
// Reset caller capture
|
||||
caller.productID = ""
|
||||
caller.tool = ""
|
||||
caller.args = nil
|
||||
|
||||
err := root.Execute()
|
||||
cmdPath := fmt.Sprintf("dws %s %s", c.product, strings.Join(c.args, " "))
|
||||
|
||||
if err != nil {
|
||||
failed++
|
||||
fmt.Printf(" [%3d] ✗ FAIL: %s\n", i+1, cmdPath)
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
t.Errorf("command %d failed: %s → %v", i+1, cmdPath, err)
|
||||
} else {
|
||||
passed++
|
||||
fmt.Printf(" [%3d] ✓ %s\n", i+1, cmdPath)
|
||||
if caller.tool != "" {
|
||||
fmt.Printf(" → MCP: %s.%s\n", caller.productID, caller.tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n══════════════════════════════════════════════════════\n")
|
||||
fmt.Printf(" SUMMARY: %d passed, %d failed, %d total\n", passed, failed, passed+failed)
|
||||
fmt.Printf("══════════════════════════════════════════════════════\n")
|
||||
}
|
||||
@@ -15,18 +15,21 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
|
||||
使用流程:
|
||||
1. 先通过 media-upload 上传本地图片获取 resourceUrl
|
||||
2. 再通过 create-float-image 将图片以浮动方式放置到工作表上
|
||||
直接传 --file 可在一个命令中上传本地图片并创建浮动图片。
|
||||
已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。
|
||||
|
||||
--range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。
|
||||
--width / --height 为必填,单位像素,必须为正整数。
|
||||
--offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`,
|
||||
Example: ` # 先上传图片获取 resourceUrl
|
||||
Example: ` # 直接从本地文件创建
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node NODE_ID --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
# 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl)
|
||||
# 再通过 --src 创建浮动图片
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
|
||||
|
||||
@@ -34,7 +37,11 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \
|
||||
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, src, err := validateFloatImageCreateInput(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
width, err := cmd.Flags().GetInt("width")
|
||||
@@ -54,7 +61,6 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"src": mustGetFlag(cmd, "src"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
"width": width,
|
||||
"height": height,
|
||||
@@ -73,7 +79,11 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
}
|
||||
toolArgs["offsetY"] = oy
|
||||
}
|
||||
return callMCPTool("create_float_image", toolArgs)
|
||||
if filePath != "" {
|
||||
return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs)
|
||||
}
|
||||
toolArgs["src"] = src
|
||||
return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(createFloatImageCmd, LeafSpec{
|
||||
@@ -89,26 +99,30 @@ func newFloatImageCmds() []*cobra.Command {
|
||||
CLIPath: "sheet create-float-image",
|
||||
PrimaryCLIPath: "sheet create-float-image",
|
||||
},
|
||||
Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
|
||||
Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。",
|
||||
AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。",
|
||||
UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"},
|
||||
AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"},
|
||||
Examples: []string{"dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"},
|
||||
{Name: "src", Property: "src", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)")
|
||||
createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一")
|
||||
createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一")
|
||||
createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)")
|
||||
createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)")
|
||||
createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)")
|
||||
@@ -217,8 +231,8 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
Short: "更新浮动图片属性",
|
||||
Long: `更新钉钉表格指定工作表中浮动图片的属性。
|
||||
|
||||
可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。
|
||||
至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。
|
||||
可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。
|
||||
至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。
|
||||
floatImageId 可通过 list-float-images 获取。`,
|
||||
Example: ` # 移动浮动图片到新位置
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5
|
||||
@@ -226,10 +240,15 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400
|
||||
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
# 直接用本地文件替换图片
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:用 media-upload 返回的 resourceUrl 替换图片
|
||||
dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \
|
||||
--src "/core/api/resources/img/xxxx..."`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fileChanged := cmd.Flags().Changed("file")
|
||||
srcChanged := cmd.Flags().Changed("src")
|
||||
rangeChanged := cmd.Flags().Changed("range")
|
||||
widthChanged := cmd.Flags().Changed("width")
|
||||
@@ -237,8 +256,17 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
oxChanged := cmd.Flags().Changed("offset-x")
|
||||
oyChanged := cmd.Flags().Changed("offset-y")
|
||||
|
||||
if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
|
||||
return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个")
|
||||
if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged {
|
||||
return fmt.Errorf("%s", floatImageUpdateFieldsError)
|
||||
}
|
||||
filePath, src, err := validateFloatImageUpdateInput(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileChanged {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
@@ -247,7 +275,7 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
"floatImageId": mustGetFlag(cmd, "float-image-id"),
|
||||
}
|
||||
if srcChanged {
|
||||
toolArgs["src"], _ = cmd.Flags().GetString("src")
|
||||
toolArgs["src"] = src
|
||||
}
|
||||
if rangeChanged {
|
||||
toolArgs["range"], _ = cmd.Flags().GetString("range")
|
||||
@@ -280,7 +308,10 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
}
|
||||
toolArgs["offsetY"] = oy
|
||||
}
|
||||
return callMCPTool("update_float_image", toolArgs)
|
||||
if fileChanged {
|
||||
return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs)
|
||||
}
|
||||
return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{
|
||||
@@ -297,6 +328,7 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
PrimaryCLIPath: "sheet update-float-image",
|
||||
},
|
||||
Description: "更新浮动图片锚点、尺寸、偏移或资源路径。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
@@ -310,12 +342,15 @@ floatImageId 可通过 list-float-images 获取。`,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "file", Required: boolPtr(false)},
|
||||
{Name: "src", Property: "src", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)")
|
||||
updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用")
|
||||
updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl")
|
||||
updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法")
|
||||
updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
floatImageCreateInputError = "--file 与 --src 必须且只能提供一个"
|
||||
floatImageUpdateInputError = "--file 与 --src 不能同时提供"
|
||||
floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个"
|
||||
floatImagePlannedResource = "<resourceUrl-after-upload>"
|
||||
floatImageUploadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type floatImageLocalFile struct {
|
||||
file *os.File
|
||||
path string
|
||||
name string
|
||||
mimeType string
|
||||
size int64
|
||||
}
|
||||
|
||||
type floatImageUploadInfo struct {
|
||||
uploadURL string
|
||||
resourceID string
|
||||
resourceURL string
|
||||
}
|
||||
|
||||
func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
src, _ := cmd.Flags().GetString("src")
|
||||
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
|
||||
return "", "", fmt.Errorf("--file 不能为空")
|
||||
}
|
||||
hasFile := strings.TrimSpace(filePath) != ""
|
||||
hasSrc := strings.TrimSpace(src) != ""
|
||||
if hasFile == hasSrc {
|
||||
return "", "", fmt.Errorf("%s", floatImageCreateInputError)
|
||||
}
|
||||
return filePath, src, nil
|
||||
}
|
||||
|
||||
func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
src, _ := cmd.Flags().GetString("src")
|
||||
if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") {
|
||||
return "", "", fmt.Errorf("%s", floatImageUpdateInputError)
|
||||
}
|
||||
if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" {
|
||||
return "", "", fmt.Errorf("--file 不能为空")
|
||||
}
|
||||
return filePath, src, nil
|
||||
}
|
||||
|
||||
func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error {
|
||||
local, err := openFloatImageLocalFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer local.file.Close()
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return printFloatImageFileDryRun(toolName, toolArgs, local)
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size))
|
||||
}
|
||||
uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !jsonMode {
|
||||
deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL)
|
||||
deps.Out.PrintInfo("[2/3] 上传本地图片...")
|
||||
}
|
||||
client := newFloatImageUploadClient()
|
||||
if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[3/3] 提交浮动图片变更...")
|
||||
}
|
||||
toolArgs["src"] = uploadInfo.resourceURL
|
||||
if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil {
|
||||
return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) {
|
||||
return openFloatImageLocalFileWith(path, os.Stat, os.Open)
|
||||
}
|
||||
|
||||
func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) {
|
||||
info, err := stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 --file %q: %w", path, err)
|
||||
}
|
||||
if err := validateFloatImageFileInfo(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法打开 --file %q: %w", path, err)
|
||||
}
|
||||
openedInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err)
|
||||
}
|
||||
if err := validateFloatImageFileInfo(openedInfo); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &floatImageLocalFile{
|
||||
file: file,
|
||||
path: path,
|
||||
name: filepath.Base(path),
|
||||
mimeType: inferMimeType(filepath.Base(path)),
|
||||
size: openedInfo.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateFloatImageFileInfo(info os.FileInfo) error {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("--file 必须是普通文件")
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return fmt.Errorf("--file 不能为空文件")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error {
|
||||
arguments := make(map[string]any, len(toolArgs)+1)
|
||||
for key, value := range toolArgs {
|
||||
arguments[key] = value
|
||||
}
|
||||
arguments["src"] = floatImagePlannedResource
|
||||
stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName}
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": toolName,
|
||||
"arguments": arguments,
|
||||
"local_file": map[string]any{
|
||||
"path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size,
|
||||
},
|
||||
"stages": stages,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更")
|
||||
deps.Out.PrintKeyValue("文件", local.path)
|
||||
deps.Out.PrintKeyValue("名称", local.name)
|
||||
deps.Out.PrintKeyValue("类型", local.mimeType)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size))
|
||||
deps.Out.PrintKeyValue("Tool", toolName)
|
||||
deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) {
|
||||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": local.name,
|
||||
"fileSize": float64(local.size),
|
||||
"mimeType": local.mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return floatImageUploadInfo{}, WrapError(err)
|
||||
}
|
||||
return parseFloatImageUploadInfo(result)
|
||||
}
|
||||
|
||||
func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) {
|
||||
if result == nil {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
text := ""
|
||||
for _, content := range result.Content {
|
||||
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
|
||||
text = content.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
type credential struct {
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
ResourceURL string `json:"resourceUrl"`
|
||||
}
|
||||
var envelope struct {
|
||||
credential
|
||||
Result *credential `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
parsed := envelope.credential
|
||||
if envelope.Result != nil {
|
||||
parsed = *envelope.Result
|
||||
}
|
||||
parsed.UploadURL = strings.TrimSpace(parsed.UploadURL)
|
||||
parsed.ResourceID = strings.TrimSpace(parsed.ResourceID)
|
||||
parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL)
|
||||
if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) {
|
||||
return floatImageUploadInfo{}, invalidFloatImageCredentialError()
|
||||
}
|
||||
if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil {
|
||||
return floatImageUploadInfo{}, err
|
||||
}
|
||||
return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil
|
||||
}
|
||||
|
||||
func invalidFloatImageCredentialError() error {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "获取浮动图片上传凭证失败:响应格式无效",
|
||||
Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务",
|
||||
Operation: "doc/get_doc_attachment_upload_info",
|
||||
}
|
||||
}
|
||||
|
||||
func validFloatImageResourceID(value string) bool {
|
||||
return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0
|
||||
}
|
||||
|
||||
func validFloatImageResourceURL(value string) bool {
|
||||
if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||
}
|
||||
|
||||
// Keep this policy aligned with internal/transport.Client.isEndpointTrusted.
|
||||
// It stays Sheet-private in V1 so local-file float-image support does not
|
||||
// refactor the shared transport boundary selected by unrelated products.
|
||||
func validateFloatImageUploadURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return invalidFloatImageUploadURLError()
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "https") {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) {
|
||||
return invalidFloatImageUploadURLError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFloatImageLoopbackHost(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func invalidFloatImageUploadURLError() error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "浮动图片上传地址无效或不受信任",
|
||||
Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP",
|
||||
Operation: "sheet/float-image-upload",
|
||||
}
|
||||
}
|
||||
|
||||
func newFloatImageUploadClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: floatImageUploadTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile:
|
||||
// both have incompatible retry/status/host behavior, and the Doc helper can
|
||||
// copy a signed URL or response body into errors. Keep this fixed-message PUT
|
||||
// until a shared secure-upload contract can replace all upload implementations.
|
||||
func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用")
|
||||
}
|
||||
if _, err := local.file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("浮动图片文件上传失败:无法创建请求")
|
||||
}
|
||||
request.ContentLength = local.size
|
||||
request.Header.Set("Content-Type", local.mimeType)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
|
||||
return fmt.Errorf("浮动图片文件上传失败:已超过传输时限")
|
||||
}
|
||||
return fmt.Errorf("浮动图片文件上传失败:网络请求失败")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "image.fifo")
|
||||
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||
t.Fatalf("mkfifo: %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("FIFO error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type floatImageToolCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type floatImageTestCaller struct {
|
||||
dryRun bool
|
||||
format string
|
||||
calls []floatImageToolCall
|
||||
call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error)
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copyArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
index := len(c.calls)
|
||||
c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs})
|
||||
if c.call != nil {
|
||||
return c.call(ctx, product, tool, args, index)
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) Format() string {
|
||||
if c.format == "" {
|
||||
return "json"
|
||||
}
|
||||
return c.format
|
||||
}
|
||||
|
||||
func (c *floatImageTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*floatImageTestCaller) Fields() string { return "" }
|
||||
func (*floatImageTestCaller) JQ() string { return "" }
|
||||
|
||||
type floatImageRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
func floatImageTextResult(text string) *edition.ToolResult {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
|
||||
}
|
||||
|
||||
func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult {
|
||||
credential := map[string]any{
|
||||
"uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1",
|
||||
}
|
||||
payload := any(credential)
|
||||
if nested {
|
||||
payload = map[string]any{"result": credential}
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return floatImageTextResult(string(raw))
|
||||
}
|
||||
|
||||
func floatImageCommandForTest(t *testing.T, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, command := range newFloatImageCmds() {
|
||||
if command.Name() == name {
|
||||
return command
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing float-image command %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) {
|
||||
t.Helper()
|
||||
for name, value := range values {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) {
|
||||
t.Helper()
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)})
|
||||
previousArgs := os.Args
|
||||
os.Args = []string{"dws", "sheet"}
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
}
|
||||
|
||||
func writeFloatImageFixture(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile {
|
||||
t.Helper()
|
||||
local, err := openFloatImageLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open fixture: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.file.Close() })
|
||||
return local
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) {
|
||||
create := floatImageCommandForTest(t, "create-float-image")
|
||||
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("missing create input error = %v", err)
|
||||
}
|
||||
setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("conflicting create input error = %v", err)
|
||||
}
|
||||
createEmptyFile := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
|
||||
t.Fatalf("empty create file error = %v", err)
|
||||
}
|
||||
|
||||
fileOnly := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "})
|
||||
if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" {
|
||||
t.Fatalf("file-only input = %q/%q/%v", file, src, err)
|
||||
}
|
||||
srcOnly := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "})
|
||||
if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " {
|
||||
t.Fatalf("src-only input = %q/%q/%v", file, src, err)
|
||||
}
|
||||
|
||||
updateBoth := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"})
|
||||
if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
|
||||
t.Fatalf("conflicting update input error = %v", err)
|
||||
}
|
||||
updateEmptyFile := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "})
|
||||
if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
|
||||
t.Fatalf("empty update file error = %v", err)
|
||||
}
|
||||
updateSrc := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, updateSrc, map[string]string{"src": ""})
|
||||
if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" {
|
||||
t.Fatalf("src update compatibility = %q/%q/%v", file, src, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) {
|
||||
validPath := writeFloatImageFixture(t, "chart.png", "png-data")
|
||||
local, err := openFloatImageLocalFile(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) {
|
||||
t.Fatalf("local metadata = %#v", local)
|
||||
}
|
||||
_ = local.file.Close()
|
||||
|
||||
if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("missing file error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("directory error = %v", err)
|
||||
}
|
||||
emptyPath := writeFloatImageFixture(t, "empty.png", "")
|
||||
if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
|
||||
t.Fatalf("empty error = %v", err)
|
||||
}
|
||||
|
||||
validInfo, err := os.Stat(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statValid := func(string) (os.FileInfo, error) { return validInfo, nil }
|
||||
openFailure := errors.New("open sentinel")
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) {
|
||||
t.Fatalf("open error = %v", err)
|
||||
}
|
||||
closed, err := os.Open(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") {
|
||||
t.Fatalf("opened stat error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") {
|
||||
t.Fatalf("descriptor type error = %v", err)
|
||||
}
|
||||
if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") {
|
||||
t.Fatalf("descriptor size error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) {
|
||||
invalidResults := []*edition.ToolResult{
|
||||
nil,
|
||||
{},
|
||||
{Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}},
|
||||
floatImageTextResult("{"),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`),
|
||||
floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`),
|
||||
}
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
||||
for index, result := range invalidResults {
|
||||
_, err := parseFloatImageUploadInfo(result)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid result %d succeeded", index)
|
||||
}
|
||||
if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") {
|
||||
t.Fatalf("invalid result %d leaked secret: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`)
|
||||
info, err := parseFloatImageUploadInfo(top)
|
||||
if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" {
|
||||
t.Fatalf("top-level credential = %#v, %v", info, err)
|
||||
}
|
||||
nested := &edition.ToolResult{Content: []edition.ContentBlock{
|
||||
{Type: "text", Text: " "},
|
||||
{Type: "image", Text: "ignored"},
|
||||
{Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`},
|
||||
}}
|
||||
info, err = parseFloatImageUploadInfo(nested)
|
||||
if err != nil || info.resourceID != "rid-nested" {
|
||||
t.Fatalf("nested credential = %#v, %v", info, err)
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} {
|
||||
if validFloatImageResourceURL(value) {
|
||||
t.Errorf("resource URL %q unexpectedly valid", value)
|
||||
}
|
||||
}
|
||||
if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") {
|
||||
t.Fatal("resource identifier validation mismatch")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} {
|
||||
if err := validateFloatImageUploadURL(rawURL); err == nil {
|
||||
t.Errorf("upload URL %q unexpectedly valid", rawURL)
|
||||
}
|
||||
}
|
||||
if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil {
|
||||
t.Fatalf("HTTPS URL rejected: %v", err)
|
||||
}
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} {
|
||||
if err := validateFloatImageUploadURL(rawURL); err != nil {
|
||||
t.Errorf("loopback URL %q rejected: %v", rawURL, err)
|
||||
}
|
||||
}
|
||||
if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") {
|
||||
t.Fatalf("non-loopback policy mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "streamed-image")
|
||||
|
||||
t.Run("nil client", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil {
|
||||
t.Fatal("nil client succeeded")
|
||||
}
|
||||
})
|
||||
t.Run("closed file", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
_ = local.file.Close()
|
||||
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") {
|
||||
t.Fatalf("closed file error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("invalid request", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") {
|
||||
t.Fatalf("request error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("transport secret redacted", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("transport signed-secret https://upload.invalid")
|
||||
})}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local)
|
||||
if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("cancel", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return nil, request.Context().Err()
|
||||
})}
|
||||
err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("client deadline", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local)
|
||||
if err == nil || !strings.Contains(err.Error(), "超过传输时限") {
|
||||
t.Fatalf("deadline error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("non-200 body redacted", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(response, "response-body-secret")
|
||||
}))
|
||||
defer server.Close()
|
||||
err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("status error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("stream success", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
var method, contentType, body string
|
||||
var contentLength int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
method = request.Method
|
||||
contentType = request.Header.Get("Content-Type")
|
||||
contentLength = request.ContentLength
|
||||
raw, _ := io.ReadAll(request.Body)
|
||||
body = string(raw)
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" {
|
||||
t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body)
|
||||
}
|
||||
})
|
||||
t.Run("redirect not followed", func(t *testing.T) {
|
||||
local := openFloatImageFixture(t, path)
|
||||
destinationCalls := 0
|
||||
destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ }))
|
||||
defer destination.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.Header().Set("Location", destination.URL+"/location-secret")
|
||||
response.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
client := newFloatImageUploadClient()
|
||||
if client.Timeout != floatImageUploadTimeout {
|
||||
t.Fatalf("timeout = %v", client.Timeout)
|
||||
}
|
||||
err := putFloatImageFileWithClient(context.Background(), client, source.URL, local)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 {
|
||||
t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "image")
|
||||
for _, format := range []string{"json", "table"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{dryRun: true, format: format}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run remote calls = %d", len(caller.calls))
|
||||
}
|
||||
if format == "json" {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run: %v; output=%s", err, output.String())
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false {
|
||||
t.Fatalf("dry-run payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["preview_kind"]; exists {
|
||||
t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload)
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") {
|
||||
t.Fatalf("pretty dry-run = %s", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, format := range []string{"json", "table"} {
|
||||
t.Run("update-"+format, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{dryRun: true, format: format}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("update dry-run remote calls = %d", len(caller.calls))
|
||||
}
|
||||
if format == "json" {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode update dry-run: %v; output=%s", err, output.String())
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false {
|
||||
t.Fatalf("update dry-run payload = %#v", payload)
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") {
|
||||
t.Fatalf("update pretty dry-run = %s", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.png", "command-image")
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
t.Run("create src", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{
|
||||
"node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80",
|
||||
})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" {
|
||||
t.Fatalf("src calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create and update file", func(t *testing.T) {
|
||||
var uploadBodies []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
raw, _ := io.ReadAll(request.Body)
|
||||
uploadBodies = append(uploadBodies, string(raw))
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, commandName := range []string{"create-float-image", "update-float-image"} {
|
||||
t.Run(commandName, func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
if product != "doc" || tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("credential route = %s/%s", product, tool)
|
||||
}
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil
|
||||
}}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, commandName)
|
||||
flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}
|
||||
if commandName == "create-float-image" {
|
||||
flags["range"], flags["width"], flags["height"] = "A1", "100", "80"
|
||||
} else {
|
||||
flags["float-image-id"], flags["width"] = "fi-1", "120"
|
||||
}
|
||||
setFloatImageFlags(t, command, flags)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") {
|
||||
t.Fatalf("workflow calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" {
|
||||
t.Fatalf("workflow args = %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil {
|
||||
t.Fatalf("final output = %s, %v", output.String(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" {
|
||||
t.Fatalf("upload bodies = %#v", uploadBodies)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pretty progress", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
return floatImageCredentialResult(server.URL, false), nil
|
||||
}
|
||||
return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil
|
||||
}}
|
||||
var output bytes.Buffer
|
||||
installFloatImageDeps(t, caller, &output)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} {
|
||||
if !strings.Contains(output.String(), marker) {
|
||||
t.Fatalf("pretty output missing %q: %s", marker, output.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and local error", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
create := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"})
|
||||
if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) {
|
||||
t.Fatalf("create validation = %v", err)
|
||||
}
|
||||
missing := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"})
|
||||
if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("local error = %v", err)
|
||||
}
|
||||
update := floatImageCommandForTest(t, "update-float-image")
|
||||
if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) {
|
||||
t.Fatalf("update fields error = %v", err)
|
||||
}
|
||||
conflict := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"})
|
||||
if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) {
|
||||
t.Fatalf("update conflict = %v", err)
|
||||
}
|
||||
missingIdentity := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path})
|
||||
if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 {
|
||||
t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credential and put failures", func(t *testing.T) {
|
||||
credentialCause := errors.New("credential transport")
|
||||
caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return nil, credentialCause
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); !errors.Is(err, credentialCause) {
|
||||
t.Fatalf("credential error = %v", err)
|
||||
}
|
||||
|
||||
secret := "credential-response-secret"
|
||||
malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return floatImageTextResult(secret), nil
|
||||
}}
|
||||
installFloatImageDeps(t, malformed, io.Discard)
|
||||
command = floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("malformed credential error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(response, "put-body-secret")
|
||||
}))
|
||||
defer server.Close()
|
||||
putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
|
||||
}}
|
||||
installFloatImageDeps(t, putCaller, io.Discard)
|
||||
command = floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial final failure", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
finalCause := errors.New("final operation sentinel")
|
||||
caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) {
|
||||
if index == 0 {
|
||||
return floatImageCredentialResult(server.URL+"/signed-secret", false), nil
|
||||
}
|
||||
return nil, finalCause
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "create-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"})
|
||||
err := command.RunE(command, nil)
|
||||
if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") {
|
||||
t.Fatalf("partial failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update move only", func(t *testing.T) {
|
||||
caller := &floatImageTestCaller{}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
command := floatImageCommandForTest(t, "update-float-image")
|
||||
setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"})
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" {
|
||||
t.Fatalf("move-only calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) {
|
||||
path := writeFloatImageFixture(t, "chart.webp", "webp")
|
||||
local := openFloatImageFixture(t, path)
|
||||
caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) {
|
||||
if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("route/context = %s/%s/%v", product, tool, ctx)
|
||||
}
|
||||
if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) {
|
||||
t.Fatalf("credential args = %#v", args)
|
||||
}
|
||||
return floatImageCredentialResult("https://upload.invalid/path", false), nil
|
||||
}}
|
||||
installFloatImageDeps(t, caller, io.Discard)
|
||||
info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local)
|
||||
if err != nil || info.resourceID != "rid-1" {
|
||||
t.Fatalf("resolve info = %#v/%v", info, err)
|
||||
}
|
||||
|
||||
cause := fmt.Errorf("timeout synthetic")
|
||||
caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) {
|
||||
return nil, cause
|
||||
}
|
||||
if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) {
|
||||
t.Fatalf("resolve error = %v", err)
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) {
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.Fatalf("credential context error = %v", ctx.Err())
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled credential error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) {
|
||||
client := newFloatImageUploadClient()
|
||||
if client.Timeout != 5*time.Minute || client.CheckRedirect == nil {
|
||||
t.Fatalf("upload client = %#v", client)
|
||||
}
|
||||
}
|
||||
+64
-18
@@ -96,7 +96,10 @@ func newTodoCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": flagOrFallback(cmd, "title", "subject", "content"),
|
||||
@@ -111,9 +114,11 @@ func newTodoCommand() *cobra.Command {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
|
||||
@@ -154,7 +159,7 @@ func newTodoCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
|
||||
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
|
||||
{Name: "title", Property: "PersonalTodoCreateVO.subject"},
|
||||
@@ -190,7 +195,10 @@ func newTodoCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": flagOrFallback(cmd, "title", "subject", "content"),
|
||||
@@ -206,9 +214,11 @@ func newTodoCommand() *cobra.Command {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
|
||||
@@ -249,7 +259,7 @@ func newTodoCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"},
|
||||
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
|
||||
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
|
||||
@@ -376,9 +386,11 @@ func newTodoCommand() *cobra.Command {
|
||||
inner["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
inner["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inner["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("done"); v != "" {
|
||||
inner["isDone"] = v == "true"
|
||||
@@ -630,7 +642,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_task_executors", map[string]any{
|
||||
"todoExecutorsAddRequest": map[string]any{
|
||||
"taskId": mustGetFlag(cmd, "task-id"),
|
||||
@@ -672,7 +687,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds"},
|
||||
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "task-id", Property: "todoExecutorsAddRequest.taskId"},
|
||||
},
|
||||
},
|
||||
@@ -688,7 +703,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_task_executors", map[string]any{
|
||||
"todoExecutorsRemoveRequest": map[string]any{
|
||||
"taskId": mustGetFlag(cmd, "task-id"),
|
||||
@@ -729,7 +747,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"},
|
||||
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"},
|
||||
},
|
||||
},
|
||||
@@ -1189,14 +1207,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
}
|
||||
|
||||
todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
|
||||
todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
|
||||
todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
|
||||
|
||||
todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
|
||||
todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
|
||||
todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
|
||||
@@ -1235,9 +1253,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
_ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at")
|
||||
|
||||
todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)")
|
||||
todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
@@ -1893,6 +1911,34 @@ func parseExecutorIds(s string) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func parseRequiredTodoIDs(flagName, value string) ([]string, error) {
|
||||
ids := parseExecutorIds(value)
|
||||
if len(ids) > 0 {
|
||||
return ids, nil
|
||||
}
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s 至少需要一个非空 userId", flagName),
|
||||
apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")),
|
||||
apperrors.WithHint(fmt.Sprintf("使用 --%s <USER_ID>[,<USER_ID>...] 指定至少一个执行人", flagName)),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
|
||||
func parseTodoPriority(value string) (int, error) {
|
||||
priority, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return priority, nil
|
||||
}
|
||||
return 0, apperrors.NewValidation(
|
||||
"--priority 必须是整数",
|
||||
apperrors.WithReason("invalid_priority"),
|
||||
apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
|
||||
type todoLocalFileMeta struct {
|
||||
LocalPath string
|
||||
FileName string
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
@@ -44,9 +47,11 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate},
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--due", "bad"},
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
|
||||
{"task", "list", "--role-types", "invalid"},
|
||||
{"task", "list", "--plan-finish-date-start", "bad"},
|
||||
{"task", "list", "--plan-finish-date-end", "bad"},
|
||||
@@ -59,9 +64,8 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
|
||||
validCases := [][]string{
|
||||
{"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
|
||||
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"},
|
||||
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"},
|
||||
{"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
|
||||
{"task", "list", "--size", "bad"},
|
||||
{"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate},
|
||||
}
|
||||
@@ -75,6 +79,63 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
args []string
|
||||
}{
|
||||
{name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}},
|
||||
{name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}},
|
||||
{name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}},
|
||||
{name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}},
|
||||
{name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeTodoEdge(t, caller, test.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != test.reason {
|
||||
t.Fatalf("error = %T %v, want reason %q", err, err, test.reason)
|
||||
}
|
||||
if typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid input made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) {
|
||||
for _, priority := range []string{"20", "25"} {
|
||||
t.Run(priority, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil {
|
||||
t.Fatalf("create priority %s: %v", priority, err)
|
||||
}
|
||||
if caller.calls != 1 || caller.tool != "create_personal_todo" {
|
||||
t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool)
|
||||
}
|
||||
request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if !ok || request["priority"] != mustTodoAtoi(t, priority) {
|
||||
t.Fatalf("request = %#v, want priority %s", caller.args, priority)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustTodoAtoi(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "create"},
|
||||
@@ -143,6 +204,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "update", "--task-id", "1", "--remind-at", validDate},
|
||||
{"task", "update", "--task-id", "1", "--due", "bad"},
|
||||
{"task", "update", "--task-id", "1", "--priority", "bad"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"},
|
||||
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`},
|
||||
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
|
||||
@@ -155,7 +217,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
|
||||
}
|
||||
validCases := [][]string{
|
||||
{"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"},
|
||||
{"task", "update", "--task-id", "1", "--priority", "bad"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate},
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
|
||||
CommandMigrationMove = "command_move"
|
||||
CommandMigrationFlagExtraction = "flag_extraction"
|
||||
CommandMigrationAvailability = "schema_availability_hardening"
|
||||
)
|
||||
|
||||
// CommandMigrationManifest governs compatibility-preserving surface moves that
|
||||
@@ -64,6 +65,12 @@ type CommandMigrationSchema struct {
|
||||
SourceToolID string `json:"source_tool_id"`
|
||||
ReplacementToolID string `json:"replacement_tool_id"`
|
||||
Parameters []CommandParameterMigration `json:"parameters"`
|
||||
Availability *CommandAvailabilityChange `json:"availability,omitempty"`
|
||||
}
|
||||
|
||||
type CommandAvailabilityChange struct {
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
}
|
||||
|
||||
type CommandParameterMigration struct {
|
||||
@@ -126,14 +133,23 @@ func (m CommandMigrationManifest) Validate() error {
|
||||
}
|
||||
|
||||
func (m CommandMigration) validate() error {
|
||||
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
|
||||
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability {
|
||||
return fmt.Errorf("invalid kind %q", m.Kind)
|
||||
}
|
||||
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
|
||||
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
|
||||
}
|
||||
if m.Legacy.Command == m.Replacement.Command {
|
||||
return fmt.Errorf("legacy and replacement command paths must differ")
|
||||
if m.Kind == CommandMigrationAvailability {
|
||||
if !isExactCommandPath(m.Legacy.Command) {
|
||||
return fmt.Errorf("legacy must be an exact command path rooted at dws")
|
||||
}
|
||||
if m.Replacement != (CommandMigrationSide{}) {
|
||||
return fmt.Errorf("schema_availability_hardening must not declare replacement")
|
||||
}
|
||||
} else {
|
||||
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
|
||||
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
|
||||
}
|
||||
if m.Legacy.Command == m.Replacement.Command {
|
||||
return fmt.Errorf("legacy and replacement command paths must differ")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
|
||||
return fmt.Errorf("migration must include a non-empty trimmed reason")
|
||||
@@ -141,21 +157,26 @@ func (m CommandMigration) validate() error {
|
||||
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
for label, state := range map[string]CommandMigrationState{
|
||||
"legacy before": m.Legacy.Before,
|
||||
"legacy after": m.Legacy.After,
|
||||
"replacement before": m.Replacement.Before,
|
||||
"replacement after": m.Replacement.After,
|
||||
} {
|
||||
states := map[string]CommandMigrationState{
|
||||
"legacy before": m.Legacy.Before,
|
||||
"legacy after": m.Legacy.After,
|
||||
}
|
||||
if m.Kind != CommandMigrationAvailability {
|
||||
states["replacement before"] = m.Replacement.Before
|
||||
states["replacement after"] = m.Replacement.After
|
||||
}
|
||||
for label, state := range states {
|
||||
if err := state.validate(label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
|
||||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
|
||||
if m.Kind != CommandMigrationAvailability &&
|
||||
(!m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
|
||||
!m.Legacy.After.Present || !m.Legacy.After.Runnable) {
|
||||
return fmt.Errorf("legacy command must remain runnable and start visible")
|
||||
}
|
||||
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
|
||||
if m.Kind != CommandMigrationAvailability &&
|
||||
(m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden) {
|
||||
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
|
||||
}
|
||||
if err := m.Schema.validate(m.Kind); err != nil {
|
||||
@@ -214,6 +235,13 @@ func (m CommandMigration) validate() error {
|
||||
if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
|
||||
return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
|
||||
}
|
||||
case CommandMigrationAvailability:
|
||||
if !isVisibleToHiddenAvailabilityMigration(m) && !isCompatibilityVisibleAvailabilityMigration(m) {
|
||||
return fmt.Errorf("schema_availability_hardening legacy command must migrate exactly from visible to hidden or remain compatibility-visible")
|
||||
}
|
||||
if m.LegacyFlag != (CommandMigrationFlag{}) {
|
||||
return fmt.Errorf("schema_availability_hardening must not declare legacy_flag")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -259,9 +287,8 @@ func (f CommandMigrationFlag) validate() error {
|
||||
|
||||
func (s CommandMigrationSchema) validate(kind string) error {
|
||||
for label, value := range map[string]string{
|
||||
"product_id": s.ProductID,
|
||||
"source_tool_id": s.SourceToolID,
|
||||
"replacement_tool_id": s.ReplacementToolID,
|
||||
"product_id": s.ProductID,
|
||||
"source_tool_id": s.SourceToolID,
|
||||
} {
|
||||
if !isExactSchemaIdentifier(value) {
|
||||
return fmt.Errorf("schema %s must be an exact identifier", label)
|
||||
@@ -270,6 +297,21 @@ func (s CommandMigrationSchema) validate(kind string) error {
|
||||
if s.Parameters == nil {
|
||||
return fmt.Errorf("schema parameters must be an array")
|
||||
}
|
||||
if kind == CommandMigrationAvailability {
|
||||
if s.ReplacementToolID != "" || len(s.Parameters) != 0 {
|
||||
return fmt.Errorf("schema_availability_hardening must not declare a replacement tool or parameter mappings")
|
||||
}
|
||||
if s.Availability == nil || s.Availability.Before != "available" || s.Availability.After != "unavailable" {
|
||||
return fmt.Errorf("schema_availability_hardening requires availability available to unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isExactSchemaIdentifier(s.ReplacementToolID) {
|
||||
return fmt.Errorf("schema replacement_tool_id must be an exact identifier")
|
||||
}
|
||||
if s.Availability != nil {
|
||||
return fmt.Errorf("%s must not declare schema availability", kind)
|
||||
}
|
||||
seenFrom := map[string]bool{}
|
||||
seenTo := map[string]bool{}
|
||||
seenConstantProperty := map[string]bool{}
|
||||
@@ -344,6 +386,9 @@ func (m CommandMigration) key() string {
|
||||
}
|
||||
|
||||
func (m CommandMigration) displayKey() string {
|
||||
if m.Kind == CommandMigrationAvailability {
|
||||
return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command)
|
||||
}
|
||||
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
|
||||
}
|
||||
|
||||
@@ -401,7 +446,7 @@ func AuthorizeCommandMigrations(
|
||||
func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
|
||||
commands := commandIndex(snapshot)
|
||||
for _, migration := range manifest.Migrations {
|
||||
if migration.Kind != CommandMigrationMove {
|
||||
if migration.Kind != CommandMigrationMove && migration.Kind != CommandMigrationAvailability {
|
||||
continue
|
||||
}
|
||||
if _, present := commands[migration.Legacy.Command]; !present {
|
||||
@@ -409,7 +454,7 @@ func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigratio
|
||||
}
|
||||
for _, command := range snapshot.Commands {
|
||||
if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
|
||||
return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
|
||||
return fmt.Errorf("%s legacy command %q must be a leaf", migration.Kind, migration.Legacy.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,17 +483,42 @@ func evaluateCommandMigrationLifecycle(
|
||||
for _, approved := range authority.Migrations {
|
||||
basePhase := matchCommandMigrationPhase(mergeBase, approved)
|
||||
wantBase := commandMigrationBefore
|
||||
if approved.State == CommandMigrationConsumed {
|
||||
if approved.State == CommandMigrationConsumed && !isCompatibilityVisibleAvailabilityMigration(approved) {
|
||||
wantBase = commandMigrationAfter
|
||||
}
|
||||
if basePhase != wantBase {
|
||||
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
|
||||
}
|
||||
proposed, exists := candidateByKey[approved.key()]
|
||||
if exists && !sameCommandMigrationApproval(approved, proposed) {
|
||||
if exists && !sameCommandMigrationApproval(approved, proposed) &&
|
||||
!isPendingAvailabilityCompatibilityRefinement(approved, proposed) {
|
||||
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
|
||||
}
|
||||
currentPhase := matchCommandMigrationPhase(current, approved)
|
||||
if isCompatibilityVisibleAvailabilityMigration(approved) {
|
||||
if currentPhase != commandMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate drifted from compatibility-visible command migration %s", approved.displayKey())
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate must retain compatibility-visible command migration %s", approved.displayKey())
|
||||
}
|
||||
switch approved.State {
|
||||
case CommandMigrationPending:
|
||||
switch proposed.State {
|
||||
case CommandMigrationPending:
|
||||
continue
|
||||
case CommandMigrationConsumed:
|
||||
authorizations = append(authorizations, approved)
|
||||
continue
|
||||
}
|
||||
case CommandMigrationConsumed:
|
||||
if proposed.State != CommandMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed compatibility-visible command migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch approved.State {
|
||||
case CommandMigrationPending:
|
||||
if !exists {
|
||||
@@ -521,9 +591,40 @@ func sameCommandMigrationApproval(left, right CommandMigration) bool {
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func isVisibleToHiddenAvailabilityMigration(migration CommandMigration) bool {
|
||||
return migration.Kind == CommandMigrationAvailability &&
|
||||
migration.Legacy.Before == (CommandMigrationState{Present: true, Runnable: true}) &&
|
||||
migration.Legacy.After == (CommandMigrationState{Present: true, Runnable: true, Hidden: true})
|
||||
}
|
||||
|
||||
func isCompatibilityVisibleAvailabilityMigration(migration CommandMigration) bool {
|
||||
visible := CommandMigrationState{Present: true, Runnable: true}
|
||||
return migration.Kind == CommandMigrationAvailability &&
|
||||
migration.Legacy.Before == visible && migration.Legacy.After == visible
|
||||
}
|
||||
|
||||
func isPendingAvailabilityCompatibilityRefinement(approved, proposed CommandMigration) bool {
|
||||
if approved.State != CommandMigrationPending || proposed.State != CommandMigrationPending ||
|
||||
!isVisibleToHiddenAvailabilityMigration(approved) ||
|
||||
!isCompatibilityVisibleAvailabilityMigration(proposed) {
|
||||
return false
|
||||
}
|
||||
proposed.Legacy.After = approved.Legacy.After
|
||||
return reflect.DeepEqual(approved, proposed)
|
||||
}
|
||||
|
||||
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
|
||||
commands := commandIndex(snapshot)
|
||||
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
|
||||
if migration.Kind == CommandMigrationAvailability {
|
||||
if legacy == migration.Legacy.Before {
|
||||
return commandMigrationBefore
|
||||
}
|
||||
if legacy == migration.Legacy.After {
|
||||
return commandMigrationAfter
|
||||
}
|
||||
return commandMigrationPartial
|
||||
}
|
||||
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
|
||||
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
|
||||
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
|
||||
@@ -629,6 +730,10 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change
|
||||
if change.Kind == "command_became_hidden" && change.Flag == "" {
|
||||
return true
|
||||
}
|
||||
case CommandMigrationAvailability:
|
||||
if change.Kind == "command_became_hidden" && change.Flag == "" {
|
||||
return true
|
||||
}
|
||||
case CommandMigrationFlagExtraction:
|
||||
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
|
||||
return true
|
||||
|
||||
@@ -186,6 +186,7 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
|
||||
{"replacement identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ReplacementToolID = "bad/id" }, "replacement_tool_id"},
|
||||
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
|
||||
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
|
||||
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
|
||||
@@ -241,6 +242,153 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaAvailabilityMigrationLifecycle(t *testing.T) {
|
||||
pending := availabilityCommandMigrationManifest(CommandMigrationPending)
|
||||
consumed := availabilityCommandMigrationManifest(CommandMigrationConsumed)
|
||||
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
before := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws devapp"),
|
||||
testCommand("dws devapp +event-list"),
|
||||
)
|
||||
after := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws devapp"),
|
||||
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
|
||||
)
|
||||
|
||||
got, err := AuthorizeCommandMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("availability lifecycle authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
report, err := CompareAllWithInterfaceMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
|
||||
FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("availability interface report compatible=%v error=%v report=%#v", report.Compatible, err, report)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(
|
||||
before,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate-added pending migration must remain inert: %#v, %v", got, err)
|
||||
}
|
||||
|
||||
valid := pending.Migrations[0]
|
||||
compatibilityVisible := cloneCommandMigration(valid)
|
||||
compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before
|
||||
if err := compatibilityVisible.validate(); err != nil {
|
||||
t.Fatalf("compatibility-visible availability migration must validate: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{"legacy path", func(m *CommandMigration) { m.Legacy.Command = "dws devapp *" }, "legacy must be an exact"},
|
||||
{"replacement", func(m *CommandMigration) { m.Replacement.Command = "dws devapp other" }, "must not declare replacement"},
|
||||
{"absent after", func(m *CommandMigration) { m.Legacy.After = CommandMigrationState{} }, "visible to hidden or remain compatibility-visible"},
|
||||
{"legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" }, "must not declare legacy_flag"},
|
||||
{"missing availability", func(m *CommandMigration) { m.Schema.Availability = nil }, "requires availability"},
|
||||
{"wrong availability", func(m *CommandMigration) { m.Schema.Availability.After = "available" }, "requires availability"},
|
||||
{"parameter mapping", func(m *CommandMigration) {
|
||||
m.Schema.Parameters = []CommandParameterMigration{{From: "cursor", To: "next-token"}}
|
||||
}, "must not declare"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
migration := cloneCommandMigration(valid)
|
||||
if valid.Schema.Availability != nil {
|
||||
change := *valid.Schema.Availability
|
||||
migration.Schema.Availability = &change
|
||||
}
|
||||
test.mutate(&migration)
|
||||
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("availability migration validation error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := valid.displayKey(); !strings.Contains(got, CommandMigrationAvailability) {
|
||||
t.Fatalf("availability display key = %q", got)
|
||||
}
|
||||
if got := matchCommandMigrationPhase(testSnapshot(testCommand("dws")), valid); got != commandMigrationPartial {
|
||||
t.Fatalf("missing availability command phase = %q", got)
|
||||
}
|
||||
parent := before
|
||||
parent.Commands = append(append([]Command(nil), before.Commands...), testCommand("dws devapp +event-list detail"))
|
||||
if err := validateCommandMoveLegacyLeaves(parent, pending); err == nil || !strings.Contains(err.Error(), "must be a leaf") {
|
||||
t.Fatalf("availability parent command error = %v", err)
|
||||
}
|
||||
moveSchema := commandMigrationManifest(CommandMigrationPending).Migrations[0].Schema
|
||||
moveSchema.Availability = &CommandAvailabilityChange{Before: "available", After: "unavailable"}
|
||||
if err := moveSchema.validate(CommandMigrationMove); err == nil || !strings.Contains(err.Error(), "must not declare schema availability") {
|
||||
t.Fatalf("ordinary move availability error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompatibilityVisibleAvailabilityMigrationLifecycle(t *testing.T) {
|
||||
classicPending := availabilityCommandMigrationManifest(CommandMigrationPending)
|
||||
steadyPending := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
|
||||
steadyConsumed := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationConsumed)
|
||||
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
visible := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws devapp"),
|
||||
testCommand("dws devapp +event-list"),
|
||||
)
|
||||
hidden := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws devapp"),
|
||||
Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true},
|
||||
)
|
||||
references := map[string]Snapshot{"merge-base": visible, "stable": visible}
|
||||
|
||||
if got, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyPending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("pending compatibility refinement authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyPending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("pending compatibility receipt authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyConsumed); err != nil || len(got) != 1 {
|
||||
t.Fatalf("consumed compatibility receipt authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyConsumed); err != nil || len(got) != 1 {
|
||||
t.Fatalf("retained consumed compatibility receipt authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(visible, references, empty, steadyPending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate-added compatibility receipt authorizations=%#v error=%v", got, err)
|
||||
}
|
||||
|
||||
modified := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending)
|
||||
modified.Migrations[0].Reason = "Changed reason."
|
||||
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, modified); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
|
||||
t.Fatalf("modified compatibility refinement error=%v", err)
|
||||
}
|
||||
if _, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyConsumed); err == nil || !strings.Contains(err.Error(), "modified base-owned") {
|
||||
t.Fatalf("refinement and consumption in one change error=%v", err)
|
||||
}
|
||||
if _, err := AuthorizeCommandMigrations(hidden, references, steadyPending, steadyPending); err == nil || !strings.Contains(err.Error(), "drifted from compatibility-visible") {
|
||||
t.Fatalf("hidden compatibility command error=%v", err)
|
||||
}
|
||||
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, empty); err == nil || !strings.Contains(err.Error(), "must retain compatibility-visible") {
|
||||
t.Fatalf("removed consumed compatibility receipt error=%v", err)
|
||||
}
|
||||
if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyPending); err == nil || !strings.Contains(err.Error(), "back to pending") {
|
||||
t.Fatalf("consumed compatibility receipt reverted error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -686,7 +834,10 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife
|
||||
|
||||
func cloneCommandMigration(source CommandMigration) CommandMigration {
|
||||
cloned := source
|
||||
cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
|
||||
if source.Schema.Parameters != nil {
|
||||
cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters))
|
||||
copy(cloned.Schema.Parameters, source.Schema.Parameters)
|
||||
}
|
||||
for index, parameter := range source.Schema.Parameters {
|
||||
if parameter.ReplacementConstant == nil {
|
||||
continue
|
||||
@@ -703,6 +854,34 @@ func singleCommandMigrationManifest(state string) CommandMigrationManifest {
|
||||
return manifest
|
||||
}
|
||||
|
||||
func availabilityCommandMigrationManifest(state string) CommandMigrationManifest {
|
||||
return CommandMigrationManifest{
|
||||
Version: CommandMigrationManifestVersion,
|
||||
Migrations: []CommandMigration{{
|
||||
Kind: CommandMigrationAvailability,
|
||||
Legacy: CommandMigrationSide{
|
||||
Command: "dws devapp +event-list",
|
||||
Before: CommandMigrationState{Present: true, Runnable: true},
|
||||
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Schema: CommandMigrationSchema{
|
||||
ProductID: "devapp",
|
||||
SourceToolID: "devapp.shortcut_event_list",
|
||||
Parameters: []CommandParameterMigration{},
|
||||
Availability: &CommandAvailabilityChange{Before: "available", After: "unavailable"},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Fail closed until terminal pagination evidence is available.",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func compatibilityVisibleAvailabilityCommandMigrationManifest(state string) CommandMigrationManifest {
|
||||
manifest := availabilityCommandMigrationManifest(state)
|
||||
manifest.Migrations[0].Legacy.After = manifest.Migrations[0].Legacy.Before
|
||||
return manifest
|
||||
}
|
||||
|
||||
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
|
||||
modified := source
|
||||
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public
|
||||
// surface intentionally stays smaller than the atomic helper surface: only
|
||||
// reads with strict response contracts and safe live proof are available.
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"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 productAgoal = "agoal"
|
||||
|
||||
var agoalLoadLocation = time.LoadLocation
|
||||
|
||||
func agoalResponseError(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 requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空")
|
||||
}
|
||||
rawSuccess, present := data["success"]
|
||||
if !present {
|
||||
return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态")
|
||||
}
|
||||
success, ok := rawSuccess.(bool)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message, _ := data["message"].(string)
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, agoalResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
for _, key := range []string{"error", "errorCode", "error_code"} {
|
||||
if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" {
|
||||
return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key))
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index))
|
||||
}
|
||||
if agoalFirstString(item, idKeys...) == "" {
|
||||
return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index))
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func agoalFirstString(item map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func agoalNonNegativeInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, typed >= 0
|
||||
case int64:
|
||||
if typed < 0 || int64(int(typed)) != typed {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return 0, false
|
||||
}
|
||||
return int(parsed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func agoalReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func agoalUnavailableSafety(write bool) contract.SafetySpec {
|
||||
if write {
|
||||
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
return agoalReadSafety()
|
||||
}
|
||||
|
||||
func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec {
|
||||
properties := map[string]any{
|
||||
"count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"},
|
||||
collection: map[string]any{
|
||||
"type": "array", "description": description,
|
||||
"items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true},
|
||||
},
|
||||
}
|
||||
required := []string{"count", collection}
|
||||
if paged {
|
||||
properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"}
|
||||
properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"}
|
||||
properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"}
|
||||
required = append(required, "page", "pageSize", "totalCount")
|
||||
}
|
||||
schema, _ := json.Marshal(map[string]any{
|
||||
"type": "object", "description": description, "additionalProperties": false,
|
||||
"properties": properties, "required": required,
|
||||
})
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: schema,
|
||||
SensitivePaths: append([]string(nil), sensitivePaths...),
|
||||
}
|
||||
}
|
||||
|
||||
var ReportStatisticsList = shortcut.Shortcut{
|
||||
Service: productAgoal,
|
||||
Command: "+report-statistics-list",
|
||||
Product: productAgoal,
|
||||
Description: "查询周月报规则提交统计",
|
||||
Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_report_statistics_list",
|
||||
CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list",
|
||||
},
|
||||
Description: "查询周月报规则提交统计",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询周月报规则提交统计",
|
||||
UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"},
|
||||
AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"},
|
||||
Examples: []string{"dws agoal +report-statistics-list --keyword <KEYWORD>"},
|
||||
},
|
||||
Result: agoalListResult("statistics", "周月报规则提交统计记录", false,
|
||||
"statistics.title", "statistics.lastModifier", "statistics.content.name"),
|
||||
},
|
||||
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("keyword") {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "statistics": items})
|
||||
},
|
||||
}
|
||||
|
||||
var ObjectTemplateList = shortcut.Shortcut{
|
||||
Service: productAgoal,
|
||||
Command: "+obj-template-list",
|
||||
Product: productAgoal,
|
||||
Description: "分页查询 Agoal 目标模板",
|
||||
Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_obj_template_list",
|
||||
CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list",
|
||||
},
|
||||
Description: "分页查询 Agoal 目标模板",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页查询 Agoal 目标模板",
|
||||
UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"},
|
||||
AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"},
|
||||
Examples: []string{"dws agoal +obj-template-list --keyword <KEYWORD> --page 1 --page-size 20"},
|
||||
},
|
||||
Result: agoalListResult("templates", "Agoal 目标模板查询结果", true,
|
||||
"templates.title", "templates.creator", "templates.dimensions.title"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于等于 1")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
wantPage := rt.Int("page")
|
||||
wantSize := rt.Int("page-size")
|
||||
params := map[string]any{"page": wantPage, "pageSize": wantSize}
|
||||
if rt.Changed("keyword") {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil {
|
||||
return err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象")
|
||||
}
|
||||
items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, pageOK := agoalNonNegativeInt(content["page"])
|
||||
pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"])
|
||||
total, totalOK := agoalNonNegativeInt(content["totalCount"])
|
||||
if !pageOK || !pageSizeOK || !totalOK {
|
||||
return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
|
||||
}
|
||||
if page != wantPage || pageSize != wantSize {
|
||||
return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致")
|
||||
}
|
||||
if total < len(items) {
|
||||
return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数")
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(items), "templates": items,
|
||||
"page": page, "pageSize": pageSize, "totalCount": total,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ContractFields = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+contract-fields", Product: productAgoal,
|
||||
Description: "查询经营合约字段配置",
|
||||
Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_contract_fields",
|
||||
CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields",
|
||||
},
|
||||
Description: "查询并严格筛选经营合约字段配置",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询经营合约字段配置",
|
||||
UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"},
|
||||
AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"},
|
||||
Examples: []string{"dws agoal +contract-fields --keyword <KEYWORD>"},
|
||||
},
|
||||
Result: agoalListResult("fields", "经营合约字段配置", false),
|
||||
},
|
||||
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type")
|
||||
return rt.Output(map[string]any{"count": len(fields), "fields": fields})
|
||||
},
|
||||
}
|
||||
|
||||
var UserRules = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+user-rules", Product: productAgoal,
|
||||
Description: "查询用户 Agoal 规则周期",
|
||||
Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_user_rules",
|
||||
CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules",
|
||||
},
|
||||
Description: "查询用户规则周期并可按稳定 ruleId 精确筛选",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询用户 Agoal 规则周期",
|
||||
UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"},
|
||||
AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"},
|
||||
Examples: []string{"dws agoal +user-rules --rule-id <RULE_ID>"},
|
||||
},
|
||||
Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false,
|
||||
"rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"},
|
||||
{Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("user-id") {
|
||||
params["dingUserId"] = rt.Str("user-id")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil {
|
||||
return err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象")
|
||||
}
|
||||
rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wantRule := strings.TrimSpace(rt.Str("rule-id"))
|
||||
if wantRule != "" {
|
||||
filtered := make([]map[string]any, 0, 1)
|
||||
for _, rule := range rules {
|
||||
if agoalFirstString(rule, "id") == wantRule {
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(rules), "rules": rules})
|
||||
},
|
||||
}
|
||||
|
||||
var ReportSubmitDetail = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal,
|
||||
Description: "分页查询周月报人员提交详情",
|
||||
Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_report_submit_detail",
|
||||
CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail",
|
||||
},
|
||||
Description: "分页查询周月报人员提交详情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页查询周月报人员提交详情",
|
||||
UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"},
|
||||
AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"},
|
||||
Examples: []string{"dws agoal +report-submit-detail --template-id <TEMPLATE_ID> --submit-state NOT_SUBMITTED --page 1 --page-size 20"},
|
||||
},
|
||||
Result: agoalListResult("submissions", "周月报人员提交详情", true,
|
||||
"submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true},
|
||||
{Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}},
|
||||
{Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于等于 1")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
if rt.Changed("query-date") {
|
||||
if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
wantPage, wantSize := rt.Int("page"), rt.Int("page-size")
|
||||
baseParams := map[string]any{
|
||||
"templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")),
|
||||
}
|
||||
if rt.Changed("query-date") {
|
||||
// Validate has already accepted this exact immutable Cobra value.
|
||||
queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date"))
|
||||
baseParams["queryDate"] = queryDate
|
||||
}
|
||||
keyword := strings.TrimSpace(rt.Str("keyword"))
|
||||
if keyword == "" {
|
||||
items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total})
|
||||
}
|
||||
all, err := readAllAgoalSubmissions(rt, baseParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := filterAgoalSubmissions(all, keyword)
|
||||
start := len(filtered)
|
||||
pageOffset := wantPage - 1
|
||||
if pageOffset <= len(filtered)/wantSize {
|
||||
start = pageOffset * wantSize
|
||||
}
|
||||
end := start + wantSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
items := filtered[start:end]
|
||||
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)})
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
agoalSubmissionReadPageSize = 100
|
||||
agoalSubmissionMaxPages = 20
|
||||
)
|
||||
|
||||
func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) {
|
||||
params := make(map[string]any, len(baseParams)+2)
|
||||
for key, value := range baseParams {
|
||||
params[key] = value
|
||||
}
|
||||
params["page"] = page
|
||||
params["pageSize"] = pageSize
|
||||
raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象")
|
||||
}
|
||||
items, err := requireAgoalSubmissionList(content["result"])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
gotPage, pageOK := agoalNonNegativeInt(content["page"])
|
||||
gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"])
|
||||
total, totalOK := agoalNonNegativeInt(content["totalCount"])
|
||||
if !pageOK || !sizeOK || !totalOK {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
|
||||
}
|
||||
if gotPage != page || gotSize != pageSize {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致")
|
||||
}
|
||||
if total < len(items) {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数")
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
seen := map[string]bool{}
|
||||
wantTotal := -1
|
||||
for page := 1; page <= agoalSubmissionMaxPages; page++ {
|
||||
items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wantTotal < 0 {
|
||||
wantTotal = total
|
||||
} else if total != wantTotal {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化")
|
||||
}
|
||||
for _, item := range items {
|
||||
identity := agoalSubmissionIdentity(item)
|
||||
if seen[identity] {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份")
|
||||
}
|
||||
seen[identity] = true
|
||||
all = append(all, item)
|
||||
}
|
||||
if len(all) == wantTotal {
|
||||
return all, nil
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进")
|
||||
}
|
||||
}
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限")
|
||||
}
|
||||
|
||||
func agoalSubmissionIdentity(item map[string]any) string {
|
||||
if reportID := agoalFirstString(item, "reportId"); reportID != "" {
|
||||
return "report:" + reportID
|
||||
}
|
||||
user, _ := item["user"].(map[string]any)
|
||||
return "user:" + agoalFirstString(user, "dingUserId", "id")
|
||||
}
|
||||
|
||||
func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
user, _ := item["user"].(map[string]any)
|
||||
if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
for _, key := range keys {
|
||||
value, ok := item[key].(string)
|
||||
if ok && strings.Contains(strings.ToLower(value), keyword) {
|
||||
out = append(out, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requireAgoalSubmissionList(value any) ([]map[string]any, error) {
|
||||
rawItems, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rawItems))
|
||||
for index, raw := range rawItems {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index))
|
||||
}
|
||||
user, ok := item["user"].(map[string]any)
|
||||
if !ok || agoalFirstString(user, "dingUserId", "id") == "" {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index))
|
||||
}
|
||||
projectedUser := map[string]any{}
|
||||
for _, key := range []string{"dingUserId", "id", "name", "workNo"} {
|
||||
if value, found := user[key]; found {
|
||||
projectedUser[key] = value
|
||||
}
|
||||
}
|
||||
projected := map[string]any{"user": projectedUser}
|
||||
for _, key := range []string{"reportId", "publishTime"} {
|
||||
if value, found := item[key]; found {
|
||||
projected[key] = value
|
||||
}
|
||||
}
|
||||
out = append(out, projected)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeAgoalQueryDate(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||
return parsed.Format("2006-01-02"), nil
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
location, loadErr := agoalLoadLocation("Asia/Shanghai")
|
||||
if loadErr != nil {
|
||||
location = time.FixedZone("CST", 8*60*60)
|
||||
}
|
||||
return parsed.In(location).Format("2006-01-02"), nil
|
||||
}
|
||||
return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间")
|
||||
}
|
||||
|
||||
type unavailableAgoalSpec struct {
|
||||
command string
|
||||
description string
|
||||
intent string
|
||||
tool string
|
||||
flags []shortcut.Flag
|
||||
write bool
|
||||
reason string
|
||||
}
|
||||
|
||||
func agoalStringFlag(name, description string, required bool) shortcut.Flag {
|
||||
return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required}
|
||||
}
|
||||
|
||||
func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut {
|
||||
flagNames := make([]string, 0, len(spec.flags))
|
||||
for _, flag := range spec.flags {
|
||||
flagNames = append(flagNames, flag.Name)
|
||||
}
|
||||
unavailable := func(*shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason)
|
||||
}
|
||||
return shortcut.Shortcut{
|
||||
Service: productAgoal, Command: spec.command, Product: productAgoal,
|
||||
Description: spec.description, Intent: spec.intent,
|
||||
Risk: func() shortcut.Risk {
|
||||
if spec.write {
|
||||
return shortcut.RiskHighWrite
|
||||
}
|
||||
return shortcut.RiskRead
|
||||
}(),
|
||||
Safety: agoalUnavailableSafety(spec.write),
|
||||
Flags: spec.flags,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: flagNames,
|
||||
Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足",
|
||||
}},
|
||||
SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason,
|
||||
Validate: unavailable,
|
||||
Execute: unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
var unavailableAgoalSpecs = []unavailableAgoalSpec{
|
||||
{command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{
|
||||
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
|
||||
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
|
||||
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
|
||||
{command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("profile-id", "战略解码稳定 ID", true),
|
||||
}, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
|
||||
{command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{
|
||||
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
|
||||
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
|
||||
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
|
||||
{command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("contract-id", "经营合约稳定 ID", true),
|
||||
}, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
|
||||
{command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true),
|
||||
}, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"},
|
||||
{command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true),
|
||||
}, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"},
|
||||
{command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true),
|
||||
{Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}},
|
||||
agoalStringFlag("content", "完整计分卡 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{
|
||||
agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true),
|
||||
}, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"},
|
||||
{command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true),
|
||||
{Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"},
|
||||
{Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"},
|
||||
{Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"},
|
||||
}, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"},
|
||||
}
|
||||
|
||||
func init() {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: productAgoal,
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放",
|
||||
UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"},
|
||||
AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"},
|
||||
},
|
||||
})
|
||||
items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail}
|
||||
for _, spec := range unavailableAgoalSpecs {
|
||||
items = append(items, unavailableAgoalShortcut(spec))
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].Availability != shortcut.AvailabilityUnavailable {
|
||||
items[index].OutputRollout = output.RolloutUnifiedActive
|
||||
}
|
||||
}
|
||||
shortcut.Register(items...)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{name: "int", value: 3, want: 3, ok: true},
|
||||
{name: "negative int", value: -1, want: -1},
|
||||
{name: "int64", value: int64(4), want: 4, ok: true},
|
||||
{name: "negative int64", value: int64(-1)},
|
||||
{name: "float", value: float64(5), want: 5, ok: true},
|
||||
{name: "negative float", value: float64(-1)},
|
||||
{name: "fraction", value: 1.5},
|
||||
{name: "huge float", value: math.MaxFloat64},
|
||||
{name: "json number", value: json.Number("6"), want: 6, ok: true},
|
||||
{name: "bad json number", value: json.Number("1.5")},
|
||||
{name: "negative json number", value: json.Number("-1")},
|
||||
{name: "wrong type", value: "7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := agoalNonNegativeInt(tc.value)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
items := []map[string]any{{"id": "one", "name": "Known"}}
|
||||
if got := filterAgoalItems(items, "", "name"); len(got) != 1 {
|
||||
t.Fatalf("empty item filter = %#v", got)
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, ""); len(got) != 1 {
|
||||
t.Fatalf("empty submission filter = %#v", got)
|
||||
}
|
||||
if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" {
|
||||
t.Fatalf("fallback identity = %q", got)
|
||||
}
|
||||
|
||||
for _, bad := range []any{
|
||||
nil,
|
||||
[]any{"bad"},
|
||||
[]any{map[string]any{"user": "bad"}},
|
||||
[]any{map[string]any{"user": map[string]any{"name": "missing identity"}}},
|
||||
} {
|
||||
if _, err := requireAgoalSubmissionList(bad); err == nil {
|
||||
t.Fatalf("malformed submission list accepted: %#v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) {
|
||||
if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" {
|
||||
t.Fatalf("date normalization = %q, %v", got, err)
|
||||
}
|
||||
if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" {
|
||||
t.Fatalf("RFC3339 normalization = %q, %v", got, err)
|
||||
}
|
||||
testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) {
|
||||
return nil, errors.New("fixture location failure")
|
||||
})
|
||||
if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" {
|
||||
t.Fatalf("fallback timezone normalization = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response map[string]any
|
||||
}{
|
||||
{name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"},
|
||||
{name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}},
|
||||
{name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
{name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}},
|
||||
{name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}},
|
||||
{name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"},
|
||||
{name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}},
|
||||
{name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"},
|
||||
{name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}},
|
||||
{name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
{name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}},
|
||||
{name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}},
|
||||
{name: "rules transport", declaration: UserRules, tool: "get_user_rules"},
|
||||
{name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}},
|
||||
{name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}},
|
||||
{name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"},
|
||||
{name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}},
|
||||
{name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{}
|
||||
if tc.response != nil {
|
||||
caller.responses = map[string][]map[string]any{tc.tool: {tc.response}}
|
||||
}
|
||||
if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("failure fixture was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, caller); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) {
|
||||
responses := make([]map[string]any, 0, agoalSubmissionMaxPages)
|
||||
for page := 1; page <= agoalSubmissionMaxPages; page++ {
|
||||
responses = append(responses, map[string]any{"success": true, "content": map[string]any{
|
||||
"page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1,
|
||||
"result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}},
|
||||
}})
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, caller,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
|
||||
t.Fatal("bounded pagination limit was accepted as complete")
|
||||
}
|
||||
|
||||
scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"content": map[string]any{}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
|
||||
t.Fatal("scan page validation failure was accepted")
|
||||
}
|
||||
|
||||
dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" {
|
||||
t.Fatalf("queryDate = %#v", got)
|
||||
}
|
||||
|
||||
laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing",
|
||||
"--page", strconv.Itoa(int(^uint(0)>>1))); err != nil {
|
||||
t.Fatalf("maximum page must project an empty local page without overflowing: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type agoalCoverageCall struct {
|
||||
tool string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type agoalCoverageCaller struct {
|
||||
responses map[string][]map[string]any
|
||||
calls []agoalCoverageCall
|
||||
}
|
||||
|
||||
func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params})
|
||||
queue := c.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("unexpected agoal coverage call: " + tool)
|
||||
}
|
||||
payload := queue[0]
|
||||
c.responses[tool] = queue[1:]
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
|
||||
}
|
||||
|
||||
func (*agoalCoverageCaller) Format() string { return "json" }
|
||||
func (*agoalCoverageCaller) DryRun() bool { return false }
|
||||
func (*agoalCoverageCaller) Fields() string { return "" }
|
||||
func (*agoalCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(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", "")
|
||||
service := &cobra.Command{Use: productAgoal}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{productAgoal, declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func agoalRegistered(command string) (shortcut.Shortcut, bool) {
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == productAgoal && item.Command == command {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return shortcut.Shortcut{}, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "empty", data: nil},
|
||||
{name: "missing success", data: map[string]any{"content": []any{}}},
|
||||
{name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}},
|
||||
{name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}},
|
||||
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil {
|
||||
t.Fatal("malformed response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "missing collection", value: nil},
|
||||
{name: "wrong collection", value: map[string]any{}},
|
||||
{name: "bad element", value: []any{"bad"}},
|
||||
{name: "missing id", value: []any{map[string]any{"title": "template"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil {
|
||||
t.Fatal("malformed collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) {
|
||||
t.Run("report nonempty", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report legal zero", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_report_statistics": {{"success": true, "content": []any{}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("template nonempty page", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_obj_template": {{"success": true, "content": map[string]any{
|
||||
"page": 2, "pageSize": 5, "totalCount": 6,
|
||||
"result": []any{map[string]any{"id": "template"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("template rejects false pagination", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_obj_template": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 0,
|
||||
"result": []any{map[string]any{"id": "impossible"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil {
|
||||
t.Fatal("impossible pagination total was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) {
|
||||
t.Run("contract fields known and guaranteed zero", func(t *testing.T) {
|
||||
items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}}
|
||||
if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 {
|
||||
t.Fatalf("known field projection = %#v", got)
|
||||
}
|
||||
if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 {
|
||||
t.Fatalf("zero field projection = %#v", got)
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_op_contract_fields": {{"success": true, "content": []any{items[0]}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user rules exact filter", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{
|
||||
"rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
zero := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{
|
||||
"rules": []any{map[string]any{"id": "rule-1"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report submission page and privacy identity", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
|
||||
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20,
|
||||
}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scans then filters locally", func(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}},
|
||||
{"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}},
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, "known"); len(got) != 1 {
|
||||
t.Fatalf("known submission projection = %#v", got)
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 {
|
||||
t.Fatalf("zero submission projection = %#v", got)
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2,
|
||||
"result": []any{items[0], items[1]},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
|
||||
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize,
|
||||
}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) {
|
||||
item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("duplicate submission pagination was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects changing total", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("changing pagination total was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects stalled page", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("stalled pagination was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) {
|
||||
items, err := requireAgoalSubmissionList([]any{map[string]any{
|
||||
"reportId": "report", "unreviewed": "drop",
|
||||
"user": map[string]any{"id": "user", "name": "Known", "email": "drop"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := items[0]["unreviewed"]; found {
|
||||
t.Fatal("unreviewed submission field was projected")
|
||||
}
|
||||
user := items[0]["user"].(map[string]any)
|
||||
if _, found := user["email"]; found {
|
||||
t.Fatal("unreviewed personnel field was projected")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content map[string]any
|
||||
}{
|
||||
{name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}},
|
||||
{name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}},
|
||||
{name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}},
|
||||
} {
|
||||
t.Run("report rejects "+tc.name, func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": tc.content}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil {
|
||||
t.Fatal("malformed submission page was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) {
|
||||
for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} {
|
||||
caller := &agoalCoverageCaller{}
|
||||
err := runAgoalCoverage(t, ObjectTemplateList, caller, args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("invalid args %v error = %#v", args, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"},
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"},
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"},
|
||||
} {
|
||||
caller := &agoalCoverageCaller{}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil {
|
||||
t.Fatalf("invalid submission args %v were accepted", args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) {
|
||||
total := 0
|
||||
public := 0
|
||||
unavailable := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != productAgoal {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
t.Errorf("unavailable %s must remain hidden/non-public", item.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
|
||||
}
|
||||
if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" {
|
||||
t.Errorf("public %s has incomplete Contract/Result", item.Command)
|
||||
}
|
||||
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
|
||||
t.Errorf("public %s has incomplete Safety", item.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 16 || public != 5 || unavailable != 11 {
|
||||
t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) {
|
||||
for _, spec := range unavailableAgoalSpecs {
|
||||
declaration, found := agoalRegistered(spec.command)
|
||||
if !found {
|
||||
t.Fatalf("registered unavailable command missing: %s", spec.command)
|
||||
}
|
||||
args := make([]string, 0)
|
||||
for _, flag := range declaration.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--"+flag.Name)
|
||||
switch flag.Name {
|
||||
case "scope-type":
|
||||
args = append(args, "PERSONAL")
|
||||
case "tracking-period-type":
|
||||
args = append(args, "MONTHLY")
|
||||
case "submit-state":
|
||||
args = append(args, "ON_TIME")
|
||||
default:
|
||||
if flag.Type != shortcut.FlagBool {
|
||||
args = append(args, "fixture")
|
||||
}
|
||||
}
|
||||
}
|
||||
caller := &agoalCoverageCaller{}
|
||||
err := runAgoalCoverage(t, declaration, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), spec.reason) {
|
||||
t.Errorf("%s unavailable error = %v", spec.command, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
|
||||
// Service packages — each registers its shortcuts from init().
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
|
||||
|
||||
+1164
-276
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,742 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devapp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func devAppSuccessResult(values map[string]any) map[string]any {
|
||||
return map[string]any{"success": true, "result": values}
|
||||
}
|
||||
|
||||
func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any {
|
||||
result := map[string]any{"success": true, key: items, "hasMore": more}
|
||||
if cursor != "" {
|
||||
result["nextCursor"] = cursor
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) {
|
||||
if devAppContainers(nil) != nil {
|
||||
t.Fatal("nil response unexpectedly produced containers")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{nil, false}, {false, false}, {true, true},
|
||||
{float64(0), false}, {float64(200), false}, {float64(500), true},
|
||||
{"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true},
|
||||
{map[string]any{}, false}, {map[string]any{"code": 1}, true},
|
||||
{[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true},
|
||||
} {
|
||||
if got := devAppErrorValue(tc.value); got != tc.want {
|
||||
t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := requireDevAppSuccess(map[string]any{
|
||||
"success": true, "result": map[string]any{"versionStatus": "FAILED"},
|
||||
}, "devapp/test"); err != nil {
|
||||
t.Fatalf("successful query with failed business state was rejected: %v", err)
|
||||
}
|
||||
if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) {
|
||||
t.Fatal("known envelope was not recognized")
|
||||
}
|
||||
if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) {
|
||||
t.Fatal("business field was treated as envelope-only")
|
||||
}
|
||||
if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil {
|
||||
t.Fatal("empty business object was accepted")
|
||||
}
|
||||
|
||||
object := map[string]any{"id": "actual", "name": "actual"}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil {
|
||||
t.Fatal("missing identity was accepted")
|
||||
}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil {
|
||||
t.Fatal("mismatched identity was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil {
|
||||
t.Fatal("missing readback field was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil {
|
||||
t.Fatal("mismatched readback field was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil {
|
||||
t.Fatal("missing string field was accepted")
|
||||
}
|
||||
|
||||
deep := map[string]any{"success": true, "content": map[string]any{
|
||||
"result": map[string]any{"hasMore": true, "nextCursor": " next "},
|
||||
}}
|
||||
projection, err := devAppListProjection(deep, "items", nil, "devapp/test")
|
||||
if err != nil || projection["nextCursor"] != "next" {
|
||||
t.Fatalf("deep pagination projection=%#v err=%v", projection, err)
|
||||
}
|
||||
if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil {
|
||||
t.Fatal("failed list envelope was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) {
|
||||
t.Run("list all filters and projection", func(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"unified_app_id": "app", "appName": "name", "clientId": "client",
|
||||
"agent_id": "agent", "app_status": "normal", "modified_time": "now",
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")},
|
||||
}}
|
||||
err := runDevAppCoverage(t, ListApp, caller,
|
||||
"--name", "name", "--app-key", "client", "--app-group-id", "7",
|
||||
"--creator", "owner", "--robot-name", "robot", "--develop-type", "2",
|
||||
"--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc",
|
||||
"--cursor", "cursor", "--page-size", "9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner",
|
||||
"robotName": "robot", "developType": 2, "filterCoolApp": 1,
|
||||
"sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9,
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) {
|
||||
t.Fatalf("list params=%#v, want %#v", caller.calls, want)
|
||||
}
|
||||
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, ListApp, badPage); err == nil {
|
||||
t.Fatal("list without terminal pagination evidence was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
result map[string]any
|
||||
}{
|
||||
{"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}},
|
||||
{"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}},
|
||||
{"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}},
|
||||
{"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}},
|
||||
{"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}},
|
||||
{"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}},
|
||||
{"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}},
|
||||
{"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("read transport and identity failure", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("transport failure was accepted")
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("wrong object identity was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission list", func(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"permissionCode": "scope", "permissionName": "name", "interfaceName": "api",
|
||||
"auth_status": "AUTHED", "scope_type": "APP",
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")},
|
||||
}}
|
||||
err := runDevAppCoverage(t, PermissionList, caller,
|
||||
"--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope",
|
||||
"--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := permissionListFirst(item, "missing"); ok {
|
||||
t.Fatal("missing permission field unexpectedly resolved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member list success and failures", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("member transport failure was accepted")
|
||||
}
|
||||
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_members": {{"success": true, "members": "bad"}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("malformed member list was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
key string
|
||||
item map[string]any
|
||||
}{
|
||||
{"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}},
|
||||
{"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, ok := eventListFirst(map[string]any{}, "missing"); ok {
|
||||
t.Fatal("missing event field unexpectedly resolved")
|
||||
}
|
||||
if _, ok := versionListFirst(map[string]any{}, "missing"); ok {
|
||||
t.Fatal("missing version field unexpectedly resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) {
|
||||
t.Run("create all fields and failure branches", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{
|
||||
"unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon",
|
||||
})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, CreateApp, caller,
|
||||
"--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil {
|
||||
t.Fatal("create transport failure was accepted")
|
||||
}
|
||||
for name, responses := range map[string]map[string][]map[string]any{
|
||||
"receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}},
|
||||
"resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}},
|
||||
"readback failure": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
},
|
||||
"name mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
|
||||
},
|
||||
"description mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})},
|
||||
},
|
||||
"icon mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{"--name", "created", "--yes"}
|
||||
if strings.Contains(name, "description") {
|
||||
args = append(args, "--desc", "desc")
|
||||
}
|
||||
if strings.Contains(name, "icon") {
|
||||
args = append(args, "--icon-media-id", "icon")
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil {
|
||||
t.Fatal("invalid create sequence was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update all fields and dry run", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"update_dev_app": {map[string]any{"success": true}},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{
|
||||
"unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon",
|
||||
})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, caller,
|
||||
"--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{},
|
||||
"--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
|
||||
t.Fatal("update readback failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete guards and bounded readback", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil {
|
||||
t.Fatal("delete verification without selector was accepted")
|
||||
}
|
||||
missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete without appKey was accepted")
|
||||
}
|
||||
writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete transport failure was accepted")
|
||||
}
|
||||
stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete with present readback was accepted")
|
||||
}
|
||||
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {
|
||||
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
|
||||
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
|
||||
},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete stalled pagination was accepted")
|
||||
}
|
||||
malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": "bad", "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete malformed readback was accepted")
|
||||
}
|
||||
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete pagination without termination was accepted")
|
||||
}
|
||||
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
|
||||
for page := 0; page < devAppMaxReadbackPages; page++ {
|
||||
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page))))
|
||||
}
|
||||
pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}}
|
||||
helpers.InitDeps(pageLimitCaller)
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
|
||||
t.Fatal("delete verification page limit was accepted")
|
||||
}
|
||||
helpers.InitDeps(&devAppCoverageCaller{})
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
|
||||
t.Fatal("delete verification transport failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials require client id", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("credential without client id was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("webapp all fields and failures", func(t *testing.T) {
|
||||
args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"}
|
||||
resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
"get_extension_webapp_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("webapp write failure was accepted")
|
||||
}
|
||||
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil {
|
||||
t.Fatal("webapp readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
"get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil {
|
||||
t.Fatal("webapp mismatch was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) {
|
||||
t.Run("robot validate and all fields", func(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--unified-app-id", "app", "--skills", "skill", "--yes"},
|
||||
{"--unified-app-id", "app", "--add-scope=false", "--yes"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"}
|
||||
resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("robot write failure was accepted")
|
||||
}
|
||||
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil {
|
||||
t.Fatal("robot readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil {
|
||||
t.Fatal("robot mismatch was accepted")
|
||||
}
|
||||
boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil {
|
||||
t.Fatal("robot boolean mismatch was accepted")
|
||||
}
|
||||
skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil {
|
||||
t.Fatal("robot skills mismatch was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status previews and readback failures", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("app status readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app": {map[string]any{"success": true}},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("app status mismatch was accepted")
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app_robot": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("robot status readback failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member mutations validate receipts and exact readback", func(t *testing.T) {
|
||||
member := map[string]any{"userId": "user", "memberType": "DEVELOPER"}
|
||||
add := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"add_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberAdd, add,
|
||||
"--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" ||
|
||||
!reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) ||
|
||||
add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" {
|
||||
t.Fatalf("member add calls=%#v", add.calls)
|
||||
}
|
||||
|
||||
remove := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"remove_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberRemove, remove,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" {
|
||||
t.Fatalf("member remove calls=%#v", remove.calls)
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} {
|
||||
unconfirmed := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, declaration, unconfirmed,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil {
|
||||
t.Fatalf("%s succeeded without confirmation", declaration.Command)
|
||||
}
|
||||
if len(unconfirmed.calls) != 0 {
|
||||
t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls)
|
||||
}
|
||||
dryRun := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, declaration, dryRun,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dryRun.calls) != 0 {
|
||||
t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member mutations reject invalid input and false success", func(t *testing.T) {
|
||||
for _, values := range [][]string{nil, {""}, {"user", "user"}} {
|
||||
if _, err := validatedDevAppValues(values, "--user-ids"); err == nil {
|
||||
t.Fatalf("invalid member IDs accepted: %#v", values)
|
||||
}
|
||||
}
|
||||
if _, err := validatedDevAppMemberType(" "); err == nil {
|
||||
t.Fatal("blank member type was accepted")
|
||||
}
|
||||
if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" {
|
||||
t.Fatalf("normalized member type=%q err=%v", got, err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"},
|
||||
{"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil {
|
||||
t.Fatalf("invalid member arguments accepted: %#v", args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid member arguments made calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"}
|
||||
badAddResponses := []map[string][]map[string]any{
|
||||
{},
|
||||
{"add_dev_app_members": {{"success": false}}},
|
||||
{"add_dev_app_members": {{"success": true}}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
}, false, "")}},
|
||||
}
|
||||
for index, responses := range badAddResponses {
|
||||
if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil {
|
||||
t.Fatalf("member add false success case %d was accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"remove_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil {
|
||||
t.Fatal("member remove accepted target still present")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
writeTool string
|
||||
status any
|
||||
}{
|
||||
{"enable missing status", RobotEnable, "enable_dev_app_robot", nil},
|
||||
{"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resource := map[string]any{"unifiedAppId": "app"}
|
||||
if tc.status != nil {
|
||||
resource["robotStatus"] = tc.status
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.writeTool: {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("invalid robot status was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("event validation and readback failures", func(t *testing.T) {
|
||||
for _, values := range [][]string{nil, {""}, {"event", "event"}} {
|
||||
if _, err := validatedDevAppValues(values, "--event-codes"); err == nil {
|
||||
t.Fatalf("invalid event values accepted: %#v", values)
|
||||
}
|
||||
}
|
||||
if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) {
|
||||
t.Fatalf("normalized event values=%#v err=%v", got, err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("event write failure was accepted")
|
||||
}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, listResponses := range map[string][]map[string]any{
|
||||
"transport": nil,
|
||||
"malformed": {{"success": true, "list": "bad", "hasMore": false}},
|
||||
"projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")},
|
||||
"stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": listResponses,
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("invalid event readback was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": {
|
||||
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
|
||||
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
|
||||
},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("stalled event cursor was accepted")
|
||||
}
|
||||
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
|
||||
for page := 0; page < devAppMaxReadbackPages; page++ {
|
||||
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page))))
|
||||
}
|
||||
pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": pages,
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("event readback page limit was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version create all fields and failures", func(t *testing.T) {
|
||||
args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("version write failure was accepted")
|
||||
}
|
||||
for name, responses := range map[string]map[string][]map[string]any{
|
||||
"receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}},
|
||||
"missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}},
|
||||
"readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}},
|
||||
"version mismatch": {
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})},
|
||||
},
|
||||
"description mismatch": {
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil {
|
||||
t.Fatal("invalid version create sequence was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list pagination projection failures", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
key string
|
||||
idKey string
|
||||
}{
|
||||
{PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"},
|
||||
{EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"},
|
||||
{VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatalf("%s accepted missing pagination", tc.declaration.Command)
|
||||
}
|
||||
}
|
||||
malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("malformed event collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type devAppCoverageCall struct {
|
||||
tool string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type devAppCoverageCaller struct {
|
||||
responses map[string][]map[string]any
|
||||
calls []devAppCoverageCall
|
||||
}
|
||||
|
||||
func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params})
|
||||
queue := c.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("unexpected devapp coverage call: " + tool)
|
||||
}
|
||||
payload := queue[0]
|
||||
c.responses[tool] = queue[1:]
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
|
||||
}
|
||||
|
||||
func (*devAppCoverageCaller) Format() string { return "json" }
|
||||
func (*devAppCoverageCaller) DryRun() bool { return false }
|
||||
func (*devAppCoverageCaller) Fields() string { return "" }
|
||||
func (*devAppCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(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", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
|
||||
if err := root.Execute(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func devAppRegistered(command string) (shortcut.Shortcut, bool) {
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "devapp" && item.Command == command {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return shortcut.Shortcut{}, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "empty", data: nil},
|
||||
{name: "missing success", data: map[string]any{"list": []any{}}},
|
||||
{name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}},
|
||||
{name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}},
|
||||
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := listAppProject(tc.data); err == nil {
|
||||
t.Fatal("malformed response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "missing collection", data: map[string]any{"success": true, "hasMore": false}},
|
||||
{name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}},
|
||||
{name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}},
|
||||
{name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := listAppProject(tc.data); err == nil {
|
||||
t.Fatal("malformed collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
valid := []map[string]any{{"unifiedAppId": "app"}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "missing pagination", data: map[string]any{"success": true}},
|
||||
{name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}},
|
||||
{name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}},
|
||||
{name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}},
|
||||
{name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil {
|
||||
t.Fatal("malformed pagination was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) {
|
||||
writes := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{CreateApp, []string{"--name", "app"}},
|
||||
{UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}},
|
||||
{DeleteApp, []string{"--unified-app-id", "app"}},
|
||||
{EnableApp, []string{"--unified-app-id", "app"}},
|
||||
{DisableApp, []string{"--unified-app-id", "app"}},
|
||||
{WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}},
|
||||
{RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}},
|
||||
{RobotEnable, []string{"--unified-app-id", "app"}},
|
||||
{RobotDisable, []string{"--unified-app-id", "app"}},
|
||||
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}},
|
||||
{VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}},
|
||||
}
|
||||
for _, tc := range writes {
|
||||
t.Run(tc.declaration.Command, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed command called MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{UpdateApp, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{WebappConfig, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{RobotConfig, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason == "confirmation_required" {
|
||||
t.Fatalf("invalid input error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid input called MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) {
|
||||
t.Run("credentials require stable identity client and secret", func(t *testing.T) {
|
||||
valid := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client", "appSecret": "secret",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("credential response without a secret was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) {
|
||||
declaration, ok := devAppRegistered("+credentials-get")
|
||||
if !ok || declaration.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout)
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret",
|
||||
"clientSecret": "test-client-secret", "secret": "test-secret",
|
||||
}}},
|
||||
}}
|
||||
encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(encoded, &envelope); err != nil {
|
||||
t.Fatalf("unified output is not JSON: %v", err)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unified output data type = %T, want object", envelope["data"])
|
||||
}
|
||||
if _, nested := data["result"]; nested {
|
||||
t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it")
|
||||
}
|
||||
for _, path := range declaration.Contract.Result.SensitivePaths {
|
||||
if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" {
|
||||
t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member projector supports known and guaranteed zero", func(t *testing.T) {
|
||||
members := []any{
|
||||
map[string]any{"userId": "user-1", "memberType": "OWNER"},
|
||||
map[string]any{"userId": "user-2", "memberType": "DEVELOPER"},
|
||||
}
|
||||
if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" {
|
||||
t.Fatalf("known member projection = %#v", got)
|
||||
}
|
||||
if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 {
|
||||
t.Fatalf("zero member projection = %#v", got)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"success": true, "members": []any{"bad"}},
|
||||
{"success": true, "members": []any{map[string]any{"name": "missing-id"}}},
|
||||
} {
|
||||
if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil {
|
||||
t.Fatalf("malformed member response accepted: %#v", bad)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
responses map[string][]map[string]any
|
||||
wantTools []string
|
||||
}{
|
||||
{
|
||||
name: "robot config", declaration: RobotConfig,
|
||||
args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}},
|
||||
},
|
||||
wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "robot enable", declaration: RobotEnable,
|
||||
args: []string{"--unified-app-id", "app", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"enable_dev_app_robot": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}},
|
||||
},
|
||||
wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "robot disable", declaration: RobotDisable,
|
||||
args: []string{"--unified-app-id", "app", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"disable_dev_app_robot": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}},
|
||||
},
|
||||
wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "event subscribe", declaration: EventSubscribe,
|
||||
args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {{"success": true}},
|
||||
"list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}},
|
||||
},
|
||||
wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"},
|
||||
},
|
||||
{
|
||||
name: "version create", declaration: VersionCreate,
|
||||
args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}},
|
||||
"get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}},
|
||||
},
|
||||
wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: tc.responses}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTools := make([]string, 0, len(caller.calls))
|
||||
for _, call := range caller.calls {
|
||||
gotTools = append(gotTools, call.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(gotTools, tc.wantTools) {
|
||||
t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("event false receipt is rejected by readback", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {{"success": true}},
|
||||
"list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil {
|
||||
t.Fatal("event subscription without exact readback was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []devAppCoverageCall{
|
||||
{tool: "create_dev_app", params: map[string]any{"name": "created"}},
|
||||
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update mismatch fails", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"update_dev_app": {{"success": true}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
|
||||
t.Fatal("write/readback mismatch was accepted")
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
writeTool string
|
||||
status string
|
||||
}{
|
||||
{name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"},
|
||||
{name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.writeTool: {{"success": true}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appStatus": tc.status,
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("webapp config", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {{"success": true}},
|
||||
"get_extension_webapp_config": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "homepageUrl": "https://example.invalid",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete pre-read and absence", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}},
|
||||
"delete_dev_app": {{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []devAppCoverageCall{
|
||||
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
{tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
{tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) {
|
||||
public := 0
|
||||
unavailable := 0
|
||||
total := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "devapp" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v",
|
||||
item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command))
|
||||
}
|
||||
if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" {
|
||||
t.Errorf("unavailable %s lacks concrete reason", item.Command)
|
||||
}
|
||||
if !item.Contract.Empty() {
|
||||
t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
|
||||
}
|
||||
if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" {
|
||||
t.Errorf("public %s has incomplete Contract/Result", item.Command)
|
||||
}
|
||||
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
|
||||
t.Errorf("public %s has incomplete Safety", item.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 30 || public != 25 || unavailable != 5 {
|
||||
t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) {
|
||||
for command, reason := range devAppUnavailableReasons {
|
||||
declaration, found := devAppRegistered(command)
|
||||
if !found {
|
||||
t.Fatalf("registered unavailable command missing: %s", command)
|
||||
}
|
||||
args := []string{}
|
||||
for _, flag := range declaration.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--"+flag.Name)
|
||||
if flag.Type != shortcut.FlagBool {
|
||||
args = append(args, "fixture")
|
||||
}
|
||||
}
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, declaration, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("%s unavailable error = %v", command, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}},
|
||||
{name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
|
||||
{name: "exhausted", data: map[string]any{"hasMore": false}},
|
||||
{name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}},
|
||||
{name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
|
||||
{name: "exhausted", data: map[string]any{"success": true, "hasMore": false}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := devAppListProjection(tc.data, "items", items)
|
||||
got, err := devAppListProjection(tc.data, "items", items, "devapp/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" {
|
||||
t.Fatalf("hasMore not preserved: %#v", got)
|
||||
}
|
||||
@@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
if got := devAppPaginationCandidates(nil); got != nil {
|
||||
t.Fatalf("nil candidates=%#v", got)
|
||||
}
|
||||
deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
|
||||
if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" {
|
||||
deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
|
||||
if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" {
|
||||
t.Fatalf("deep projection=%#v", got)
|
||||
}
|
||||
deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}}
|
||||
if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" {
|
||||
t.Fatalf("deeper projection=%#v", got)
|
||||
}
|
||||
cyclic := map[string]any{"success": true}
|
||||
cyclic["content"] = cyclic
|
||||
if got := devAppPaginationCandidates(cyclic); len(got) != 13 {
|
||||
t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) {
|
||||
caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
|
||||
caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{})
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -12,10 +12,16 @@ import (
|
||||
|
||||
func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
|
||||
active := map[string]bool{
|
||||
"+list": true, "+get": true, "+webapp-get": true,
|
||||
"+permission-list": true, "+event-list": true, "+version-list": true,
|
||||
"+list": true, "+get": true, "+create": true, "+update": true,
|
||||
"+delete": true, "+enable": true, "+disable": true, "+webapp-get": true,
|
||||
"+webapp-config": true,
|
||||
"+permission-list": true, "+version-list": true,
|
||||
"+robot-get": true, "+version-get": true,
|
||||
"+version-check-approval": true, "+version-status": true,
|
||||
"+credentials-get": true, "+member-list": true,
|
||||
"+member-add": true, "+member-remove": true,
|
||||
"+robot-config": true, "+robot-enable": true, "+robot-disable": true,
|
||||
"+event-list": true, "+event-subscribe": true, "+version-create": true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range shortcut.All() {
|
||||
@@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
|
||||
t.Errorf("active pilot shortcut %s is not registered", name)
|
||||
}
|
||||
}
|
||||
for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} {
|
||||
for _, paginated := range []string{"+list", "+permission-list", "+version-list"} {
|
||||
if !seen[paginated] {
|
||||
t.Errorf("paginated shortcut %s is not registered", paginated)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ package shortcut
|
||||
// command discovery and skill generation.
|
||||
func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
"agoal\u0000+contract-fields": {},
|
||||
"agoal\u0000+obj-template-list": {},
|
||||
"agoal\u0000+report-statistics-list": {},
|
||||
"agoal\u0000+report-submit-detail": {},
|
||||
"agoal\u0000+user-rules": {},
|
||||
"aisearch\u0000+search-person": {},
|
||||
"aitable\u0000+advperm-disable": {},
|
||||
"aitable\u0000+advperm-enable": {},
|
||||
@@ -254,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"contact\u0000+search-user": {},
|
||||
"contact\u0000+team": {},
|
||||
"devapp\u0000+create": {},
|
||||
"devapp\u0000+credentials-get": {},
|
||||
"devapp\u0000+delete": {},
|
||||
"devapp\u0000+disable": {},
|
||||
"devapp\u0000+enable": {},
|
||||
"devapp\u0000+event-list": {},
|
||||
"devapp\u0000+event-subscribe": {},
|
||||
"devapp\u0000+get": {},
|
||||
"devapp\u0000+list": {},
|
||||
"devapp\u0000+member-add": {},
|
||||
"devapp\u0000+member-list": {},
|
||||
"devapp\u0000+member-remove": {},
|
||||
"devapp\u0000+permission-list": {},
|
||||
"devapp\u0000+robot-config": {},
|
||||
"devapp\u0000+robot-disable": {},
|
||||
"devapp\u0000+robot-enable": {},
|
||||
"devapp\u0000+robot-get": {},
|
||||
"devapp\u0000+update": {},
|
||||
"devapp\u0000+version-check-approval": {},
|
||||
"devapp\u0000+version-create": {},
|
||||
"devapp\u0000+version-get": {},
|
||||
"devapp\u0000+version-list": {},
|
||||
"devapp\u0000+version-status": {},
|
||||
|
||||
@@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error {
|
||||
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
|
||||
}
|
||||
|
||||
// OutputForTool stores a once-fetched MCP payload while retaining the tool name
|
||||
// for product-specific outcome normalization. It is intended for composite
|
||||
// shortcuts that must validate stable identity or collection shape before the
|
||||
// shared renderer sees the response; callers must not use it to issue a second
|
||||
// business request.
|
||||
func (rt *RuntimeContext) OutputForTool(tool string, payload any) error {
|
||||
if output.UsesUnifiedResult(rt.cmd) {
|
||||
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
|
||||
}
|
||||
if output.CommandRollout(rt.cmd) == output.RolloutDualValidate {
|
||||
if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
|
||||
}
|
||||
|
||||
func (rt *RuntimeContext) storePayload(tool string, payload any) error {
|
||||
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) {
|
||||
t.Fatalf("devapp result=%s", got.Outcome())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) {
|
||||
t.Run("unified", func(t *testing.T) {
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dual validate", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
output.SetCommandRollout(cmd, output.RolloutDualValidate)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dual validate failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled })
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
output.SetCommandRollout(cmd, output.RolloutDualValidate)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("dual validation error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
output.SetCommandRollout(cmd, output.RolloutLegacyOnly)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id"`) {
|
||||
t.Fatalf("legacy output=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ var hrbrainSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_pat.json
|
||||
var patSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_devapp.json
|
||||
var devAppSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_agoal.json
|
||||
var agoalSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -113,6 +119,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
devdocSemanticCatalogJSON,
|
||||
hrbrainSemanticCatalogJSON,
|
||||
patSemanticCatalogJSON,
|
||||
devAppSemanticCatalogJSON,
|
||||
agoalSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "agoal",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+report-statistics-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+obj-template-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。","risk":"read","public":true,"reviewed":true},
|
||||
"+strategy-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+strategy-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+strategy-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-fields": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+contract-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-get": {"disposition":"schema_leaf","semantic_delta":"可访问部门与多个周期的 exact+raw 均返回无 success 的 null。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-entity-get": {"disposition":"schema_leaf","semantic_delta":"不存在双 ID 的 raw 响应为无 success 的 null,缺少安全实体 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少计分卡旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+user-rules": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+user-objectives": {"disposition":"schema_leaf","semantic_delta":"当前用户全部可见规则/周期的 exact+raw 均只为空,缺少非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+report-submit-detail": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。","risk":"read","public":true,"reviewed":true},
|
||||
"+obj-template-upsert": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;无删除或版本恢复,无法验证 upsert 终态与零残留。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "devapp",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。","risk":"read","public":true,"reviewed":true},
|
||||
"+get": {"disposition":"semantic_adapter","semantic_delta":"拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+create": {"disposition":"semantic_adapter","semantic_delta":"创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。","risk":"write","public":true,"reviewed":true},
|
||||
"+update": {"disposition":"semantic_adapter","semantic_delta":"要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+enable": {"disposition":"semantic_adapter","semantic_delta":"启用后按同一 unifiedAppId 读回并要求 appStatus=normal。","risk":"write","public":true,"reviewed":true},
|
||||
"+disable": {"disposition":"semantic_adapter","semantic_delta":"停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+credentials-get": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。","risk":"read","public":true,"reviewed":true},
|
||||
"+webapp-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+webapp-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+permission-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+permission-add": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+permission-remove": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+member-add": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-remove": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+security-config": {"disposition":"schema_leaf","semantic_delta":"整组覆盖写入缺少读取旧值、精确写回和恢复原配置的安全闭环,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+robot-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+robot-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+robot-enable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。","risk":"write","public":true,"reviewed":true},
|
||||
"+robot-disable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+event-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+event-subscribe": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+event-unsubscribe": {"disposition":"schema_leaf","semantic_delta":"缺少可恢复的稳定订阅 fixture 与逐项退订读回,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+version-create": {"disposition":"semantic_adapter","semantic_delta":"创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。","risk":"write","public":true,"reviewed":true},
|
||||
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-check-approval": {"disposition":"semantic_adapter","semantic_delta":"只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-publish": {"disposition":"schema_leaf","semantic_delta":"可能进入审批或线上生效,缺少安全测试租户与回滚合同,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+version-status": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,36 @@ func openSupplementServers() []ServerInfo {
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff",
|
||||
Prefixes: []string{"recruit", "job"},
|
||||
},
|
||||
{
|
||||
ID: "edu-contact",
|
||||
Name: "家校通讯录",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2",
|
||||
Prefixes: []string{"edu-contact", "edu"},
|
||||
},
|
||||
{
|
||||
ID: "edu-group",
|
||||
Name: "家校群",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a",
|
||||
Prefixes: []string{"edu-group"},
|
||||
},
|
||||
{
|
||||
ID: "edu-app",
|
||||
Name: "家校应用",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e",
|
||||
Prefixes: []string{"edu-app"},
|
||||
},
|
||||
{
|
||||
ID: "edu-familygroup",
|
||||
Name: "家庭群",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944",
|
||||
Prefixes: []string{"edu-familygroup"},
|
||||
},
|
||||
{
|
||||
ID: "college-contact",
|
||||
Name: "高校通讯录",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474",
|
||||
Prefixes: []string{"college-contact"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,3 +137,43 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
|
||||
t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints(t *testing.T) {
|
||||
servers := openSupplementServers()
|
||||
byID := make(map[string]ServerInfo, len(servers))
|
||||
for _, s := range servers {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
|
||||
edu := []struct {
|
||||
id string
|
||||
endpoint string
|
||||
prefixes []string
|
||||
}{
|
||||
{"edu-contact", "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2", []string{"edu-contact", "edu"}},
|
||||
{"edu-group", "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a", []string{"edu-group"}},
|
||||
{"edu-app", "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e", []string{"edu-app"}},
|
||||
{"edu-familygroup", "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944", []string{"edu-familygroup"}},
|
||||
{"college-contact", "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474", []string{"college-contact"}},
|
||||
}
|
||||
|
||||
for _, want := range edu {
|
||||
got, ok := byID[want.id]
|
||||
if !ok {
|
||||
t.Errorf("openSupplementServers() missing edu endpoint %q", want.id)
|
||||
continue
|
||||
}
|
||||
if got.Endpoint != want.endpoint {
|
||||
t.Errorf("%s endpoint = %q, want %q", want.id, got.Endpoint, want.endpoint)
|
||||
}
|
||||
if len(got.Prefixes) != len(want.prefixes) {
|
||||
t.Errorf("%s prefixes length = %d, want %d", want.id, len(got.Prefixes), len(want.prefixes))
|
||||
continue
|
||||
}
|
||||
for i, p := range want.prefixes {
|
||||
if got.Prefixes[i] != p {
|
||||
t.Errorf("%s prefixes[%d] = %q, want %q", want.id, i, got.Prefixes[i], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ usage() {
|
||||
# Single source of truth for the partition set. CI runs one job per partition and
|
||||
# pins its shard names to this list, so a name that appears here without a
|
||||
# dispatch entry below fails closed rather than silently skipping tests.
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-o c-p-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
|
||||
mode="${1:-}"
|
||||
partition=""
|
||||
@@ -67,7 +67,8 @@ fi
|
||||
schema_count=0
|
||||
ab_count=0
|
||||
cal_count=0
|
||||
cmr_count=0
|
||||
cmo_count=0
|
||||
cpr_count=0
|
||||
csz_count=0
|
||||
cother_count=0
|
||||
dr_count=0
|
||||
@@ -79,7 +80,8 @@ while IFS= read -r test_name; do
|
||||
Test*Schema*) schema_count=$((schema_count + 1)) ;;
|
||||
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-O]*) cmo_count=$((cmo_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[P-R]*) cpr_count=$((cpr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
|
||||
TestC*) cother_count=$((cother_count + 1)) ;;
|
||||
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
|
||||
@@ -103,7 +105,8 @@ for spec in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c-a-l:$cal_count" \
|
||||
"c-m-r:$cmr_count" \
|
||||
"c-m-o:$cmo_count" \
|
||||
"c-p-r:$cpr_count" \
|
||||
"c-s-z:$csz_count" \
|
||||
"c-other:$cother_count" \
|
||||
"d-r:$dr_count" \
|
||||
@@ -142,14 +145,14 @@ for name in $classified; do
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmo_count + cpr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-o=%s c-p-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmo_count" "$cpr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
|
||||
if [ "$mode" = "verify" ]; then
|
||||
exit 0
|
||||
@@ -208,7 +211,8 @@ run_named_partition() {
|
||||
schema) run_partition schema no-race "$schema_pattern" ;;
|
||||
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
|
||||
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
|
||||
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
|
||||
c-m-o) run_partition c-m-o race '^TestCrossPlatformCoverage[M-O]' "$schema_pattern" ;;
|
||||
c-p-r) run_partition c-p-r race '^TestCrossPlatformCoverage[P-R]' "$schema_pattern" ;;
|
||||
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
|
||||
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
|
||||
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
|
||||
|
||||
+798
@@ -0,0 +1,798 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Opt-in, real-data release evidence gate for the Dev -> DevApp -> Agoal
|
||||
# Shortcut delivery group. The current worktree binary supplies both exact
|
||||
# `+shortcut` leaves and their owning atomic command trees. Raw responses and
|
||||
# stable IDs stay in a repository-external temporary directory; stdout contains
|
||||
# only PII-free PASS labels and aggregate facts.
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
dws_bin=${DWS_SHORTCUT_GATE_BIN:-"$repo_root/dws"}
|
||||
selected_product=${1:-all}
|
||||
|
||||
case "$selected_product" in
|
||||
all | dev | devapp | agoal) ;;
|
||||
*)
|
||||
echo "usage: $0 [all|dev|devapp|agoal]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${DWS_SHORTCUT_LIVE_GATE:-} != 1 ]]; then
|
||||
echo "set DWS_SHORTCUT_LIVE_GATE=1 to authorize the real-data dual-layer gate" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
if [[ ! -t 0 ]]; then
|
||||
echo "the DevApp live gate requires an interactive terminal for explicit write and cleanup confirmation" >&2
|
||||
exit 2
|
||||
fi
|
||||
read -r -p "This gate creates, changes, and deletes isolated DevApp fixtures. Type RUN to authorize the writes and cleanup: " live_confirmation
|
||||
if [[ $live_confirmation != RUN ]]; then
|
||||
echo "live gate authorization declined" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if [[ ! -x $dws_bin ]]; then
|
||||
echo "current worktree binary is missing; run make build first" >&2
|
||||
exit 2
|
||||
fi
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "jq is required" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
member_user_id=""
|
||||
member_type=${DWS_DEVAPP_E2E_MEMBER_TYPE:-DEVELOPER}
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
member_user_id_file=${DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE:-}
|
||||
if [[ -z $member_user_id_file || ! -f $member_user_id_file ]]; then
|
||||
echo "set DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE to a private file containing one safe test-account userId" >&2
|
||||
exit 2
|
||||
fi
|
||||
IFS= read -r member_user_id <"$member_user_id_file" || true
|
||||
[[ -n $member_user_id ]] || {
|
||||
echo "the private DevApp member fixture is empty" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -n $member_type ]] || {
|
||||
echo "DWS_DEVAPP_E2E_MEMBER_TYPE must not be empty" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
evidence_dir=$(mktemp -d "${TMPDIR:-/tmp}/dws-shortcut-dual-live.XXXXXX")
|
||||
exact_app_id=""
|
||||
exact_app_name=""
|
||||
raw_app_id=""
|
||||
raw_app_name=""
|
||||
|
||||
fail() {
|
||||
echo "FAIL $1 (secure response retained only until trap cleanup)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_json() {
|
||||
local label=$1
|
||||
local output=$2
|
||||
shift 2
|
||||
if ! "$@" --format json >"$output" 2>"$output.stderr"; then
|
||||
fail "$label"
|
||||
fi
|
||||
jq -e . "$output" >/dev/null 2>&1 || fail "$label: non-JSON response"
|
||||
}
|
||||
|
||||
assert_jq() {
|
||||
local label=$1
|
||||
local expression=$2
|
||||
shift 2
|
||||
jq -e "$expression" "$@" >/dev/null 2>&1 || fail "$label"
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'PASS %s\n' "$1"
|
||||
}
|
||||
|
||||
assert_public_surface() {
|
||||
local product=$1
|
||||
local expected=$2
|
||||
local actual
|
||||
actual=$(jq -r --arg product "$product" '.results[] | select(.service == $product) | .command' "$repo_root/docs/shortcut-public-catalog.json" | LC_ALL=C sort)
|
||||
expected=$(printf '%s\n' "$expected" | sed '/^$/d' | LC_ALL=C sort)
|
||||
[[ $actual == "$expected" ]] || fail "$product public catalog differs from the exhaustive live matrix"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
if [[ -n $exact_app_id ]]; then
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes --format json >/dev/null 2>&1
|
||||
fi
|
||||
if [[ -n $raw_app_id ]]; then
|
||||
"$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes --format json >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf "$evidence_dir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
run_dev_gate() {
|
||||
# Dev owns routing/documentation only and registers no Shortcut leaves.
|
||||
assert_public_surface dev ""
|
||||
pass "DEV-INVENTORY public=0 fully-unlocked=0 hardened-blocked=0 routed=4 platform-unavailable=1"
|
||||
}
|
||||
|
||||
expect_confirmation() {
|
||||
local label=$1
|
||||
local output=$2
|
||||
shift 2
|
||||
local status=0
|
||||
# The outer gate intentionally requires a TTY for one explicit RUN
|
||||
# authorization. The command under test must not inherit that TTY here:
|
||||
# otherwise its own confirmation prompt blocks instead of returning the
|
||||
# machine-readable confirmation_required result that proves zero writes.
|
||||
"$@" --format json </dev/null >"$output" 2>"$output.stderr" || status=$?
|
||||
[[ $status -ne 0 ]] || fail "$label: unconfirmed command unexpectedly succeeded"
|
||||
jq -e '.ok == false and .error.type == "validation" and .error.subtype == "confirmation_required"' "$output" >/dev/null 2>&1 || fail "$label: missing confirmation_required"
|
||||
}
|
||||
|
||||
run_devapp_gate() {
|
||||
assert_public_surface devapp "+create
|
||||
+credentials-get
|
||||
+delete
|
||||
+disable
|
||||
+enable
|
||||
+event-list
|
||||
+event-subscribe
|
||||
+get
|
||||
+list
|
||||
+member-add
|
||||
+member-list
|
||||
+member-remove
|
||||
+permission-list
|
||||
+robot-config
|
||||
+robot-disable
|
||||
+robot-enable
|
||||
+robot-get
|
||||
+update
|
||||
+version-check-approval
|
||||
+version-create
|
||||
+version-get
|
||||
+version-list
|
||||
+version-status
|
||||
+webapp-config
|
||||
+webapp-get"
|
||||
local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A"
|
||||
local suffix="$(date +%s)-$$"
|
||||
exact_app_name="DWS-E2E-EXACT-$suffix"
|
||||
raw_app_name="DWS-E2E-RAW-$suffix"
|
||||
local exact_updated_name="DWS-E2E-UPDATED-$suffix"
|
||||
local raw_updated_name="DWS-E2E-RAW-UPDATED-$suffix"
|
||||
local bot_raw="DWS-E2E-BOT-RAW-$suffix"
|
||||
local bot_exact="DWS-E2E-BOT-EXACT-$suffix"
|
||||
local event_raw="bpms_task_change"
|
||||
local event_exact="$event_raw"
|
||||
|
||||
run_json "devapp create guard before exact query" "$evidence_dir/devapp-create-guard-before-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp create guard before raw query" "$evidence_dir/devapp-create-guard-before-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
expect_confirmation "devapp +create confirmation" "$evidence_dir/devapp-create-confirm.json" \
|
||||
"$dws_bin" devapp +create --name "$exact_app_name"
|
||||
run_json "devapp create guard after exact query" "$evidence_dir/devapp-create-guard-after-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp create guard after raw query" "$evidence_dir/devapp-create-guard-after-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
assert_jq "devapp +create confirmation zero-call state" '
|
||||
(input) as $br | (input) as $ae | (input) as $ar |
|
||||
.data.count == 0 and ($br.data.items | length) == 0 and
|
||||
$ae.data.count == 0 and ($ar.data.items | length) == 0
|
||||
' "$evidence_dir/devapp-create-guard-before-exact.json" "$evidence_dir/devapp-create-guard-before-raw.json" \
|
||||
"$evidence_dir/devapp-create-guard-after-exact.json" "$evidence_dir/devapp-create-guard-after-raw.json"
|
||||
|
||||
run_json "devapp exact create" "$evidence_dir/devapp-create-exact.json" \
|
||||
"$dws_bin" devapp +create --name "$exact_app_name" --desc "dual-live-exact" --yes
|
||||
exact_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-exact.json") || fail "devapp exact create stable ID"
|
||||
assert_jq "devapp exact create terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "create"' "$evidence_dir/devapp-create-exact.json"
|
||||
run_json "devapp exact create raw readback" "$evidence_dir/devapp-create-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$exact_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-exact-raw-readback.json" >/dev/null || fail "devapp exact create raw readback"
|
||||
|
||||
run_json "devapp raw create" "$evidence_dir/devapp-create-raw.json" \
|
||||
"$dws_bin" dev app create --name "$raw_app_name" --desc "dual-live-raw" --yes
|
||||
raw_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-raw.json") || fail "devapp raw create stable ID"
|
||||
assert_jq "devapp raw create terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-create-raw.json"
|
||||
run_json "devapp raw create exact readback" "$evidence_dir/devapp-create-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$raw_app_id"
|
||||
jq -e --arg id "$raw_app_id" --arg name "$raw_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-raw-exact-readback.json" >/dev/null || fail "devapp raw create exact readback"
|
||||
pass "DEVAPP +create confirm0/exact+raw terminal-receipt/stable-ID readback"
|
||||
|
||||
run_json "devapp guard snapshot app" "$evidence_dir/devapp-guard-app-before.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot webapp" "$evidence_dir/devapp-guard-webapp-before.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot robot" "$evidence_dir/devapp-guard-robot-before.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot events" "$evidence_dir/devapp-guard-events-before.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp guard snapshot versions" "$evidence_dir/devapp-guard-versions-before.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
|
||||
expect_confirmation "devapp +update confirmation" "$evidence_dir/devapp-update-confirm.json" \
|
||||
"$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "guard-must-not-write"
|
||||
expect_confirmation "devapp +delete confirmation" "$evidence_dir/devapp-delete-confirm.json" \
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +enable confirmation" "$evidence_dir/devapp-enable-confirm.json" \
|
||||
"$dws_bin" devapp +enable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +disable confirmation" "$evidence_dir/devapp-disable-confirm.json" \
|
||||
"$dws_bin" devapp +disable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +webapp-config confirmation" "$evidence_dir/devapp-webapp-confirm.json" \
|
||||
"$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/guard"
|
||||
expect_confirmation "devapp +robot-config confirmation" "$evidence_dir/devapp-robot-config-confirm.json" \
|
||||
"$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "guard-must-not-write" --mode STREAM
|
||||
expect_confirmation "devapp +robot-enable confirmation" "$evidence_dir/devapp-robot-enable-confirm.json" \
|
||||
"$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +robot-disable confirmation" "$evidence_dir/devapp-robot-disable-confirm.json" \
|
||||
"$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +event-subscribe confirmation" "$evidence_dir/devapp-event-confirm.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact"
|
||||
expect_confirmation "devapp +version-create confirmation" "$evidence_dir/devapp-version-create-confirm.json" \
|
||||
"$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "guard-must-not-write"
|
||||
|
||||
run_json "devapp guard snapshot app after" "$evidence_dir/devapp-guard-app-after.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot webapp after" "$evidence_dir/devapp-guard-webapp-after.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot robot after" "$evidence_dir/devapp-guard-robot-after.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot events after" "$evidence_dir/devapp-guard-events-after.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp guard snapshot versions after" "$evidence_dir/devapp-guard-versions-after.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
assert_jq "devapp all public write confirmations preserve raw state" '
|
||||
(input) as $aa | (input) as $wb | (input) as $wa | (input) as $rb | (input) as $ra |
|
||||
(input) as $eb | (input) as $ea | (input) as $vb | (input) as $va |
|
||||
[.data.unifiedAppId,.data.name,.data.desc,.data.appStatus] == [$aa.data.unifiedAppId,$aa.data.name,$aa.data.desc,$aa.data.appStatus] and
|
||||
$wb.data == $wa.data and $rb.data == $ra.data and
|
||||
([$eb.data.events[].eventCode] | sort) == ([$ea.data.events[].eventCode] | sort) and
|
||||
([$vb.data.items[].versionId] | sort) == ([$va.data.items[].versionId] | sort)
|
||||
' "$evidence_dir/devapp-guard-app-before.json" "$evidence_dir/devapp-guard-app-after.json" \
|
||||
"$evidence_dir/devapp-guard-webapp-before.json" "$evidence_dir/devapp-guard-webapp-after.json" \
|
||||
"$evidence_dir/devapp-guard-robot-before.json" "$evidence_dir/devapp-guard-robot-after.json" \
|
||||
"$evidence_dir/devapp-guard-events-before.json" "$evidence_dir/devapp-guard-events-after.json" \
|
||||
"$evidence_dir/devapp-guard-versions-before.json" "$evidence_dir/devapp-guard-versions-after.json"
|
||||
|
||||
run_json "devapp exact list known" "$evidence_dir/devapp-list-known-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp raw list known" "$evidence_dir/devapp-list-known-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp exact list zero" "$evidence_dir/devapp-list-zero-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$nonce" --page-size 20
|
||||
run_json "devapp raw list zero" "$evidence_dir/devapp-list-zero-raw.json" \
|
||||
"$dws_bin" dev app list --name "$nonce" --page-size 20
|
||||
jq -e --arg id "$exact_app_id" '
|
||||
(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and .data.apps[0].unifiedAppId == $id and
|
||||
$raw.ok == true and $raw.outcome == "success" and ($raw.data.items | length) == 1 and $raw.data.items[0].unifiedAppId == $id and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.apps | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.items | length) == 0 and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-list-known-exact.json" "$evidence_dir/devapp-list-known-raw.json" \
|
||||
"$evidence_dir/devapp-list-zero-exact.json" "$evidence_dir/devapp-list-zero-raw.json" >/dev/null || fail "devapp +list dual known/zero alignment"
|
||||
pass "DEVAPP +list exact+raw known-nonempty/guaranteed-zero stable-ID/cursor termination"
|
||||
|
||||
run_json "devapp exact get" "$evidence_dir/devapp-get-exact.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw get" "$evidence_dir/devapp-get-raw.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .ok == true and .data.unifiedAppId == $id and $raw.ok == true and $raw.data.unifiedAppId == $id and .data.appKey == $raw.data.appKey' "$evidence_dir/devapp-get-exact.json" "$evidence_dir/devapp-get-raw.json" >/dev/null || fail "devapp +get dual object read"
|
||||
pass "DEVAPP +get exact+raw stable-application-ID object alignment"
|
||||
|
||||
run_json "devapp raw update" "$evidence_dir/devapp-update-raw.json" \
|
||||
"$dws_bin" dev app update --unified-app-id "$exact_app_id" --name "$raw_updated_name" --desc "raw-update" --yes
|
||||
assert_jq "devapp raw update terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-update-raw.json"
|
||||
run_json "devapp raw update exact readback" "$evidence_dir/devapp-update-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$raw_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "raw-update"' "$evidence_dir/devapp-update-raw-exact-readback.json" >/dev/null || fail "devapp raw update exact readback"
|
||||
run_json "devapp exact update" "$evidence_dir/devapp-update-exact.json" \
|
||||
"$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "$exact_updated_name" --desc "exact-update" --yes
|
||||
assert_jq "devapp exact update terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "update"' "$evidence_dir/devapp-update-exact.json"
|
||||
run_json "devapp exact update raw readback" "$evidence_dir/devapp-update-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$exact_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "exact-update"' "$evidence_dir/devapp-update-exact-raw-readback.json" >/dev/null || fail "devapp exact update raw readback"
|
||||
pass "DEVAPP +update confirm0/exact+raw terminal-receipt/key-field readback"
|
||||
|
||||
run_json "devapp raw disable" "$evidence_dir/devapp-disable-raw.json" \
|
||||
"$dws_bin" dev app disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp raw disable exact readback" "$evidence_dir/devapp-disable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-disable-raw.json"
|
||||
assert_jq "devapp raw disable exact state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-raw-exact-readback.json"
|
||||
run_json "devapp exact enable" "$evidence_dir/devapp-enable-exact.json" \
|
||||
"$dws_bin" devapp +enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp exact enable raw readback" "$evidence_dir/devapp-enable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-enable-exact.json"
|
||||
assert_jq "devapp exact enable raw state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-exact-raw-readback.json"
|
||||
run_json "devapp exact disable" "$evidence_dir/devapp-disable-exact.json" \
|
||||
"$dws_bin" devapp +disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp exact disable raw readback" "$evidence_dir/devapp-disable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-disable-exact.json"
|
||||
assert_jq "devapp exact disable raw state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-exact-raw-readback.json"
|
||||
run_json "devapp raw enable" "$evidence_dir/devapp-enable-raw.json" \
|
||||
"$dws_bin" dev app enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp raw enable exact readback" "$evidence_dir/devapp-enable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-enable-raw.json"
|
||||
assert_jq "devapp raw enable exact state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-raw-exact-readback.json"
|
||||
pass "DEVAPP +disable confirm0/exact+raw terminal-receipt/disabled readback/restored"
|
||||
pass "DEVAPP +enable confirm0/exact+raw terminal-receipt/normal readback"
|
||||
|
||||
run_json "devapp exact credentials" "$evidence_dir/devapp-credentials-exact.json" \
|
||||
"$dws_bin" devapp +credentials-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw credentials" "$evidence_dir/devapp-credentials-raw.json" \
|
||||
"$dws_bin" dev app credentials get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.unifiedAppId == $id and
|
||||
(.data.appKey | type) == "string" and (.data.appKey | length) > 0 and
|
||||
(.data.appSecret | type) == "string" and (.data.appSecret | length) > 0 and
|
||||
$raw.ok == true and $raw.data.unifiedAppId == $id and
|
||||
.data.appKey == $raw.data.appKey and .data.appSecret == $raw.data.appSecret
|
||||
' "$evidence_dir/devapp-credentials-exact.json" "$evidence_dir/devapp-credentials-raw.json" >/dev/null || fail "devapp +credentials-get dual object read"
|
||||
pass "DEVAPP +credentials-get exact+raw stable-app/client/one-nonempty-secret alignment"
|
||||
|
||||
run_json "devapp raw webapp config" "$evidence_dir/devapp-webapp-config-raw.json" \
|
||||
"$dws_bin" dev app webapp config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/raw" --yes
|
||||
run_json "devapp raw webapp exact readback" "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw webapp terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-webapp-config-raw.json"
|
||||
assert_jq "devapp raw webapp exact readback" '.data.homepageUrl == "https://example.invalid/raw"' "$evidence_dir/devapp-webapp-config-raw-exact-readback.json"
|
||||
run_json "devapp exact webapp config" "$evidence_dir/devapp-webapp-config-exact.json" \
|
||||
"$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/exact" --yes
|
||||
run_json "devapp exact webapp raw readback" "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact webapp terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-webapp-config-exact.json"
|
||||
assert_jq "devapp exact webapp raw readback" '.data.homepageUrl == "https://example.invalid/exact"' "$evidence_dir/devapp-webapp-config-exact-raw-readback.json"
|
||||
pass "DEVAPP +webapp-config confirm0/exact+raw terminal-receipt/URL readback"
|
||||
run_json "devapp exact webapp get final" "$evidence_dir/devapp-webapp-get-exact.json" \
|
||||
"$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw webapp get final" "$evidence_dir/devapp-webapp-get-raw.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.homepageUrl == $raw.data.homepageUrl' "$evidence_dir/devapp-webapp-get-exact.json" "$evidence_dir/devapp-webapp-get-raw.json" >/dev/null || fail "devapp +webapp-get dual object read"
|
||||
pass "DEVAPP +webapp-get exact+raw stable-app/key-field object alignment"
|
||||
|
||||
run_json "devapp member raw known" "$evidence_dir/devapp-member-known-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
run_json "devapp member exact known" "$evidence_dir/devapp-member-known-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id"
|
||||
run_json "devapp member exact zero" "$evidence_dir/devapp-member-zero-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$nonce"
|
||||
jq -e --arg nonce "$nonce" '
|
||||
(input) as $raw | (input) as $zero |
|
||||
.ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.members | type) == "array" and
|
||||
([.data.members[].userId] | sort) == ([$raw.data.members[].userId] | sort) and
|
||||
$zero.ok == true and $zero.data.count == 0 and ($zero.data.members | length) == 0 and
|
||||
([$raw.data.members[].userId] | index($nonce)) == null
|
||||
' "$evidence_dir/devapp-member-known-exact.json" "$evidence_dir/devapp-member-known-raw.json" "$evidence_dir/devapp-member-zero-exact.json" >/dev/null || fail "devapp +member-list adapter alignment"
|
||||
pass "DEVAPP +member-list exact local-ID-filter+raw full-set known/guaranteed-zero stable-member alignment"
|
||||
|
||||
run_json "devapp member fixture exact before" "$evidence_dir/devapp-member-fixture-before-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
run_json "devapp member fixture raw before" "$evidence_dir/devapp-member-fixture-before-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-fixture-before-exact.json" "$evidence_dir/devapp-member-fixture-before-raw.json" >/dev/null || fail "devapp member fixture initially absent"
|
||||
|
||||
expect_confirmation "devapp +member-add confirmation" "$evidence_dir/devapp-member-add-confirm.json" \
|
||||
"$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type"
|
||||
expect_confirmation "devapp +member-remove confirmation" "$evidence_dir/devapp-member-remove-confirm.json" \
|
||||
"$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type"
|
||||
run_json "devapp member fixture exact after guards" "$evidence_dir/devapp-member-fixture-after-guards-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
run_json "devapp member fixture raw after guards" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-fixture-after-guards-exact.json" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" >/dev/null || fail "devapp member confirmations made no writes"
|
||||
|
||||
run_json "devapp member exact add" "$evidence_dir/devapp-member-add-exact.json" \
|
||||
"$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member exact add raw readback" "$evidence_dir/devapp-member-add-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg type "$member_type" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_add" and .data.resource.count == 1 and .data.resource.memberType == $type' \
|
||||
"$evidence_dir/devapp-member-add-exact.json" >/dev/null || fail "devapp member exact add terminal"
|
||||
jq -e --arg user "$member_user_id" --arg type "$member_type" '([.data.members[] | select(.userId == $user and .memberType == $type)] | length) == 1' \
|
||||
"$evidence_dir/devapp-member-add-exact-raw-readback.json" >/dev/null || fail "devapp member exact add raw stable-ID role readback"
|
||||
|
||||
run_json "devapp member raw remove" "$evidence_dir/devapp-member-remove-raw.json" \
|
||||
"$dws_bin" dev app member remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member raw remove exact readback" "$evidence_dir/devapp-member-remove-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
assert_jq "devapp member raw remove terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-remove-raw.json"
|
||||
assert_jq "devapp member raw remove exact absence" '.ok == true and .data.count == 0 and (.data.members | length) == 0' "$evidence_dir/devapp-member-remove-raw-exact-readback.json"
|
||||
|
||||
run_json "devapp member raw add" "$evidence_dir/devapp-member-add-raw.json" \
|
||||
"$dws_bin" dev app member add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member raw add exact readback" "$evidence_dir/devapp-member-add-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
assert_jq "devapp member raw add terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-add-raw.json"
|
||||
jq -e --arg user "$member_user_id" --arg type "$member_type" \
|
||||
'.ok == true and .data.count == 1 and .data.members[0].userId == $user and .data.members[0].memberType == $type' \
|
||||
"$evidence_dir/devapp-member-add-raw-exact-readback.json" >/dev/null || fail "devapp member raw add exact stable-ID role readback"
|
||||
|
||||
run_json "devapp member exact remove" "$evidence_dir/devapp-member-remove-exact.json" \
|
||||
"$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member exact remove raw readback" "$evidence_dir/devapp-member-remove-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp member exact remove terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_remove"' \
|
||||
"$evidence_dir/devapp-member-remove-exact.json"
|
||||
jq -e --arg user "$member_user_id" '([.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-remove-exact-raw-readback.json" >/dev/null || fail "devapp member exact remove raw absence"
|
||||
pass "DEVAPP +member-add/+member-remove confirm0/exact+raw terminal-receipt/stable-ID role/absence readback"
|
||||
|
||||
run_json "devapp permission exact known" "$evidence_dir/devapp-permission-known-exact.json" \
|
||||
"$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --page-size 20
|
||||
run_json "devapp permission raw known" "$evidence_dir/devapp-permission-known-raw.json" \
|
||||
"$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --page-size 20
|
||||
run_json "devapp permission exact zero" "$evidence_dir/devapp-permission-zero-exact.json" \
|
||||
"$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20
|
||||
run_json "devapp permission raw zero" "$evidence_dir/devapp-permission-zero-raw.json" \
|
||||
"$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20
|
||||
assert_jq "devapp permission dual known/zero alignment" '
|
||||
(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.items | length) > 0 and
|
||||
([.data.permissions[].scopeValue] | sort) == ([$raw.data.items[].scopeValue] | sort) and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.permissions | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.items | length) == 0 and
|
||||
.meta.pagination.endpoint_exhausted == $raw.meta.pagination.endpoint_exhausted and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-permission-known-exact.json" "$evidence_dir/devapp-permission-known-raw.json" \
|
||||
"$evidence_dir/devapp-permission-zero-exact.json" "$evidence_dir/devapp-permission-zero-raw.json"
|
||||
pass "DEVAPP +permission-list exact+raw known-nonempty/guaranteed-zero stable-scope/cursor alignment"
|
||||
|
||||
run_json "devapp robot exact initial" "$evidence_dir/devapp-robot-get-exact.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp robot raw initial" "$evidence_dir/devapp-robot-get-raw.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.robotStatus == $raw.data.robotStatus' "$evidence_dir/devapp-robot-get-exact.json" "$evidence_dir/devapp-robot-get-raw.json" >/dev/null || fail "devapp +robot-get initial dual object"
|
||||
pass "DEVAPP +robot-get exact+raw stable-app/status object alignment"
|
||||
|
||||
run_json "devapp robot raw config" "$evidence_dir/devapp-robot-config-raw.json" \
|
||||
"$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --desc "dual-live-raw" --mode STREAM --add-scope --yes
|
||||
run_json "devapp robot raw config exact readback" "$evidence_dir/devapp-robot-config-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw config terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-config-raw.json"
|
||||
jq -e --arg name "$bot_raw" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-raw-exact-readback.json" >/dev/null || fail "devapp robot raw config exact readback"
|
||||
run_json "devapp robot exact config" "$evidence_dir/devapp-robot-config-exact.json" \
|
||||
"$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "$bot_exact" --brief "dual-live-exact" --desc "dual-live-exact" --mode STREAM --yes
|
||||
run_json "devapp robot exact config raw readback" "$evidence_dir/devapp-robot-config-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact config terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-config-exact.json"
|
||||
jq -e --arg name "$bot_exact" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-exact-raw-readback.json" >/dev/null || fail "devapp robot exact config raw readback"
|
||||
pass "DEVAPP +robot-config confirm0/exact+raw terminal-receipt/config-field readback"
|
||||
|
||||
run_json "devapp robot raw enable" "$evidence_dir/devapp-robot-enable-raw.json" \
|
||||
"$dws_bin" dev app robot enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot raw enable exact readback" "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-enable-raw.json"
|
||||
assert_jq "devapp robot raw enable exact state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-raw-exact-readback.json"
|
||||
run_json "devapp robot exact disable" "$evidence_dir/devapp-robot-disable-exact.json" \
|
||||
"$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot exact disable raw readback" "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-disable-exact.json"
|
||||
assert_jq "devapp robot exact disable raw state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-exact-raw-readback.json"
|
||||
run_json "devapp robot raw reconfigure" "$evidence_dir/devapp-robot-reconfigure-raw.json" \
|
||||
"$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --mode STREAM --yes
|
||||
run_json "devapp robot exact enable" "$evidence_dir/devapp-robot-enable-exact.json" \
|
||||
"$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot exact enable raw readback" "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-enable-exact.json"
|
||||
assert_jq "devapp robot exact enable raw state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-exact-raw-readback.json"
|
||||
run_json "devapp robot raw disable" "$evidence_dir/devapp-robot-disable-raw.json" \
|
||||
"$dws_bin" dev app robot disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot raw disable exact readback" "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-disable-raw.json"
|
||||
assert_jq "devapp robot raw disable exact state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-raw-exact-readback.json"
|
||||
pass "DEVAPP +robot-enable confirm0/exact+raw terminal-receipt/ONLINE readback"
|
||||
pass "DEVAPP +robot-disable confirm0/exact+raw terminal-receipt/UNCONFIGURED readback"
|
||||
|
||||
run_json "devapp event raw subscribe" "$evidence_dir/devapp-event-subscribe-raw.json" \
|
||||
"$dws_bin" dev app event subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes
|
||||
run_json "devapp event raw subscribe exact replay" "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes
|
||||
run_json "devapp event raw subscribe raw readback" "$evidence_dir/devapp-event-subscribe-raw-readback.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_raw" --page-size 100
|
||||
assert_jq "devapp event raw subscribe terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-event-subscribe-raw.json"
|
||||
assert_jq "devapp event raw subscribe exact replay" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json"
|
||||
jq -e --arg code "$event_raw" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-raw-readback.json" >/dev/null || fail "devapp event raw subscribe readback"
|
||||
run_json "devapp event exact subscribe" "$evidence_dir/devapp-event-subscribe-exact.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" --yes
|
||||
run_json "devapp event exact subscribe raw readback" "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
assert_jq "devapp event exact subscribe terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-exact.json"
|
||||
jq -e --arg code "$event_exact" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" >/dev/null || fail "devapp event exact subscribe raw readback"
|
||||
pass "DEVAPP +event-subscribe confirm0/exact+raw terminal-receipt/eventCode readback"
|
||||
|
||||
run_json "devapp event exact known" "$evidence_dir/devapp-event-list-known-exact.json" \
|
||||
"$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
run_json "devapp event raw known" "$evidence_dir/devapp-event-list-known-raw.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
run_json "devapp event exact zero" "$evidence_dir/devapp-event-list-zero-exact.json" \
|
||||
"$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100
|
||||
run_json "devapp event raw zero" "$evidence_dir/devapp-event-list-zero-raw.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100
|
||||
jq -e --arg code "$event_exact" '(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .data.count == 1 and .data.events[0].eventCode == $code and
|
||||
$raw.ok == true and ($raw.data.events | length) == 1 and $raw.data.events[0].eventCode == $code and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.events | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.events | length) == 0 and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true' \
|
||||
"$evidence_dir/devapp-event-list-known-exact.json" "$evidence_dir/devapp-event-list-known-raw.json" \
|
||||
"$evidence_dir/devapp-event-list-zero-exact.json" "$evidence_dir/devapp-event-list-zero-raw.json" >/dev/null || fail "devapp +event-list dual known/zero alignment"
|
||||
pass "DEVAPP +event-list exact+raw known-nonempty/guaranteed-zero stable-event/cursor alignment"
|
||||
|
||||
run_json "devapp version exact zero before" "$evidence_dir/devapp-version-zero-exact.json" \
|
||||
"$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp version raw zero before" "$evidence_dir/devapp-version-zero-raw.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
assert_jq "devapp version guaranteed zero fixture" '(input) as $raw | .data.count == 0 and (.data.versions | length) == 0 and ($raw.data.items | length) == 0 and .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true' "$evidence_dir/devapp-version-zero-exact.json" "$evidence_dir/devapp-version-zero-raw.json"
|
||||
run_json "devapp version raw create" "$evidence_dir/devapp-version-create-raw.json" \
|
||||
"$dws_bin" dev app version create --unified-app-id "$exact_app_id" --desc "dual-live-raw" --yes
|
||||
local raw_version_id
|
||||
raw_version_id=$(jq -er '.data.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-raw.json") || fail "devapp raw version create stable ID"
|
||||
assert_jq "devapp raw version create terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-version-create-raw.json"
|
||||
run_json "devapp raw version exact readback" "$evidence_dir/devapp-version-create-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$raw_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$raw_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-raw-exact-readback.json" >/dev/null || fail "devapp raw version exact readback"
|
||||
run_json "devapp version exact create" "$evidence_dir/devapp-version-create-exact.json" \
|
||||
"$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "dual-live-exact" --yes
|
||||
local exact_version_id
|
||||
exact_version_id=$(jq -er '.data.resource.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-exact.json") || fail "devapp exact version create stable ID"
|
||||
assert_jq "devapp exact version create terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-version-create-exact.json"
|
||||
run_json "devapp exact version raw readback" "$evidence_dir/devapp-version-create-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-exact-raw-readback.json" >/dev/null || fail "devapp exact version raw readback"
|
||||
pass "DEVAPP +version-create confirm0/exact+raw terminal-receipt/app+version-ID readback"
|
||||
|
||||
local version_aligned=false
|
||||
for _ in 1 2 3 4 5; do
|
||||
run_json "devapp version exact known" "$evidence_dir/devapp-version-known-exact.json" \
|
||||
"$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp version raw known" "$evidence_dir/devapp-version-known-raw.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
if jq -e --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" '
|
||||
(input) as $raw |
|
||||
.data.count >= 1 and
|
||||
([.data.versions[].versionId] | index($raw_version)) != null and
|
||||
([.data.versions[].versionId] | index($exact_version)) != null and
|
||||
([.data.versions[].versionId] | sort) == ([$raw.data.items[].versionId] | sort) and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-version-known-exact.json" "$evidence_dir/devapp-version-known-raw.json" >/dev/null 2>&1; then
|
||||
version_aligned=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ $version_aligned != true ]]; then
|
||||
jq -n -c --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" \
|
||||
--slurpfile exact "$evidence_dir/devapp-version-known-exact.json" \
|
||||
--slurpfile raw "$evidence_dir/devapp-version-known-raw.json" '{
|
||||
diagnostic:"DEVAPP-VERSION-LIST",
|
||||
exact_count:$exact[0].data.count,
|
||||
exact_items:($exact[0].data.versions|length),
|
||||
raw_items:($raw[0].data.items|length),
|
||||
ids_equal:(([$exact[0].data.versions[].versionId]|sort)==([$raw[0].data.items[].versionId]|sort)),
|
||||
raw_version_visible:(([$exact[0].data.versions[].versionId]|index($raw_version))!=null),
|
||||
exact_version_visible:(([$exact[0].data.versions[].versionId]|index($exact_version))!=null),
|
||||
exact_pagination_type:($exact[0].meta.pagination|type),
|
||||
raw_pagination_type:($raw[0].meta.pagination|type),
|
||||
exact_exhausted:$exact[0].meta.pagination.endpoint_exhausted,
|
||||
raw_exhausted:$raw[0].meta.pagination.endpoint_exhausted
|
||||
}' >&2
|
||||
fail "devapp version known alignment"
|
||||
fi
|
||||
pass "DEVAPP +version-list exact+raw known-nonempty/empty-fixture-zero stable-version/cursor alignment"
|
||||
|
||||
run_json "devapp version exact get" "$evidence_dir/devapp-version-get-exact.json" \
|
||||
"$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw get" "$evidence_dir/devapp-version-get-raw.json" \
|
||||
"$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | .data.unifiedAppId == $app and .data.versionId == $version and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-get-exact.json" "$evidence_dir/devapp-version-get-raw.json" >/dev/null || fail "devapp +version-get dual object"
|
||||
pass "DEVAPP +version-get exact+raw stable-app+version-ID object alignment"
|
||||
|
||||
run_json "devapp version exact approval" "$evidence_dir/devapp-version-approval-exact.json" \
|
||||
"$dws_bin" devapp +version-check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw approval" "$evidence_dir/devapp-version-approval-raw.json" \
|
||||
"$dws_bin" dev app version check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-approval-exact.json" "$evidence_dir/devapp-version-approval-raw.json" >/dev/null || fail "devapp +version-check-approval dual object"
|
||||
pass "DEVAPP +version-check-approval exact+raw stable-app+version-ID pending/terminal alignment"
|
||||
|
||||
run_json "devapp version exact status" "$evidence_dir/devapp-version-status-exact.json" \
|
||||
"$dws_bin" devapp +version-status --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw status" "$evidence_dir/devapp-version-status-raw.json" \
|
||||
"$dws_bin" dev app version status --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-status-exact.json" "$evidence_dir/devapp-version-status-raw.json" >/dev/null || fail "devapp +version-status dual object"
|
||||
pass "DEVAPP +version-status exact+raw stable-app+version-ID status alignment"
|
||||
|
||||
run_json "devapp raw delete" "$evidence_dir/devapp-delete-raw.json" \
|
||||
"$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes
|
||||
assert_jq "devapp raw delete terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-delete-raw.json"
|
||||
run_json "devapp raw delete exact absence" "$evidence_dir/devapp-delete-raw-exact-absence.json" \
|
||||
"$dws_bin" devapp +list --name "$raw_app_name" --page-size 20
|
||||
run_json "devapp raw delete raw absence" "$evidence_dir/devapp-delete-raw-raw-absence.json" \
|
||||
"$dws_bin" dev app list --name "$raw_app_name" --page-size 20
|
||||
assert_jq "devapp raw delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-raw-exact-absence.json" "$evidence_dir/devapp-delete-raw-raw-absence.json"
|
||||
raw_app_id=""
|
||||
|
||||
run_json "devapp exact delete" "$evidence_dir/devapp-delete-exact.json" \
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes
|
||||
assert_jq "devapp exact delete terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "delete"' "$evidence_dir/devapp-delete-exact.json"
|
||||
run_json "devapp exact delete exact absence" "$evidence_dir/devapp-delete-exact-exact-absence.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_updated_name" --page-size 20
|
||||
run_json "devapp exact delete raw absence" "$evidence_dir/devapp-delete-exact-raw-absence.json" \
|
||||
"$dws_bin" dev app list --name "$exact_updated_name" --page-size 20
|
||||
assert_jq "devapp exact delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-exact-exact-absence.json" "$evidence_dir/devapp-delete-exact-raw-absence.json"
|
||||
exact_app_id=""
|
||||
pass "DEVAPP +delete confirm0/exact+raw terminal-receipt/dual absence cleanup"
|
||||
|
||||
pass "DEVAPP-INVENTORY public=25 dual-layer-pass=25 unavailable=5 cleanup=zero-residual"
|
||||
}
|
||||
|
||||
run_agoal_gate() {
|
||||
assert_public_surface agoal "+contract-fields
|
||||
+obj-template-list
|
||||
+report-statistics-list
|
||||
+report-submit-detail
|
||||
+user-rules"
|
||||
local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A"
|
||||
local future_date="2099-12-31"
|
||||
|
||||
run_json "agoal report statistics exact known" "$evidence_dir/agoal-report-known-exact.json" \
|
||||
"$dws_bin" agoal +report-statistics-list
|
||||
run_json "agoal report statistics raw known" "$evidence_dir/agoal-report-known-raw.json" \
|
||||
"$dws_bin" agoal report list-statistics
|
||||
assert_jq "agoal report statistics known alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and
|
||||
(.data.count == ($raw.content | length)) and
|
||||
([.data.statistics[].templateId] | sort) == ([$raw.content[].templateId] | sort)
|
||||
' "$evidence_dir/agoal-report-known-exact.json" "$evidence_dir/agoal-report-known-raw.json"
|
||||
run_json "agoal report statistics exact zero" "$evidence_dir/agoal-report-zero-exact.json" \
|
||||
"$dws_bin" agoal +report-statistics-list --keyword "$nonce"
|
||||
run_json "agoal report statistics raw zero" "$evidence_dir/agoal-report-zero-raw.json" \
|
||||
"$dws_bin" agoal report list-statistics --keyword "$nonce"
|
||||
assert_jq "agoal report statistics zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and
|
||||
(.data.statistics | type) == "array" and (.data.statistics | length) == 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) == 0
|
||||
' "$evidence_dir/agoal-report-zero-exact.json" "$evidence_dir/agoal-report-zero-raw.json"
|
||||
pass "AGOAL +report-statistics-list exact+raw known-nonempty/guaranteed-zero stable-template alignment"
|
||||
|
||||
run_json "agoal template exact known" "$evidence_dir/agoal-template-known-exact.json" \
|
||||
"$dws_bin" agoal +obj-template-list --page 1 --page-size 20
|
||||
run_json "agoal template raw known" "$evidence_dir/agoal-template-known-raw.json" \
|
||||
"$dws_bin" agoal obj-template list --page 1 --page-size 20
|
||||
assert_jq "agoal template known alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and
|
||||
.data.totalCount == $raw.content.totalCount and
|
||||
([.data.templates[] | (.id // .templateId)] | sort) ==
|
||||
([$raw.content.result[] | (.id // .templateId)] | sort)
|
||||
' "$evidence_dir/agoal-template-known-exact.json" "$evidence_dir/agoal-template-known-raw.json"
|
||||
run_json "agoal template exact zero" "$evidence_dir/agoal-template-zero-exact.json" \
|
||||
"$dws_bin" agoal +obj-template-list --keyword "$nonce" --page 1 --page-size 20
|
||||
run_json "agoal template raw zero" "$evidence_dir/agoal-template-zero-raw.json" \
|
||||
"$dws_bin" agoal obj-template list --keyword "$nonce" --page 1 --page-size 20
|
||||
assert_jq "agoal template zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and
|
||||
(.data.templates | type) == "array" and (.data.templates | length) == 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize
|
||||
' "$evidence_dir/agoal-template-zero-exact.json" "$evidence_dir/agoal-template-zero-raw.json"
|
||||
pass "AGOAL +obj-template-list exact+raw known-nonempty/guaranteed-zero stable-ID/page alignment"
|
||||
|
||||
run_json "agoal contract fields raw" "$evidence_dir/agoal-fields-raw.json" \
|
||||
"$dws_bin" agoal contract fields
|
||||
local field_id
|
||||
field_id=$(jq -er '.content[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-fields-raw.json") || fail "agoal contract fields raw fixture"
|
||||
run_json "agoal contract fields exact known" "$evidence_dir/agoal-fields-known-exact.json" \
|
||||
"$dws_bin" agoal +contract-fields --keyword "$field_id"
|
||||
run_json "agoal contract fields exact zero" "$evidence_dir/agoal-fields-zero-exact.json" \
|
||||
"$dws_bin" agoal +contract-fields --keyword "$nonce"
|
||||
jq -e --arg field_id "$field_id" --arg nonce "$nonce" '
|
||||
(input) as $zero | (input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and
|
||||
.data.fields[0].id == $field_id and
|
||||
$zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and
|
||||
($zero.data.fields | length) == 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) > 0 and
|
||||
([$raw.content[].id] | index($field_id)) != null and
|
||||
([$raw.content[] | [.id,.code,.title,.category,.type] | map(select(type == "string")) | join(" ") | ascii_downcase] |
|
||||
map(contains($nonce | ascii_downcase)) | any) == false
|
||||
' "$evidence_dir/agoal-fields-known-exact.json" "$evidence_dir/agoal-fields-zero-exact.json" "$evidence_dir/agoal-fields-raw.json" >/dev/null 2>&1 || fail "agoal contract fields adapter alignment"
|
||||
pass "AGOAL +contract-fields exact local-filter+raw full-set known/guaranteed-zero stable-ID alignment"
|
||||
|
||||
run_json "agoal user rules raw" "$evidence_dir/agoal-rules-raw.json" \
|
||||
"$dws_bin" agoal user rules
|
||||
local rule_id
|
||||
rule_id=$(jq -er '.content.rules[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-rules-raw.json") || fail "agoal user rules raw fixture"
|
||||
run_json "agoal user rules exact known" "$evidence_dir/agoal-rules-known-exact.json" \
|
||||
"$dws_bin" agoal +user-rules --rule-id "$rule_id"
|
||||
run_json "agoal user rules exact zero" "$evidence_dir/agoal-rules-zero-exact.json" \
|
||||
"$dws_bin" agoal +user-rules --rule-id "$nonce"
|
||||
jq -e --arg rule_id "$rule_id" --arg nonce "$nonce" '
|
||||
(input) as $zero | (input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and .data.rules[0].id == $rule_id and
|
||||
$zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and
|
||||
($zero.data.rules | length) == 0 and
|
||||
$raw.success == true and ($raw.content.rules | type) == "array" and ($raw.content.rules | length) > 0 and
|
||||
([$raw.content.rules[].id] | index($rule_id)) != null and
|
||||
([$raw.content.rules[].id] | index($nonce)) == null
|
||||
' "$evidence_dir/agoal-rules-known-exact.json" "$evidence_dir/agoal-rules-zero-exact.json" "$evidence_dir/agoal-rules-raw.json" >/dev/null 2>&1 || fail "agoal user rules adapter alignment"
|
||||
pass "AGOAL +user-rules exact local-filter+raw full-set known/guaranteed-zero stable-rule alignment"
|
||||
|
||||
local template_id=""
|
||||
local submit_state=""
|
||||
while IFS= read -r candidate_template; do
|
||||
for candidate_state in ON_TIME LATE NOT_SUBMITTED; do
|
||||
run_json "agoal submission raw fixture scan" "$evidence_dir/agoal-submit-scan.json" \
|
||||
"$dws_bin" agoal report submit-detail --template-id "$candidate_template" --submit-state "$candidate_state" --page 1 --page-size 20 --timeout 60
|
||||
if jq -e '.success == true and (.content.result | type) == "array" and (.content.result | length) > 0' "$evidence_dir/agoal-submit-scan.json" >/dev/null; then
|
||||
template_id=$candidate_template
|
||||
submit_state=$candidate_state
|
||||
cp "$evidence_dir/agoal-submit-scan.json" "$evidence_dir/agoal-submit-known-raw.json"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done < <(jq -r '.content[].templateId' "$evidence_dir/agoal-report-known-raw.json")
|
||||
[[ -n $template_id && -n $submit_state ]] || fail "agoal submission known-nonempty fixture"
|
||||
run_json "agoal submission exact known" "$evidence_dir/agoal-submit-known-exact.json" \
|
||||
"$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state "$submit_state" --page 1 --page-size 20 --timeout 60
|
||||
assert_jq "agoal submission known alignment" '
|
||||
(input) as $raw |
|
||||
def stable_id: (.reportId // .user.dingUserId // .user.id);
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and
|
||||
.data.totalCount == $raw.content.totalCount and
|
||||
([.data.submissions[] | stable_id] | sort) == ([$raw.content.result[] | stable_id] | sort)
|
||||
' "$evidence_dir/agoal-submit-known-exact.json" "$evidence_dir/agoal-submit-known-raw.json"
|
||||
run_json "agoal submission exact guaranteed zero" "$evidence_dir/agoal-submit-zero-exact.json" \
|
||||
"$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60
|
||||
run_json "agoal submission raw guaranteed zero" "$evidence_dir/agoal-submit-zero-raw.json" \
|
||||
"$dws_bin" agoal report submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60
|
||||
assert_jq "agoal submission guaranteed zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and
|
||||
(.data.submissions | type) == "array" and (.data.submissions | length) == 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize
|
||||
' "$evidence_dir/agoal-submit-zero-exact.json" "$evidence_dir/agoal-submit-zero-raw.json"
|
||||
pass "AGOAL +report-submit-detail exact+raw known-nonempty/future guaranteed-zero stable-user/page alignment"
|
||||
|
||||
pass "AGOAL-INVENTORY public=5 dual-layer-pass=5 unavailable=11"
|
||||
}
|
||||
|
||||
if [[ $selected_product == all || $selected_product == dev ]]; then
|
||||
run_dev_gate
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
# Added below after the Agoal proof block so product execution remains
|
||||
# Dev -> DevApp -> Agoal when `all` is selected.
|
||||
run_devapp_gate
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == agoal ]]; then
|
||||
run_agoal_gate
|
||||
fi
|
||||
@@ -40,6 +40,8 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devapp.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_agoal.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,17 +19,172 @@ trap cleanup EXIT HUP INT TERM
|
||||
|
||||
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
|
||||
|
||||
normalize_notes() {
|
||||
awk '
|
||||
/^[[:space:]]*$/ && !started { next }
|
||||
{ started = 1; lines[++count] = $0 }
|
||||
END {
|
||||
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
|
||||
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
extract_release_notes() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
'
|
||||
}
|
||||
|
||||
release_heading() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }'
|
||||
}
|
||||
|
||||
changelog_without_release_notes() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " '
|
||||
index($0, heading) == 1 { print; skipped = 1; next }
|
||||
skipped && /^## / { skipped = 0 }
|
||||
!skipped { print }
|
||||
'
|
||||
}
|
||||
|
||||
# A pre-tag beta amendment must preserve the original seal verbatim except for
|
||||
# appending newly archived fragment bodies to their existing categories. These
|
||||
# helpers canonicalize the renderer's category format before comparing it.
|
||||
canonicalize_fragment_notes() {
|
||||
awk '
|
||||
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
|
||||
function trim(value) {
|
||||
sub(/^[\n]+/, "", value)
|
||||
sub(/[\n]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
function flush() {
|
||||
if (category == "") return
|
||||
value = trim(body)
|
||||
if (value == "" || seen[category]++) invalid = 1
|
||||
blocks[category] = value
|
||||
category = ""
|
||||
body = ""
|
||||
}
|
||||
/^### / {
|
||||
flush()
|
||||
candidate = substr($0, 5)
|
||||
if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) {
|
||||
invalid = 1
|
||||
next
|
||||
}
|
||||
category = candidate
|
||||
next
|
||||
}
|
||||
{
|
||||
if (category == "") {
|
||||
if ($0 !~ /^[[:space:]]*$/) invalid = 1
|
||||
next
|
||||
}
|
||||
body = body $0 "\n"
|
||||
}
|
||||
END {
|
||||
flush()
|
||||
if (invalid) exit 1
|
||||
for (i = 1; i <= 6; i++) {
|
||||
category = order[i]
|
||||
if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category]
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
merge_canonical_fragment_notes() {
|
||||
awk '
|
||||
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
|
||||
function trim(value) {
|
||||
sub(/^[\n]+/, "", value)
|
||||
sub(/[\n]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
function flush() {
|
||||
if (category == "") return
|
||||
value = trim(body)
|
||||
if (value == "") invalid = 1
|
||||
if (part == 1) before[category] = value
|
||||
else if (part == 2) addition[category] = value
|
||||
else invalid = 1
|
||||
category = ""
|
||||
body = ""
|
||||
}
|
||||
FNR == 1 {
|
||||
if (started) flush()
|
||||
started = 1
|
||||
part++
|
||||
}
|
||||
/^### / {
|
||||
flush()
|
||||
category = substr($0, 5)
|
||||
if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1
|
||||
next
|
||||
}
|
||||
{ body = body $0 "\n" }
|
||||
END {
|
||||
flush()
|
||||
if (invalid || part != 2) exit 1
|
||||
for (i = 1; i <= 6; i++) {
|
||||
category = order[i]
|
||||
if (!(category in before) && !(category in addition)) continue
|
||||
printf "### %s\n\n", category
|
||||
if (category in before) printf "%s\n\n", before[category]
|
||||
if (category in addition) printf "%s\n\n", addition[category]
|
||||
}
|
||||
}
|
||||
' "$1" "$2"
|
||||
}
|
||||
|
||||
archive_changed=false
|
||||
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
|
||||
archive_changed=true
|
||||
fi
|
||||
|
||||
if [ "$archive_changed" = true ]; then
|
||||
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
|
||||
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
|
||||
exit 1
|
||||
}
|
||||
new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
|
||||
new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||
archival_mode=seal
|
||||
if [ "$new_release_version_count" -eq 1 ]; then
|
||||
release_version="$new_release_version"
|
||||
else
|
||||
# An amendment keeps the existing heading. Derive the one allowed target
|
||||
# from the literal archive directory, then verify every move below.
|
||||
release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)"
|
||||
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || {
|
||||
printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2
|
||||
exit 1
|
||||
}
|
||||
base_heading="$(release_heading "$merge_base" "$release_version")"
|
||||
head_heading="$(release_heading "$head" "$release_version")"
|
||||
[ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || {
|
||||
printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2
|
||||
exit 1
|
||||
}
|
||||
changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment"
|
||||
changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment"
|
||||
if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then
|
||||
printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2
|
||||
exit 1
|
||||
fi
|
||||
if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then
|
||||
printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
archival_mode=beta-amendment
|
||||
fi
|
||||
# The archive directory is matched as a literal prefix, never as a regex:
|
||||
# interpolating the version into one would make `.` match any character, so
|
||||
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
|
||||
@@ -47,7 +202,7 @@ if [ "$archive_changed" = true ]; then
|
||||
{ invalid = 1 }
|
||||
END { exit !(changelog && moved > 0 && !invalid) }
|
||||
' "$tmp_root/status"; then
|
||||
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
|
||||
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal or untagged beta-amendment PR' >&2
|
||||
exit 1
|
||||
fi
|
||||
source_changes="$tmp_root/source-changes"
|
||||
@@ -63,17 +218,25 @@ if [ "$archive_changed" = true ]; then
|
||||
esac
|
||||
done
|
||||
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
|
||||
git -C "$ROOT" show "$head:CHANGELOG.md" |
|
||||
awk -v heading="## [$release_version] - " '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' >"$tmp_root/actual-section"
|
||||
case "$release_version" in
|
||||
*-beta.*)
|
||||
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
|
||||
;;
|
||||
*)
|
||||
if [ "$archival_mode" = beta-amendment ]; then
|
||||
extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes"
|
||||
extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes"
|
||||
if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" ||
|
||||
! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" ||
|
||||
! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" ||
|
||||
! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then
|
||||
printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2
|
||||
exit 1
|
||||
fi
|
||||
normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized"
|
||||
else
|
||||
extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section"
|
||||
case "$release_version" in
|
||||
*-beta.*)
|
||||
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
|
||||
;;
|
||||
*)
|
||||
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
|
||||
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
|
||||
@@ -88,22 +251,13 @@ if [ "$archive_changed" = true ]; then
|
||||
found { print }
|
||||
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
|
||||
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
|
||||
;;
|
||||
esac
|
||||
normalize_notes() {
|
||||
awk '
|
||||
/^[[:space:]]*$/ && !started { next }
|
||||
{ started = 1; lines[++count] = $0 }
|
||||
END {
|
||||
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
|
||||
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
|
||||
;;
|
||||
esac
|
||||
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
|
||||
fi
|
||||
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
|
||||
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
|
||||
printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2
|
||||
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -86,6 +86,54 @@
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command."
|
||||
},
|
||||
{
|
||||
"kind": "schema_availability_hardening",
|
||||
"legacy": {
|
||||
"command": "dws devapp +event-list",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "devapp",
|
||||
"source_tool_id": "devapp.shortcut_event_list",
|
||||
"parameters": [],
|
||||
"availability": {"before": "available", "after": "unavailable"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Fail closed until event-list proves authoritative terminal pagination for both non-empty and zero-result live fixtures."
|
||||
},
|
||||
{
|
||||
"kind": "schema_availability_hardening",
|
||||
"legacy": {
|
||||
"command": "dws devapp +member-add",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "devapp",
|
||||
"source_tool_id": "devapp.shortcut_member_add",
|
||||
"parameters": [],
|
||||
"availability": {"before": "available", "after": "unavailable"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Fail closed until a safe second-member fixture proves exact role readback and rollback for member addition."
|
||||
},
|
||||
{
|
||||
"kind": "schema_availability_hardening",
|
||||
"legacy": {
|
||||
"command": "dws devapp +member-remove",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "devapp",
|
||||
"source_tool_id": "devapp.shortcut_member_remove",
|
||||
"parameters": [],
|
||||
"availability": {"before": "available", "after": "unavailable"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Fail closed until a safe second-member fixture proves exact removal readback and restoration."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,79 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSchemaAvailabilityMigrationNormalizesOnlyAvailability(t *testing.T) {
|
||||
baseline := baselineContract()
|
||||
current := cloneContract(baseline)
|
||||
mutateTool(¤t, func(tool *toolSchema) { tool.Availability = "unavailable" })
|
||||
migration := interfacesnapshot.CommandMigration{
|
||||
Kind: interfacesnapshot.CommandMigrationAvailability,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws doc create",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "doc",
|
||||
SourceToolID: "doc.create",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{},
|
||||
Availability: &interfacesnapshot.CommandAvailabilityChange{Before: "available", After: "unavailable"},
|
||||
},
|
||||
State: interfacesnapshot.CommandMigrationPending,
|
||||
Reason: "Reviewed fail-closed availability hardening.",
|
||||
}
|
||||
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "unavailable" {
|
||||
t.Fatalf("normalized availability = %q", got)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("availability hardening failures = %v", failures)
|
||||
}
|
||||
|
||||
unrelated := cloneContract(current)
|
||||
mutateTool(&unrelated, func(tool *toolSchema) { tool.Risk = "high" })
|
||||
normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, []interfacesnapshot.CommandMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, "changed risk") {
|
||||
t.Fatalf("unrelated risk drift was hidden: %q", failures)
|
||||
}
|
||||
|
||||
alreadyAfter := cloneContract(current)
|
||||
if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, []interfacesnapshot.CommandMigration{migration}); err != nil {
|
||||
t.Fatalf("consumed merge-base availability should remain inert: %v", err)
|
||||
}
|
||||
wrongPath := cloneContract(current)
|
||||
mutateTool(&wrongPath, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc other" })
|
||||
normalized, err = normalizeSchemaCommandMigrations(baseline, wrongPath, []interfacesnapshot.CommandMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "available" {
|
||||
t.Fatalf("wrong-path migration changed availability to %q", got)
|
||||
}
|
||||
wrong := cloneContract(current)
|
||||
mutateTool(&wrong, func(tool *toolSchema) { tool.Availability = "available" })
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, wrong, []interfacesnapshot.CommandMigration{migration}); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match Schema availability") {
|
||||
t.Fatalf("wrong availability error = %v", err)
|
||||
}
|
||||
|
||||
compatibilityVisible := migration
|
||||
compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{compatibilityVisible}); err != nil {
|
||||
t.Fatalf("compatibility-visible availability hardening must authorize the exact Schema transition: %v", err)
|
||||
}
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, baseline, []interfacesnapshot.CommandMigration{compatibilityVisible}); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match Schema availability") {
|
||||
t.Fatalf("compatibility-visible receipt must not authorize unchanged Schema availability: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
|
||||
@@ -815,6 +815,12 @@ var reviewedConstraintTransition = map[string]map[string]string{
|
||||
"doc/doc.shortcut_import": {
|
||||
`{"require_one_of":[["folder","workspace"]]}`: "",
|
||||
},
|
||||
// PR #1105 adds local --file as an alternative to the historically required
|
||||
// --src input. Every historical --src invocation remains valid; publishing
|
||||
// both groups makes the final Schema express the runtime's exact-one rule.
|
||||
"sheet/sheet.create_float_image": {
|
||||
"": `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}`,
|
||||
},
|
||||
}
|
||||
|
||||
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
|
||||
@@ -1869,6 +1875,24 @@ func normalizeSchemaCommandMigrations(
|
||||
normalizedProduct := normalized.Products[migration.Schema.ProductID]
|
||||
normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
|
||||
switch migration.Kind {
|
||||
case interfacesnapshot.CommandMigrationAvailability:
|
||||
change := migration.Schema.Availability
|
||||
if change != nil && oldTool.Availability == change.After && newSource.Availability == change.After {
|
||||
continue
|
||||
}
|
||||
if change == nil || oldTool.Availability != change.Before || newSource.Availability != change.After {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved availability hardening %q does not match Schema availability %q -> %q",
|
||||
migration.Legacy.Command,
|
||||
oldTool.Availability,
|
||||
newSource.Availability,
|
||||
)
|
||||
}
|
||||
if newSource.PrimaryCLIPath != legacyPath {
|
||||
continue
|
||||
}
|
||||
normalizedTool.Availability = newSource.Availability
|
||||
|
||||
case interfacesnapshot.CommandMigrationMove:
|
||||
if newSource.PrimaryCLIPath != replacementPath {
|
||||
continue
|
||||
|
||||
@@ -992,6 +992,36 @@ func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testin
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sheetToolPath = "sheet/sheet.create_float_image"
|
||||
const sheetTarget = `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}`
|
||||
sheetOldTool := toolSchema{}
|
||||
sheetNewTool := sheetOldTool
|
||||
sheetNewTool.Constraints = sheetTarget
|
||||
|
||||
if !compatibleReviewedConstraintTransition(sheetToolPath, sheetOldTool, sheetNewTool) {
|
||||
t.Fatal("reviewed float-image local-file transition must be accepted")
|
||||
}
|
||||
if failures := checkToolCompatibility(sheetToolPath, sheetOldTool, sheetNewTool); len(failures) != 0 {
|
||||
t.Fatalf("reviewed float-image local-file transition failed: %v", failures)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{name: "float image unlisted tool", path: "sheet/sheet.other", new: sheetTarget},
|
||||
{name: "float image unlisted source", path: sheetToolPath, old: `{"require_one_of":[["src"]]}`, new: sheetTarget},
|
||||
{name: "float image unlisted target", path: sheetToolPath, new: `{"require_one_of":[["file","src"]]}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) {
|
||||
t.Fatal("unreviewed float-image constraint transition unexpectedly passed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing a property through the reviewed mapping exclusion table is the one
|
||||
|
||||
@@ -112,6 +112,26 @@ coverage:
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/recruit.md
|
||||
- mono: edu-app
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-app.md
|
||||
- mono: edu-contact
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-contact.md
|
||||
- mono: edu-group
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-group.md
|
||||
- mono: college-contact
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/college-contact.md
|
||||
- mono: edu-familygroup
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-familygroup.md
|
||||
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
|
||||
@@ -43,13 +43,14 @@ cli_version: ">=1.0.15"
|
||||
|
||||
| 服务 | shortcut 数 | multi skill |
|
||||
|---|---:|---|
|
||||
| `agoal` | 5 | `—` |
|
||||
| `aisearch` | 1 | `—` |
|
||||
| `aitable` | 100 | `dingtalk-aitable` |
|
||||
| `attendance` | 8 | `dingtalk-misc` |
|
||||
| `calendar` | 27 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `contact` | 13 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-misc` |
|
||||
| `devapp` | 25 | `dingtalk-misc` |
|
||||
| `ding` | 1 | `dingtalk-misc` |
|
||||
| `doc` | 45 | `dingtalk-doc` |
|
||||
| `drive` | 28 | `dingtalk-drive` |
|
||||
@@ -98,6 +99,11 @@ cli_version: ">=1.0.15"
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
|
||||
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
|
||||
| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) |
|
||||
| `edu-app` | 家校应用(教育版):家校任务、班级消息摘要等教育场景应用能力 | [edu-app.md](./references/products/edu-app.md) |
|
||||
| `edu-contact` | 家校通讯录(教育版):学校组织架构、班级列表等教育场景通讯录能力 | [edu-contact.md](./references/products/edu-contact.md) |
|
||||
| `edu-group` | 家校群(师生群):班级群/师生群的查询、创建、解散 | [edu-group.md](./references/products/edu-group.md) |
|
||||
| `edu-familygroup` | 家庭群:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制 | [edu-familygroup.md](./references/products/edu-familygroup.md) |
|
||||
| `college-contact` | 高校通讯录:高校组织架构/院系部门管理(查询/创建/更新/删除)/师生员工管理(查询/添加/移除/变更类型与部门/激活短信)/通讯录搜索/概览统计/升级 | [college-contact.md](./references/products/college-contact.md) |
|
||||
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
@@ -167,6 +173,8 @@ cli_version: ">=1.0.15"
|
||||
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
|
||||
| `todo` | `task delete` | 删除待办 |
|
||||
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
|
||||
| `college-contact` | `dept delete` | 删除高校部门,不可恢复 |
|
||||
| `college-contact` | `employee remove` | 移除高校员工,不可恢复 |
|
||||
|
||||
### 确认流程
|
||||
```
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 高校通讯录 (college-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### dept (部门管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
|
||||
| `dept get-detail` | 查询部门详情 | `--dept-id` |
|
||||
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
|
||||
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
|
||||
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
|
||||
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
|
||||
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
|
||||
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
|
||||
| `dept overview` | 查询高校概览统计 | 无 |
|
||||
|
||||
### employee (员工管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `employee get-detail` | 查询员工详情 | `--staff-id` |
|
||||
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
|
||||
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
|
||||
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
|
||||
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
|
||||
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
|
||||
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
|
||||
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
|
||||
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
|
||||
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
|
||||
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
|
||||
|
||||
### alumni (校友管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
|
||||
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
|
||||
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
|
||||
| `alumni query` | 查询单个校友详情 | `--staff-id` |
|
||||
| `alumni search` | 搜索校友 | `--keyword` |
|
||||
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
|
||||
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
|
||||
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
|
||||
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
|
||||
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
|
||||
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
|
||||
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
|
||||
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
|
||||
|
||||
### graduate (毕业年级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
|
||||
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
|
||||
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
|
||||
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
|
||||
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
|
||||
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
|
||||
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
|
||||
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
|
||||
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
|
||||
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
|
||||
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
|
||||
|
||||
### group (规则管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
|
||||
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
|
||||
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
|
||||
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
|
||||
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
|
||||
| `group enable-group-rule` | 启用规则 | `--rule-id` |
|
||||
| `group disable-group-rule` | 停用规则 | `--rule-id` |
|
||||
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
|
||||
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
|
||||
|
||||
## 常用参数说明
|
||||
|
||||
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
|
||||
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
|
||||
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
|
||||
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
|
||||
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
|
||||
用户说"搜人/找某某老师/找某某同学" → `dept search`
|
||||
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
|
||||
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
|
||||
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
|
||||
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
|
||||
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
|
||||
用户说"群规则/建群规则/自动建群" → group 子命令
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
|
||||
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
|
||||
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
|
||||
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
|
||||
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
|
||||
| `dept create` | `deptId` | update/delete 的 --dept-id |
|
||||
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
|
||||
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
|
||||
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
|
||||
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
|
||||
|
||||
## 危险操作
|
||||
|
||||
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
|
||||
@@ -595,6 +595,7 @@ Flags:
|
||||
用户说"恢复文件/还原删除的文件/从回收站恢复/还原回收站文件" → `recycle restore`
|
||||
用户说"给文档授权/分享权限" → `permission add`
|
||||
用户说"授权并通知对方/加权限后告知他/通知一下被授权的人" → `permission add --members ... --notify`(未提通知需求时不传 `--notify`)
|
||||
用户说"权限设置/权限模式/分享范围/水印等策略配置" → `permission get-setting`
|
||||
用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set`
|
||||
用户说"关闭公开/取消公开/取消互联网访问" → `publish unset`
|
||||
用户说"查看公开状态/是否公开/发布状态" → `publish get`
|
||||
@@ -724,6 +725,7 @@ Usage:
|
||||
dws drive permission update --node <ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"}]'
|
||||
dws drive permission list --node <ID>
|
||||
dws drive permission list --node <ID> --limit 50 --next-token <上次返回的 nextToken>
|
||||
dws drive permission get-setting --node <ID>
|
||||
dws drive permission remove --node <ID> --users uid1
|
||||
dws drive permission remove --node <ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
|
||||
Flags:
|
||||
@@ -754,6 +756,17 @@ Flags:
|
||||
> - 单次请求最多 30 个成员,超出请分批调用
|
||||
> - list 命令底层一次性返回全量成员后在内存中按 pageSize 分页,当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页
|
||||
|
||||
`get-setting` 返回节点权限配置(不是成员清单):`permissionMode`(INHERITED 继承上级 / INDEPENDENT 独立管理)、`shareScope`(可见范围与链接分享设置)、`policies`(水印、组织外分享、添加成员门槛等策略列表)。查询协作者清单仍用 `permission list`。
|
||||
|
||||
get-setting 返回字段说明:
|
||||
- `permissionMode` — INHERITED(继承上级)/ INDEPENDENT(独立管理),未知时为 null
|
||||
- `shareScope` — `visibility`(PRIVATE/ORGANIZATION/PUBLIC);`partnerIncluded`、`defaultRole`、`canSearch`、`canRecommend` 仅 ORGANIZATION 有意义;`linkShare`(仅开启链接分享时返回):`requirePassword`(密码明文不返回)、`expireAt`/`expireDays`(未设置为 null)、`forCurrentNode`
|
||||
- `policies[]` — 每项含 `code`(策略码)、`name`/`description`(中文名与值语义说明,随行必带)、`value`(当前值)、`disabledValues`(不可设置取值列表)、`allowedValues`(可设置值域,与 disabledValues 互斥);未下发或不支持的策略不返回;`node_spread_scope` 仅文件夹类节点返回
|
||||
- `disabledValues[]` — 每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null);恒返回,无被禁档位时为空数组;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}`
|
||||
- `value` 按策略分型:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE(无 NOBODY);node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY(无 READER_AND_ABOVE);node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效)
|
||||
- `name`/`description` 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动)
|
||||
- 方向语义:NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许
|
||||
|
||||
### 文件互联网公开发布
|
||||
|
||||
管理文件的互联网公开发布状态。公开后任何人通过链接即可访问,无需登录钉钉。操作者需要是文件的管理员或拥有者。
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 家校应用 (edu-app) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### message (消息管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
|
||||
|
||||
> `--target-role`: guardian(家长) / student(学生)
|
||||
> `--status`: 0(未处理) / 1(已处理)
|
||||
|
||||
### task (任务管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
|
||||
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
|
||||
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
|
||||
|
||||
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
|
||||
|
||||
### report (成绩单管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
|
||||
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
|
||||
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
|
||||
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
|
||||
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
|
||||
|
||||
### notice (通知管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
|
||||
| `notice get` | 查询通知详情 | `--notice-id` |
|
||||
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
|
||||
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
|
||||
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
|
||||
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
|
||||
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
|
||||
|
||||
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`;建议格式 `orgId-staffId-UUID`
|
||||
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
|
||||
> `notice list-by-teacher --status` / `notice list-by-student --status` 用于筛选通知状态
|
||||
|
||||
### circle (班级圈)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
|
||||
|
||||
> `--target-role`: guardian(家长视角) / student(学生视角)
|
||||
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
|
||||
|
||||
### card (打卡管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `card update` | 修改打卡任务的标题或内容(仅老师且为创建者) | `--card-id`, `--identifier`, (`--title` 或 `--content` 至少一个) |
|
||||
| `card end` | 提前结束打卡任务(仅老师且为创建者) | `--card-id` |
|
||||
| `card list` | 查询孩子/本人打卡列表(含进行中与已完结) | `--status` |
|
||||
| `card user-statistic` | 查询班级已完成/未完成人员(仅老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
|
||||
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
|
||||
|
||||
> `--status`: FINISH(已完结) / UNFINISH(进行中)
|
||||
> `--identifier` 建议格式 `orgId-staffId-UUID`,用于幂等去重
|
||||
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
|
||||
> `card finish-info` 当 `targetRole=guardian` 时,可传 `--student-id` 指定查看某个孩子的进度
|
||||
|
||||
### homework (作业管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
|
||||
| `homework get` | 查询作业详情 | `--homework-id` |
|
||||
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
|
||||
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
|
||||
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
|
||||
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
|
||||
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
|
||||
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
|
||||
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
|
||||
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
|
||||
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
|
||||
|
||||
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
|
||||
> `--submit-types` / `--hw-type` / `--publish-type` 控制提交方式与作业类型
|
||||
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
|
||||
|
||||
### diploma (奖状管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
|
||||
| `diploma get` | 查询奖状详情 | `--diploma-id` |
|
||||
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
|
||||
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
|
||||
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
|
||||
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
|
||||
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
|
||||
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
|
||||
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
|
||||
|
||||
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
|
||||
|
||||
## 危险操作
|
||||
|
||||
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
|
||||
|
||||
| 命令 | 后果 |
|
||||
|------|------|
|
||||
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
|
||||
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
|
||||
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
|
||||
|
||||
其余命令均为读或普通写操作,不需要 `--yes`。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"消息/消息摘要" → message summary-list
|
||||
用户说"家校任务/待办任务" → task 子命令
|
||||
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
|
||||
用户说"成绩/成绩单" → report 子命令
|
||||
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
|
||||
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
|
||||
用户说"班级圈/成长记录/学生动态" → circle posts
|
||||
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
|
||||
|
||||
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
|
||||
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
|
||||
关键区分: diploma(奖状/表彰) vs report(成绩单)
|
||||
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
### 老师场景
|
||||
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
|
||||
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
|
||||
3. 查看成绩单 → `report by-teacher --status 1`
|
||||
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
|
||||
5. 修改打卡标题/内容 → `card update --card-id <cardId> --identifier <id> --title "新标题"`
|
||||
6. 提前结束打卡 → `card end --card-id <cardId>`
|
||||
7. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
|
||||
8. 查看某班未打卡人员 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId>`
|
||||
9. 查看某打卡完成进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
|
||||
10. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
|
||||
11. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
|
||||
12. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
|
||||
13. 删除作业 → `homework delete --homework-id <id> --yes`
|
||||
14. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
|
||||
15. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
|
||||
16. 删除通知 → `notice delete --notice-id <id> --yes`
|
||||
17. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
|
||||
18. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
|
||||
19. 删除奖状 → `diploma delete --diploma-id <id> --yes`
|
||||
|
||||
### 家长场景
|
||||
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
|
||||
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
|
||||
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
|
||||
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
|
||||
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
|
||||
6. 查看孩子某打卡进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
|
||||
7. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
|
||||
8. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
|
||||
|
||||
### 学生场景
|
||||
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
|
||||
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
|
||||
3. 查看某份作业详情 → `homework student-detail --homework-id <id> --student-id <uid> --class-id <classId>`
|
||||
4. 标记奖状已读 → `diploma read --diploma-id <id>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
|
||||
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
|
||||
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
|
||||
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
|
||||
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
|
||||
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
|
||||
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
|
||||
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
|
||||
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
|
||||
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
|
||||
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
|
||||
@@ -0,0 +1,74 @@
|
||||
# 家校通讯录 (edu-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### school (学校/组织管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `school roles` | 查询用户在组织内的身份 | 无 |
|
||||
| `school structure` | 查询学校组织架构 | 无 |
|
||||
| `school periods` | 查询学校学段信息 | 无 |
|
||||
| `school type` | 查询学校组织类型 | 无 |
|
||||
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
|
||||
| `school class-list` | 查询学校所有班级列表 | 无 |
|
||||
|
||||
### class (班级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class detail` | 查询班级详情 | `--dept-id` |
|
||||
| `class students` | 查询班级学生信息 | `--dept-id` |
|
||||
| `class teachers` | 查询班级老师列表 | `--dept-id` |
|
||||
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
|
||||
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
|
||||
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
|
||||
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
|
||||
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
|
||||
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
|
||||
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
|
||||
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
|
||||
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
|
||||
| `class update-info` | 更新班级信息 | `--class-id` |
|
||||
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
|
||||
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
|
||||
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
|
||||
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
|
||||
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
|
||||
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
|
||||
|
||||
### family (家庭关系查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `family children` | 查询家长的孩子信息 | 无 |
|
||||
| `family parents` | 查询学生的家长信息 | 无 |
|
||||
|
||||
### teacher (教师管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `teacher classes` | 查询老师管理的班级列表 | 无 |
|
||||
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"学校/组织/学段/组织架构" → school 子命令
|
||||
用户说"班级/学生/教师/班主任" → class 子命令
|
||||
用户说"家长/孩子/家庭关系" → family 子命令
|
||||
用户说"任教/科目" → teacher update-course
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查询角色 → `school roles`
|
||||
2. 查看班级列表 → `school class-list`(提取 deptId)
|
||||
3. 查看班级详情 → `class detail --dept-id <deptId>`
|
||||
4. 查看学生列表 → `class students --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `school class-list` | `deptId` | class 子命令的 --dept-id |
|
||||
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
|
||||
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
|
||||
@@ -0,0 +1,68 @@
|
||||
# 家庭群 (edu-familygroup) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### group (家庭群查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
|
||||
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
|
||||
|
||||
### manage (家庭群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `manage create` | 创建家庭群 | `--uid`, `--children` |
|
||||
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
|
||||
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
|
||||
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"家庭群存在/有没有家庭群" → group check-exists
|
||||
用户说"孩子列表/我的孩子" → group list-children
|
||||
用户说"创建家庭/建群" → manage create
|
||||
用户说"邀请家长/拉家长入群" → manage invite-parent
|
||||
用户说"添加孩子/加娃" → manage add-child
|
||||
用户说"应用权限/小天地/学习视频" → manage toggle-app
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
|
||||
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
|
||||
3. 查看已绑定孩子 → `group list-children --uid <uid>`
|
||||
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
|
||||
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
|
||||
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### manage create --children 格式
|
||||
|
||||
```json
|
||||
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
|
||||
```
|
||||
|
||||
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
|
||||
|
||||
### manage add-child --students 格式
|
||||
|
||||
```json
|
||||
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
|
||||
```
|
||||
|
||||
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
|
||||
|
||||
### manage toggle-app --app-type 可选值
|
||||
|
||||
- `XIAOTIANDI`:小天地(学生圈)
|
||||
- `LEARNING_VIDEO`:学习视频
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
|
||||
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
|
||||
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
|
||||
@@ -0,0 +1,55 @@
|
||||
# 家校群 (edu-group) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### student-group (师生群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
|
||||
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
|
||||
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
|
||||
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
|
||||
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
|
||||
| `student-group create` | 创建班级师生群 | `--dept-id` |
|
||||
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
|
||||
|
||||
### class-group (班级群会话管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
|
||||
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
|
||||
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
|
||||
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
|
||||
|
||||
### batch (批量操作)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
|
||||
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
|
||||
| `batch create-student-groups` | 批量创建师生群 | 无 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"师生群/学生群" → student-group 子命令
|
||||
用户说"班级群/群会话" → class-group 子命令
|
||||
用户说"批量/一键操作" → batch 子命令
|
||||
|
||||
关键区分: student-group(师生群) vs class-group(班级群会话管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
|
||||
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
|
||||
3. 查看成员 → `student-group members --dept-id <deptId>`
|
||||
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
|
||||
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
|
||||
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
|
||||
@@ -51,7 +51,7 @@
|
||||
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
|
||||
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
|
||||
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
|
||||
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
|
||||
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
|
||||
@@ -80,7 +80,7 @@
|
||||
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
|
||||
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
|
||||
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
|
||||
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
|
||||
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
|
||||
@@ -237,7 +237,7 @@ Flags:
|
||||
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
|
||||
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
|
||||
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
|
||||
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
|
||||
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
|
||||
|
||||
## URL 与 ID 前置
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
### 浮动图片
|
||||
|
||||
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
|
||||
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
|
||||
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
|
||||
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
|
||||
|
||||
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
|
||||
@@ -71,7 +71,11 @@ Flags:
|
||||
Usage:
|
||||
dws sheet create-float-image [flags]
|
||||
Example:
|
||||
# 先上传图片获取 resourceUrl
|
||||
# 直接上传本地图片并创建浮动图片
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node <NODE_ID> --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
@@ -85,7 +89,8 @@ Example:
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
|
||||
--file string 本地图片文件路径,与 --src 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
|
||||
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
|
||||
--width int 图片宽度,像素,正整数 (必填)
|
||||
--height int 图片高度,像素,正整数 (必填)
|
||||
@@ -94,7 +99,8 @@ Flags:
|
||||
```
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
|
||||
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
|
||||
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `--width` / `--height` 为必填,单位像素,必须为正整数
|
||||
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
|
||||
@@ -138,13 +144,18 @@ Example:
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
|
||||
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
# 直接用本地图片替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:通过已上传的 resourceUrl 替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--src "/core/api/resources/img/xxxx..."
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
|
||||
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
|
||||
--range string 新的锚点单元格,A1 表示法
|
||||
--width int 新的图片宽度,像素
|
||||
@@ -153,7 +164,7 @@ Flags:
|
||||
--offset-y int 新的垂直偏移量,像素
|
||||
```
|
||||
|
||||
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
|
||||
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 删除浮动图片
|
||||
@@ -210,6 +221,16 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
|
||||
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
|
||||
|
||||
# ── 工作流 11: 创建或替换浮动图片 ──
|
||||
|
||||
# 从本地图片直接创建
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300 -f json
|
||||
|
||||
# 从本地图片直接替换已有浮动图
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--float-image-id <FI_ID> --file ./replacement.png -f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
@@ -232,11 +253,11 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
|
||||
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
|
||||
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
|
||||
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
|
||||
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
|
||||
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
|
||||
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
|
||||
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
|
||||
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
|
||||
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
|
||||
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
|
||||
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
|
||||
|
||||
@@ -72,6 +72,7 @@ dws drive +publish-unset --node <dentryUuid>
|
||||
| 意图 | managed leaf |
|
||||
|---|---|
|
||||
| 查看成员权限 | `drive permission list` |
|
||||
| 查询节点权限设置(权限模式/分享范围/策略) | `permission get-setting` |
|
||||
| 添加、修改、移除成员 | `permission add` / `update` / `remove` |
|
||||
| 转移所有者 | `permission transfer-owner` |
|
||||
| 查看可申请权限和审批人 | `permission apply-info` |
|
||||
@@ -79,6 +80,8 @@ dws drive +publish-unset --node <dentryUuid>
|
||||
|
||||
只在意图命中时读取一个精确 leaf Schema。成员变更、转移所有者、发起申请和公开状态变更必须明确节点、用户、角色与影响范围。转移所有者时,在构造最终命令前必须让用户分别明确决定 `--reserve-role <MANAGER|EDITOR|DOWNLOADER|READER|NONE>` 和 `--recursive=<true|false>`;Agent 不得根据默认值、对象类型或便捷性自行选择任一项。两项决策与目标、新所有者均明确后,才按 Runtime confirmation 构造首次正式调用。
|
||||
|
||||
`permission get-setting` 返回 `permissionMode`(INHERITED/INDEPENDENT,未知时为 null)、`shareScope`(可见范围与链接分享,密码明文不返回;`partnerIncluded`、`defaultRole` 等仅 ORGANIZATION 有意义,`linkShare` 仅开启链接分享时返回)和 `policies[]`(code/name/description/value/disabledValues/allowedValues;name/description 为中文名与值语义说明,随行必带;未下发的策略不返回,`node_spread_scope` 仅文件夹)。`disabledValues` 为不可设置取值列表(恒返回,无被禁档位时为空数组),每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null),与 allowedValues 互斥;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}`。`value` 按策略分型:开关型为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE;node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY;node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效)。NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许。name/description 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动)。
|
||||
|
||||
发起权限申请先只读执行 `permission apply-info`。正式 `permission apply` 会通知审批人;调用前必须向用户逐项回显并确认资源、申请角色、审批人和理由。Agent 不得默认选择第一位审批人、最高/最低角色或代写申请理由;用户未明确同意完整申请内容时停在确认环节。
|
||||
|
||||
## 快捷方式节点
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-misc
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile/家校应用/家校通讯录/家校群/高校通讯录。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile/家校任务/班级消息摘要/学校组织架构/班级列表/师生群/班级群/高校院系部门/师生员工管理 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -37,6 +37,11 @@ metadata:
|
||||
| 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) |
|
||||
| PAT 授权 / 行为权限 / scope 授权 / 一次性授权 / 会话授权 / 永久授权 / 授权浏览器策略 | PAT 行为授权与本地浏览器策略 | `dws pat` | [pat.md](references/pat.md) |
|
||||
| 切换组织 / 换组织 / 跨组织 / 多组织 / profile / 看登录了哪些组织 | 多组织 / profile 管理与跨组织取数 | `dws profile` / `dws auth` / `--profile` | [profile.md](references/profile.md) |
|
||||
| 家校应用 / 班级消息摘要 / 家校任务(教育场景) | 家校应用(教育版) | `dws edu-app` | [edu-app.md](references/edu-app.md) |
|
||||
| 家校通讯录 / 学校组织架构 / 班级列表(教育场景) | 家校通讯录(教育版) | `dws edu-contact` | [edu-contact.md](references/edu-contact.md) |
|
||||
| 家校群 / 师生群 / 班级群(教育场景) | 家校群(师生群)查询、创建、解散 | `dws edu-group` | [edu-group.md](references/edu-group.md) |
|
||||
| 家庭群 / 家长邀请 / 孩子管理 / 学生应用权限(教育场景) | 家庭群查询/创建、孩子管理、家长邀请 | `dws edu-familygroup` | [edu-familygroup.md](references/edu-familygroup.md) |
|
||||
| 高校通讯录 / 高校组织架构 / 院系部门 / 师生员工管理(高校场景) | 高校通讯录:部门与师生员工管理、搜索、统计、升级 | `dws college-contact` | [college-contact.md](references/college-contact.md) |
|
||||
| 宜搭 / AI应用脚本 / 财务辅助脚本(未产品化) | **无**稳定命令面;仅仓库内辅助脚本 | (非默认路由) | [unsupported-scripts.md](references/unsupported-scripts.md) |
|
||||
|
||||
## 说明
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 高校通讯录 (college-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### dept (部门管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
|
||||
| `dept get-detail` | 查询部门详情 | `--dept-id` |
|
||||
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
|
||||
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
|
||||
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
|
||||
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
|
||||
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
|
||||
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
|
||||
| `dept overview` | 查询高校概览统计 | 无 |
|
||||
|
||||
### employee (员工管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `employee get-detail` | 查询员工详情 | `--staff-id` |
|
||||
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
|
||||
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
|
||||
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
|
||||
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
|
||||
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
|
||||
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
|
||||
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
|
||||
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
|
||||
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
|
||||
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
|
||||
|
||||
### alumni (校友管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
|
||||
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
|
||||
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
|
||||
| `alumni query` | 查询单个校友详情 | `--staff-id` |
|
||||
| `alumni search` | 搜索校友 | `--keyword` |
|
||||
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
|
||||
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
|
||||
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
|
||||
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
|
||||
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
|
||||
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
|
||||
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
|
||||
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
|
||||
|
||||
### graduate (毕业年级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
|
||||
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
|
||||
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
|
||||
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
|
||||
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
|
||||
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
|
||||
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
|
||||
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
|
||||
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
|
||||
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
|
||||
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
|
||||
|
||||
### group (规则管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
|
||||
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
|
||||
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
|
||||
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
|
||||
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
|
||||
| `group enable-group-rule` | 启用规则 | `--rule-id` |
|
||||
| `group disable-group-rule` | 停用规则 | `--rule-id` |
|
||||
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
|
||||
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
|
||||
|
||||
## 常用参数说明
|
||||
|
||||
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
|
||||
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
|
||||
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
|
||||
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
|
||||
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
|
||||
用户说"搜人/找某某老师/找某某同学" → `dept search`
|
||||
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
|
||||
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
|
||||
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
|
||||
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
|
||||
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
|
||||
用户说"群规则/建群规则/自动建群" → group 子命令
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
|
||||
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
|
||||
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
|
||||
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
|
||||
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
|
||||
| `dept create` | `deptId` | update/delete 的 --dept-id |
|
||||
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
|
||||
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
|
||||
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
|
||||
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
|
||||
|
||||
## 危险操作
|
||||
|
||||
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
|
||||
@@ -25,19 +25,25 @@
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws devapp +create` | write | 创建开放平台企业内部应用 |
|
||||
| `dws devapp +credentials-get` | read | 读取开放平台应用凭证 |
|
||||
| `dws devapp +delete` | high-risk-write | 删除开放平台企业内部应用(不可逆) |
|
||||
| `dws devapp +disable` | high-risk-write | 停用开放平台企业内部应用 |
|
||||
| `dws devapp +enable` | write | 启用开放平台企业内部应用 |
|
||||
| `dws devapp +event-list` | read | 查询应用已订阅的事件列表 |
|
||||
| `dws devapp +event-list` | read | 查询应用可用事件目录与订阅状态 |
|
||||
| `dws devapp +event-subscribe` | write | 订阅应用事件回调 |
|
||||
| `dws devapp +get` | read | 查询开放平台企业内部应用详情 |
|
||||
| `dws devapp +list` | read | 查询开放平台企业内部应用列表 |
|
||||
| `dws devapp +member-add` | write | 添加开放平台应用成员 |
|
||||
| `dws devapp +member-list` | read | 查询开放平台应用成员 |
|
||||
| `dws devapp +member-remove` | high-risk-write | 移除开放平台应用成员 |
|
||||
| `dws devapp +permission-list` | read | 查询开放平台应用权限列表 |
|
||||
| `dws devapp +robot-config` | write | 创建或更新现有应用的机器人配置(upsert) |
|
||||
| `dws devapp +robot-disable` | high-risk-write | 停用现有应用的机器人能力 |
|
||||
| `dws devapp +robot-enable` | write | 启用现有应用机器人能力(纯启用,无需配置字段) |
|
||||
| `dws devapp +robot-get` | read | 查询现有应用的机器人配置 |
|
||||
| `dws devapp +update` | write | 修改开放平台企业内部应用基础信息 |
|
||||
| `dws devapp +version-check-approval` | read | 预检版本发布是否需要审批(不实际发布) |
|
||||
| `dws devapp +version-create` | write | 基于当前配置创建应用新版本 |
|
||||
| `dws devapp +version-get` | read | 查询指定版本详情 |
|
||||
| `dws devapp +version-list` | read | 分页查询应用版本列表 |
|
||||
| `dws devapp +version-status` | read | 查询版本发布/审批状态 |
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 家校应用 (edu-app) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### message (消息管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
|
||||
|
||||
> `--target-role`: guardian(家长) / student(学生)
|
||||
> `--status`: 0(未处理) / 1(已处理)
|
||||
|
||||
### task (任务管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
|
||||
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
|
||||
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
|
||||
|
||||
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
|
||||
|
||||
### report (成绩单管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
|
||||
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
|
||||
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
|
||||
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
|
||||
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
|
||||
|
||||
### notice (通知管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
|
||||
| `notice get` | 查询通知详情 | `--notice-id` |
|
||||
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
|
||||
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
|
||||
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
|
||||
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
|
||||
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
|
||||
|
||||
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`
|
||||
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
|
||||
|
||||
### circle (班级圈)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
|
||||
|
||||
> `--target-role`: guardian(家长视角) / student(学生视角)
|
||||
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
|
||||
|
||||
### card (打卡管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `card update` | 修改打卡标题或内容(仅创建者) | `--card-id`, `--identifier`, `--title`/`--content` |
|
||||
| `card end` | 提前结束打卡任务(仅创建者) | `--card-id` |
|
||||
| `card list` | 查询打卡列表(学生/家长) | `--status`(FINISH/UNFINISH) |
|
||||
| `card user-statistic` | 查询班级打卡完成/未完成人员(老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
|
||||
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
|
||||
|
||||
> `card list --status`: FINISH(已完结) / UNFINISH(进行中)
|
||||
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
|
||||
> `card finish-info --student-id`: 当 targetRole 为 guardian 时,用于指定查看某个孩子的进度
|
||||
|
||||
### homework (作业管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
|
||||
| `homework get` | 查询作业详情 | `--homework-id` |
|
||||
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
|
||||
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
|
||||
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
|
||||
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
|
||||
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
|
||||
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
|
||||
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
|
||||
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
|
||||
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
|
||||
|
||||
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
|
||||
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
|
||||
|
||||
### diploma (奖状管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
|
||||
| `diploma get` | 查询奖状详情 | `--diploma-id` |
|
||||
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
|
||||
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
|
||||
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
|
||||
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
|
||||
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
|
||||
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
|
||||
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
|
||||
|
||||
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
|
||||
|
||||
## 危险操作
|
||||
|
||||
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
|
||||
|
||||
| 命令 | 后果 |
|
||||
|------|------|
|
||||
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
|
||||
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
|
||||
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
|
||||
|
||||
其余命令均为读或普通写操作,不需要 `--yes`。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"消息/消息摘要" → message summary-list
|
||||
用户说"家校任务/待办任务" → task 子命令
|
||||
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
|
||||
用户说"成绩/成绩单" → report 子命令
|
||||
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
|
||||
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
|
||||
用户说"班级圈/成长记录/学生动态" → circle posts
|
||||
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
|
||||
|
||||
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
|
||||
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
|
||||
关键区分: diploma(奖状/表彰) vs report(成绩单)
|
||||
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
### 老师场景
|
||||
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
|
||||
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
|
||||
3. 查看成绩单 → `report by-teacher --status 1`
|
||||
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
|
||||
5. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
|
||||
6. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
|
||||
7. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
|
||||
8. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
|
||||
9. 删除作业 → `homework delete --homework-id <id> --yes`
|
||||
10. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
|
||||
11. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
|
||||
12. 删除通知 → `notice delete --notice-id <id> --yes`
|
||||
13. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
|
||||
14. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
|
||||
15. 删除奖状 → `diploma delete --diploma-id <id> --yes`
|
||||
|
||||
### 家长场景
|
||||
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
|
||||
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
|
||||
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
|
||||
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
|
||||
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
|
||||
6. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
|
||||
7. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
|
||||
|
||||
### 学生场景
|
||||
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
|
||||
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
|
||||
3. 标记奖状已读 → `diploma read --diploma-id <id>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
|
||||
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
|
||||
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
|
||||
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
|
||||
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
|
||||
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
|
||||
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
|
||||
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
|
||||
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
|
||||
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
|
||||
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-app/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查班级消息摘要" | `dws edu-app message summary-list --class-id <id> --cid <id> --target-role guardian\|student --status 0\|1` |
|
||||
| "查家校任务" | 见 [edu-app.md](./edu-app.md) `task` 章节 |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 师生群本身 → 见本包 references/edu-group.md
|
||||
- 家校通讯录 → 见本包 references/edu-contact.md
|
||||
@@ -0,0 +1,92 @@
|
||||
# 家校通讯录 (edu-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### school (学校/组织管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `school roles` | 查询用户在组织内的身份 | 无 |
|
||||
| `school structure` | 查询学校组织架构 | 无 |
|
||||
| `school periods` | 查询学校学段信息 | 无 |
|
||||
| `school type` | 查询学校组织类型 | 无 |
|
||||
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
|
||||
| `school class-list` | 查询学校所有班级列表 | 无 |
|
||||
|
||||
### class (班级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class detail` | 查询班级详情 | `--dept-id` |
|
||||
| `class students` | 查询班级学生信息 | `--dept-id` |
|
||||
| `class teachers` | 查询班级老师列表 | `--dept-id` |
|
||||
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
|
||||
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
|
||||
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
|
||||
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
|
||||
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
|
||||
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
|
||||
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
|
||||
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
|
||||
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
|
||||
| `class update-info` | 更新班级信息 | `--class-id` |
|
||||
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
|
||||
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
|
||||
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
|
||||
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
|
||||
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
|
||||
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
|
||||
|
||||
### family (家庭关系查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `family children` | 查询家长的孩子信息 | 无 |
|
||||
| `family parents` | 查询学生的家长信息 | 无 |
|
||||
|
||||
### teacher (教师管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `teacher classes` | 查询老师管理的班级列表 | 无 |
|
||||
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"学校/组织/学段/组织架构" → school 子命令
|
||||
用户说"班级/学生/教师/班主任" → class 子命令
|
||||
用户说"家长/孩子/家庭关系" → family 子命令
|
||||
用户说"任教/科目" → teacher update-course
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查询角色 → `school roles`
|
||||
2. 查看班级列表 → `school class-list`(提取 deptId)
|
||||
3. 查看班级详情 → `class detail --dept-id <deptId>`
|
||||
4. 查看学生列表 → `class students --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `school class-list` | `deptId` | class 子命令的 --dept-id |
|
||||
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
|
||||
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-contact/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "我在学校的身份" | `dws edu-contact school roles` |
|
||||
| "学校组织架构" | `dws edu-contact school structure` |
|
||||
| "学校学段 / 类型" | `dws edu-contact school periods` / `school type` |
|
||||
| "学校所有班级" | `dws edu-contact school class-list` |
|
||||
| "学校统计" | `dws edu-contact school stats [--statistics-type <t>]` |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 企业通讯录场景 → 切到 `dingtalk-contact`
|
||||
@@ -0,0 +1,68 @@
|
||||
# 家庭群 (edu-familygroup) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### group (家庭群查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
|
||||
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
|
||||
|
||||
### manage (家庭群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `manage create` | 创建家庭群 | `--uid`, `--children` |
|
||||
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
|
||||
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
|
||||
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"家庭群存在/有没有家庭群" → group check-exists
|
||||
用户说"孩子列表/我的孩子" → group list-children
|
||||
用户说"创建家庭/建群" → manage create
|
||||
用户说"邀请家长/拉家长入群" → manage invite-parent
|
||||
用户说"添加孩子/加娃" → manage add-child
|
||||
用户说"应用权限/小天地/学习视频" → manage toggle-app
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
|
||||
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
|
||||
3. 查看已绑定孩子 → `group list-children --uid <uid>`
|
||||
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
|
||||
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
|
||||
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### manage create --children 格式
|
||||
|
||||
```json
|
||||
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
|
||||
```
|
||||
|
||||
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
|
||||
|
||||
### manage add-child --students 格式
|
||||
|
||||
```json
|
||||
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
|
||||
```
|
||||
|
||||
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
|
||||
|
||||
### manage toggle-app --app-type 可选值
|
||||
|
||||
- `XIAOTIANDI`:小天地(学生圈)
|
||||
- `LEARNING_VIDEO`:学习视频
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
|
||||
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
|
||||
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
|
||||
@@ -0,0 +1,77 @@
|
||||
# 家校群 (edu-group) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### student-group (师生群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
|
||||
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
|
||||
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
|
||||
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
|
||||
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
|
||||
| `student-group create` | 创建班级师生群 | `--dept-id` |
|
||||
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
|
||||
|
||||
### class-group (班级群会话管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
|
||||
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
|
||||
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
|
||||
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
|
||||
|
||||
### batch (批量操作)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
|
||||
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
|
||||
| `batch create-student-groups` | 批量创建师生群 | 无 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"师生群/学生群" → student-group 子命令
|
||||
用户说"班级群/群会话" → class-group 子命令
|
||||
用户说"批量/一键操作" → batch 子命令
|
||||
|
||||
关键区分: student-group(师生群) vs class-group(班级群会话管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
|
||||
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
|
||||
3. 查看成员 → `student-group members --dept-id <deptId>`
|
||||
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
|
||||
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
|
||||
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-group/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查师生群信息 / 是否已建" | `dws edu-group student-group info --dept-id <id>` / `exists --dept-id <id>` |
|
||||
| "师生群成员" | `dws edu-group student-group members --dept-id <id>` |
|
||||
| "建班级师生群" | `dws edu-group student-group create --dept-id <id>` |
|
||||
| "解散班级师生群 ⚠️" | `dws edu-group student-group disband --dept-id <id>`(需用户确认 `--yes`) |
|
||||
|
||||
## 危险操作
|
||||
|
||||
`student-group disband` 不可逆,必须先向用户确认再加 `--yes`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 班级列表 → 见本包 references/edu-contact.md(school class-list)
|
||||
- 企业群 → 切到 `dingtalk-chat`
|
||||
@@ -51,7 +51,7 @@
|
||||
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
|
||||
| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 |
|
||||
| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
|
||||
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
|
||||
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
|
||||
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
|
||||
@@ -80,7 +80,7 @@
|
||||
| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` |
|
||||
| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
|
||||
| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
|
||||
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
|
||||
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
|
||||
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
|
||||
@@ -237,7 +237,7 @@ Flags:
|
||||
| "搜索公式文本" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<公式片段>" --match-formula` |
|
||||
| "正则搜索 / 不区分大小写" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --query "<regexp>" --use-regexp --match-case=false` |
|
||||
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1 --file <图片路径>` |
|
||||
| "创建浮动图片" | 先 `dws sheet media-upload --node <nodeId或URL> --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --src "<resourceUrl>" --range A1 --width <宽> --height <高>` |
|
||||
| "创建浮动图片" | `dws sheet create-float-image --node <nodeId或URL> --sheet-id <sheetId> --file <图片路径> --range A1 --width <宽> --height <高>` |
|
||||
|
||||
## URL 与 ID 前置
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
### 浮动图片
|
||||
|
||||
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
|
||||
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
|
||||
- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src`
|
||||
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
|
||||
|
||||
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
|
||||
@@ -71,7 +71,11 @@ Flags:
|
||||
Usage:
|
||||
dws sheet create-float-image [flags]
|
||||
Example:
|
||||
# 先上传图片获取 resourceUrl
|
||||
# 直接上传本地图片并创建浮动图片
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300
|
||||
|
||||
# 高级用法:先上传图片获取 resourceUrl
|
||||
dws sheet media-upload --node <NODE_ID> --file ./chart.png
|
||||
# 输出: resourceUrl: /core/api/resources/img/xxxx...
|
||||
|
||||
@@ -85,7 +89,8 @@ Example:
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
|
||||
--file string 本地图片文件路径,与 --src 二选一
|
||||
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一
|
||||
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
|
||||
--width int 图片宽度,像素,正整数 (必填)
|
||||
--height int 图片高度,像素,正整数 (必填)
|
||||
@@ -94,7 +99,8 @@ Flags:
|
||||
```
|
||||
|
||||
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
|
||||
- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建
|
||||
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程
|
||||
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `--width` / `--height` 为必填,单位像素,必须为正整数
|
||||
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
|
||||
@@ -138,13 +144,18 @@ Example:
|
||||
# 调整尺寸
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
|
||||
|
||||
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
|
||||
# 直接用本地图片替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--file ./replacement.png
|
||||
|
||||
# 高级用法:通过已上传的 resourceUrl 替换
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
|
||||
--src "/core/api/resources/img/xxxx..."
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--float-image-id string 浮动图片 ID (必填)
|
||||
--file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用
|
||||
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
|
||||
--range string 新的锚点单元格,A1 表示法
|
||||
--width int 新的图片宽度,像素
|
||||
@@ -153,7 +164,7 @@ Flags:
|
||||
--offset-y int 新的垂直偏移量,像素
|
||||
```
|
||||
|
||||
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
|
||||
更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。
|
||||
`--float-image-id` 可通过 `list-float-images` 获取。
|
||||
|
||||
### 删除浮动图片
|
||||
@@ -210,6 +221,16 @@ dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B1" \
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A2" \
|
||||
--values '[[{"type":"text","text":"MacBook Pro"}]]' -f json
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
|
||||
|
||||
# ── 工作流 11: 创建或替换浮动图片 ──
|
||||
|
||||
# 从本地图片直接创建
|
||||
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--file ./chart.png --range A1 --width 400 --height 300 -f json
|
||||
|
||||
# 从本地图片直接替换已有浮动图
|
||||
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--float-image-id <FI_ID> --file ./replacement.png -f json
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
@@ -232,11 +253,11 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
|
||||
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
|
||||
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
|
||||
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
|
||||
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
|
||||
- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src`
|
||||
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
|
||||
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
|
||||
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
|
||||
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
|
||||
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file`
|
||||
- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用
|
||||
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
|
||||
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
|
||||
|
||||
@@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) {
|
||||
newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) {
|
||||
t.Helper()
|
||||
repo := newChangelogGateRepo(t)
|
||||
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644)
|
||||
repo.commit(t, "merge post-seal beta fragment")
|
||||
return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
}
|
||||
|
||||
stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) {
|
||||
t.Helper()
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- Chat reply mentions.
|
||||
|
||||
- Sheet accepts local float image files.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1")
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil {
|
||||
t.Fatalf("Rename beta amendment fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "amend untagged beta release notes")
|
||||
}
|
||||
|
||||
t.Run("accepts exact pre-tag merge", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
|
||||
if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects tagged beta", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") {
|
||||
t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects rewritten sealed prose", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644)
|
||||
repo.commit(t, "rewrite sealed beta prose")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "does not exactly match") {
|
||||
t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
@@ -1106,6 +1179,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
t.Fatal("Code Admission workflow missing Policy job boundaries")
|
||||
}
|
||||
policyJob := admission[policyStart:policyEnd]
|
||||
if !strings.Contains(policyJob, "timeout-minutes: 15") {
|
||||
t.Error("Policy job must retain enough headroom for full Schema policy validation")
|
||||
}
|
||||
requirePolicyEnv := func(step, nextStep string) {
|
||||
t.Helper()
|
||||
start := strings.Index(policyJob, " - name: "+step+"\n")
|
||||
|
||||
Reference in New Issue
Block a user