fix(shortcut): fix projection-data-loss silent-empty returns
Several read shortcuts returned an empty list with exit 0 and no error envelope even though the underlying MCP tool returned data, so agents misread "no data" and made wrong decisions. Root causes: - Container key mismatch: the resolver probed the wrong key — processCodeList / values / wikiSpaces / itemList / groupList / recentItems / emailAccounts / deptUserList / labelUserList / roles / report_list, plus get_org_labels grouped labels[] needing a descend. - Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO / userInfo. - Param exceeded a backend limit: todo +created-todos sent pageSize=50 while the backend silently returns empty for pageSize>20; now uses the shared pager (pageSize=20). Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart resolvers. Every fix ships a guard test that feeds the real backend response shape (and, for minutes, both the taskUuid and minutesId item shapes) and asserts the projection is non-empty with a usable id. scripts/shortcut_real_result.py now compares the upper (projection) output against the lower (raw backend) layer, and record_real_shortcut_run.py captures the lower layer in memory (persisting only derived counts, never raw PII) so an exit-0 empty projection over a non-empty backend is scored as projection-data-loss instead of real-ok. The Python self-test runs in CI via test/scripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9f76c1844a
commit
b1e7b43f85
28 files changed
+1418
-45
No files matched your search
@@ -12,6 +12,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
|
||||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||||
|
|
||||||
## [1.0.54] - 2026-07-21
|
## [1.0.54] - 2026-07-21
|
||||||
|
|||||||
@@ -455,6 +455,12 @@ func searchClassProject(data map[string]any) []map[string]any {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// get_class_list wraps each shift's identity/label under shiftVO
|
||||||
|
// (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every
|
||||||
|
// row projects empty and the whole command silently returns nothing.
|
||||||
|
if vo, ok := m["shiftVO"].(map[string]any); ok {
|
||||||
|
m = vo
|
||||||
|
}
|
||||||
row := map[string]any{}
|
row := map[string]any{}
|
||||||
if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
|
if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
|
||||||
row["classId"] = v
|
row["classId"] = v
|
||||||
@@ -668,13 +674,21 @@ var SearchAdjustmentRule = shortcut.Shortcut{
|
|||||||
// across candidate keys, so an unknown/empty shape yields an empty list rather
|
// across candidate keys, so an unknown/empty shape yields an empty list rather
|
||||||
// than a crash or fabricated data.
|
// than a crash or fabricated data.
|
||||||
func searchRuleProject(data map[string]any) []map[string]any {
|
func searchRuleProject(data map[string]any) []map[string]any {
|
||||||
raw := attendanceResolveList(data, "ruleList", "rules", "list", "items")
|
// get_overtime_rule nests the list under result.atRuleList and
|
||||||
|
// get_adjustment_rule under result.adjustmentList; both wrap each rule's
|
||||||
|
// identity/label under entityVO. Probe those container keys AND unwrap
|
||||||
|
// entityVO, otherwise +search-overtime-rule / +search-adjustment-rule
|
||||||
|
// silently return empty despite the backend returning rules.
|
||||||
|
raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items")
|
||||||
out := make([]map[string]any, 0, len(raw))
|
out := make([]map[string]any, 0, len(raw))
|
||||||
for _, item := range raw {
|
for _, item := range raw {
|
||||||
m, ok := item.(map[string]any)
|
m, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if vo, ok := m["entityVO"].(map[string]any); ok {
|
||||||
|
m = vo
|
||||||
|
}
|
||||||
row := map[string]any{}
|
row := map[string]any{}
|
||||||
if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
|
if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
|
||||||
row["ruleId"] = v
|
row["ruleId"] = v
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package attendance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||||
|
// get_class_list nests the list under result.items and wraps each shift's
|
||||||
|
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
|
||||||
|
// silently returns empty despite the backend returning shifts.
|
||||||
|
func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"items":[
|
||||||
|
{"shiftVO":{"id":957395083,"name":"默认班次"}},
|
||||||
|
{"shiftVO":{"id":957395084,"name":"早班"}}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
got := searchClassProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 2 个班次,投影返回 %d 个 (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0]["name"] == nil || got[0]["classId"] == nil {
|
||||||
|
t.Fatalf("shiftVO fields not unwrapped: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||||
|
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
|
||||||
|
// entityVO. The resolver must probe those keys and unwrap entityVO or
|
||||||
|
// +search-overtime-rule / +search-adjustment-rule silently return empty.
|
||||||
|
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, key string
|
||||||
|
}{
|
||||||
|
{"overtime", "atRuleList"},
|
||||||
|
{"adjustment", "adjustmentList"},
|
||||||
|
} {
|
||||||
|
raw := `{"result":{"` + tc.key + `":[
|
||||||
|
{"entityVO":{"id":11,"name":"工作日加班"},"permissionVO":{}},
|
||||||
|
{"entityVO":{"id":12,"name":"节假日加班"},"permissionVO":{}}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
got := searchRuleProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("[%s] 上下层数据不一致: 底层 result.%s 有 2 条,投影返回 %d 条", tc.name, tc.key, len(got))
|
||||||
|
}
|
||||||
|
if got[0]["ruleId"] == nil || got[0]["name"] == nil {
|
||||||
|
t.Fatalf("[%s] entityVO fields not unwrapped: %v", tc.name, got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -494,7 +494,10 @@ func roomGroupsProject(data map[string]any) []map[string]any {
|
|||||||
// roomGroupsContainer locates the group slice across candidate wrapper keys,
|
// roomGroupsContainer locates the group slice across candidate wrapper keys,
|
||||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||||
func roomGroupsContainer(data map[string]any) []any {
|
func roomGroupsContainer(data map[string]any) []any {
|
||||||
keys := []string{"result", "data", "list", "items", "groups"}
|
// list_meeting_room_groups nests the groups under result.groupList;
|
||||||
|
// "groupList" MUST be probed or +room-groups silently returns empty despite
|
||||||
|
// the backend returning meeting-room groups.
|
||||||
|
keys := []string{"result", "data", "list", "items", "groupList", "groups"}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
v, ok := data[k]
|
v, ok := data[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRoomGroupsProjectGroupListShape guards against projection-data-loss:
|
||||||
|
// list_meeting_room_groups nests the groups under result.groupList; the resolver
|
||||||
|
// must probe "groupList" or +room-groups silently returns empty despite the
|
||||||
|
// backend returning meeting-room groups.
|
||||||
|
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"groupList":[
|
||||||
|
{"groupId":"g1","groupName":"北楼会议室"},
|
||||||
|
{"groupId":"g2","groupName":"南楼会议室"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if got := roomGroupsProject(data); len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.groupList 有 2 条,投影返回 %d 条", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,7 +413,12 @@ func chatGroupResolveList(data map[string]any) []any {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return []any{}
|
return []any{}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} {
|
// "roles" is probed too: chatRoleListProject reuses this resolver and
|
||||||
|
// list_custom_group_roles nests its list under result.roles — without it
|
||||||
|
// +chat-role-list silently returns empty despite the group having roles.
|
||||||
|
// Group listings key on groups/conversations, which are probed first, so
|
||||||
|
// adding roles is harmless to them.
|
||||||
|
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -422,7 +427,7 @@ func chatGroupResolveList(data map[string]any) []any {
|
|||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} {
|
for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestChatRoleListProjectRolesShape guards against projection-data-loss:
|
||||||
|
// list_custom_group_roles nests the roles under result.roles; the shared
|
||||||
|
// resolver must probe "roles" or +chat-role-list silently returns empty despite
|
||||||
|
// the group having custom roles.
|
||||||
|
func TestChatRoleListProjectRolesShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"roles":[
|
||||||
|
{"openRoleId":"r1","name":"CTO"},
|
||||||
|
{"openRoleId":"r2","name":"CEO"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
got := chatRoleListProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.roles 有 2 条,投影返回 %d 条 (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0]["openRoleId"] == nil || got[0]["name"] == nil {
|
||||||
|
t.Fatalf("role fields missing: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatGroupResolveListStillHandlesGroups ensures adding "roles" did not
|
||||||
|
// disturb the group-listing shape the resolver is shared with.
|
||||||
|
func TestChatGroupResolveListStillHandlesGroups(t *testing.T) {
|
||||||
|
const raw = `{"result":{"groups":[{"openConversationId":"c1","title":"项目群"}]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if got := chatGroupResolveList(data); len(got) != 1 {
|
||||||
|
t.Fatalf("group shape regressed: want 1, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,13 +214,13 @@ func listRolesResolveList(data map[string]any) []any {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if arr, ok := v.([]any); ok {
|
if arr, ok := v.([]any); ok {
|
||||||
return arr
|
return listRolesFlattenGroups(arr)
|
||||||
}
|
}
|
||||||
// container may itself wrap the list one level deeper
|
// container may itself wrap the list one level deeper
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "labels", "result", "data"} {
|
for _, ik := range []string{"list", "items", "labels", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return listRolesFlattenGroups(arr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +228,42 @@ func listRolesResolveList(data map[string]any) []any {
|
|||||||
return []any{}
|
return []any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listRolesFlattenGroups flattens the grouped get_org_labels response into a
|
||||||
|
// flat list of label objects. get_org_labels returns result[] as label GROUPS
|
||||||
|
// ({groupName, labels[]}), nesting the actual roles one level under each group's
|
||||||
|
// labels[]. A naive top-level scan would hand back the group wrappers — which
|
||||||
|
// carry no labelId/name — so every row would silently project to empty. When
|
||||||
|
// the array is already a flat label list (no per-group labels[]), it is returned
|
||||||
|
// unchanged so the projection tolerates both response shapes.
|
||||||
|
func listRolesFlattenGroups(arr []any) []any {
|
||||||
|
flattened := make([]any, 0, len(arr))
|
||||||
|
sawGroup := false
|
||||||
|
for _, item := range arr {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
flattened = append(flattened, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var nested []any
|
||||||
|
for _, gk := range []string{"labels", "subLabels"} {
|
||||||
|
if inner, ok := m[gk].([]any); ok {
|
||||||
|
nested = inner
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nested != nil {
|
||||||
|
sawGroup = true
|
||||||
|
flattened = append(flattened, nested...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flattened = append(flattened, item)
|
||||||
|
}
|
||||||
|
if sawGroup {
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
// listRolesFirst returns the first present candidate key's value.
|
// listRolesFirst returns the first present candidate key's value.
|
||||||
func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
|
func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
@@ -291,6 +327,12 @@ func memberListProject(data map[string]any) []map[string]any {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// get_dept_members_by_deptId wraps each member under userInfo
|
||||||
|
// (deptUserList item = {"userInfo": {userId, name, ...}}); unwrap it or
|
||||||
|
// every row projects empty and +list-dept-members silently returns none.
|
||||||
|
if ui, ok := m["userInfo"].(map[string]any); ok {
|
||||||
|
m = ui
|
||||||
|
}
|
||||||
row := map[string]any{}
|
row := map[string]any{}
|
||||||
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
|
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
|
||||||
row["userId"] = v
|
row["userId"] = v
|
||||||
@@ -306,9 +348,14 @@ func memberListProject(data map[string]any) []map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// memberListFindList returns the first slice found under the common list
|
// memberListFindList returns the first slice found under the common list
|
||||||
// container keys, or nil when none is present.
|
// container keys, or nil when none is present. get_dept_members_by_deptId nests
|
||||||
|
// its members under deptUserList, so that key must be probed too.
|
||||||
func memberListFindList(m map[string]any) []any {
|
func memberListFindList(m map[string]any) []any {
|
||||||
for _, k := range []string{"result", "data", "list", "items", "members", "userList"} {
|
// get_dept_members_by_deptId nests under deptUserList and
|
||||||
|
// get_label_members_by_labelId under labelUserList; both must be probed (and
|
||||||
|
// each item unwrapped from userInfo above) or +list-dept-members /
|
||||||
|
// +list-role-members silently return empty despite having members.
|
||||||
|
for _, k := range []string{"result", "data", "list", "items", "members", "userList", "deptUserList", "labelUserList"} {
|
||||||
if arr, ok := m[k].([]any); ok {
|
if arr, ok := m[k].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListRolesProjectGroupedShape guards against the projection-data-loss class:
|
||||||
|
// get_org_labels returns result[] as label GROUPS with the real roles nested one
|
||||||
|
// level under each group's labels[]. The projection MUST descend into those
|
||||||
|
// nested labels[] rather than treating group wrappers as roles — otherwise it
|
||||||
|
// silently returns empty (exit 0, no error envelope) while the backend has data.
|
||||||
|
func TestListRolesProjectGroupedShape(t *testing.T) {
|
||||||
|
// Faithful get_org_labels shape (as returned by the backend / leaf command).
|
||||||
|
const raw = `{"result":[
|
||||||
|
{"groupName":"默认","labels":[
|
||||||
|
{"labelId":101,"name":"角色A"},
|
||||||
|
{"labelId":102,"name":"角色B"},
|
||||||
|
{"labelId":103,"name":"角色C"}]},
|
||||||
|
{"groupName":"职务","labels":[
|
||||||
|
{"labelId":104,"name":"角色D"},
|
||||||
|
{"labelId":105,"name":"角色E"}]}
|
||||||
|
]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := listRolesProject(data)
|
||||||
|
if len(roles) != 5 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层有 5 个角色,投影返回 %d 个 (roles=%v)", len(roles), roles)
|
||||||
|
}
|
||||||
|
for _, r := range roles {
|
||||||
|
if r["labelId"] == nil || r["labelName"] == nil {
|
||||||
|
t.Fatalf("projected role missing labelId/labelName: %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListRolesProjectNestedWrapperAndNonMap covers the nested map-wrapper branch
|
||||||
|
// ({result:{labels:[...]}}) and a non-map array element, which must be passed
|
||||||
|
// through rather than dropped.
|
||||||
|
func TestListRolesProjectNestedWrapperAndNonMap(t *testing.T) {
|
||||||
|
const raw = `{"result":{"labels":[
|
||||||
|
{"groupName":"默认","labels":[{"labelId":1,"name":"角色A"}]},
|
||||||
|
"junk"
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if roles := listRolesProject(data); len(roles) != 1 {
|
||||||
|
t.Fatalf("nested wrapper + non-map: want 1 role, got %d (%v)", len(roles), roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListRolesProjectFlatShape ensures the flatten step is a no-op when the
|
||||||
|
// response is already a flat label list (response-shape drift tolerance).
|
||||||
|
func TestListRolesProjectFlatShape(t *testing.T) {
|
||||||
|
const raw = `{"result":[
|
||||||
|
{"labelId":1,"name":"销售"},
|
||||||
|
{"labelId":2,"name":"市场"}
|
||||||
|
]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if roles := listRolesProject(data); len(roles) != 2 {
|
||||||
|
t.Fatalf("flat shape: want 2 roles, got %d (%v)", len(roles), roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMemberListProjectDeptUserListShape guards against projection-data-loss:
|
||||||
|
// get_dept_members_by_deptId returns members under deptUserList, each wrapped in
|
||||||
|
// userInfo. The resolver must probe deptUserList AND unwrap userInfo or
|
||||||
|
// +list-dept-members silently returns empty despite the backend returning members.
|
||||||
|
func TestMemberListProjectDeptUserListShape(t *testing.T) {
|
||||||
|
const raw = `{"deptUserList":[
|
||||||
|
{"userInfo":{"userId":"u1","name":"张三"}},
|
||||||
|
{"userInfo":{"userId":"u2","name":"李四"}}
|
||||||
|
]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
got := memberListProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 deptUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0]["userId"] == nil || got[0]["name"] == nil {
|
||||||
|
t.Fatalf("userInfo fields not unwrapped: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemberListProjectLabelUserListShape guards the role-member path:
|
||||||
|
// get_label_members_by_labelId nests members under labelUserList, each wrapped
|
||||||
|
// in userInfo. The resolver must probe labelUserList AND unwrap userInfo or
|
||||||
|
// +list-role-members silently returns empty despite the role having members.
|
||||||
|
func TestMemberListProjectLabelUserListShape(t *testing.T) {
|
||||||
|
const raw = `{"labelUserList":[
|
||||||
|
{"userInfo":{"userId":"u1","name":"王五"}},
|
||||||
|
{"userInfo":{"userId":"u2","name":"赵六"}}
|
||||||
|
]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
got := memberListProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 labelUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0]["userId"] == nil || got[0]["name"] == nil {
|
||||||
|
t.Fatalf("userInfo not unwrapped: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemberListProjectFlatRoleShape ensures the shared projection still handles
|
||||||
|
// a flat member shape unchanged.
|
||||||
|
func TestMemberListProjectFlatRoleShape(t *testing.T) {
|
||||||
|
const raw = `{"result":[{"userId":"u9","name":"张三"}]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if got := memberListProject(data); len(got) != 1 {
|
||||||
|
t.Fatalf("flat role-member shape: want 1, got %d (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -495,8 +495,16 @@ var Recent = shortcut.Shortcut{
|
|||||||
// document list, dropping transport noise (logId) while keeping the pagination
|
// document list, dropping transport noise (logId) while keeping the pagination
|
||||||
// cursor.
|
// cursor.
|
||||||
func recentListProject(data map[string]any) map[string]any {
|
func recentListProject(data map[string]any) map[string]any {
|
||||||
|
// get_recent_list nests its payload under result.recentItems; earlier this
|
||||||
|
// read data["recentItems"] at the top level only, so the whole list silently
|
||||||
|
// projected to empty. Resolve the payload container (top-level or one level
|
||||||
|
// under result/data) before reading recentItems / pagination fields.
|
||||||
|
payload := data
|
||||||
|
if inner, ok := recentListPayload(data); ok {
|
||||||
|
payload = inner
|
||||||
|
}
|
||||||
items := []map[string]any{}
|
items := []map[string]any{}
|
||||||
raw, _ := data["recentItems"].([]any)
|
raw, _ := payload["recentItems"].([]any)
|
||||||
for _, it := range raw {
|
for _, it := range raw {
|
||||||
m, ok := it.(map[string]any)
|
m, ok := it.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -512,15 +520,31 @@ func recentListProject(data map[string]any) map[string]any {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
out := map[string]any{"count": len(items), "items": items}
|
out := map[string]any{"count": len(items), "items": items}
|
||||||
if nc, ok := data["nextCursor"]; ok && nc != nil {
|
if nc, ok := payload["nextCursor"]; ok && nc != nil {
|
||||||
out["nextCursor"] = nc
|
out["nextCursor"] = nc
|
||||||
}
|
}
|
||||||
if hm, ok := data["hasMore"]; ok {
|
if hm, ok := payload["hasMore"]; ok {
|
||||||
out["hasMore"] = hm
|
out["hasMore"] = hm
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentListPayload returns the map that actually holds recentItems, tolerating
|
||||||
|
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
|
||||||
|
func recentListPayload(data map[string]any) (map[string]any, bool) {
|
||||||
|
if _, ok := data["recentItems"]; ok {
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
for _, k := range []string{"result", "data"} {
|
||||||
|
if inner, ok := data[k].(map[string]any); ok {
|
||||||
|
if _, ok := inner["recentItems"]; ok {
|
||||||
|
return inner, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
shortcut.Register(
|
shortcut.Register(
|
||||||
List,
|
List,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package drive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRecentListProjectResultWrapper guards against projection-data-loss:
|
||||||
|
// get_recent_list nests its payload under result.recentItems; the projection
|
||||||
|
// must descend into result or +recent silently returns empty despite backend
|
||||||
|
// records.
|
||||||
|
func TestRecentListProjectResultWrapper(t *testing.T) {
|
||||||
|
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
|
||||||
|
{"name":"周报","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
|
||||||
|
{"name":"预算表","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
out := recentListProject(data)
|
||||||
|
if got, _ := out["count"].(int); got != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.recentItems 有 2 条,投影 count=%v (%v)", out["count"], out)
|
||||||
|
}
|
||||||
|
if out["hasMore"] != true || out["nextCursor"] != "c2" {
|
||||||
|
t.Fatalf("pagination fields lost from result wrapper: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
|
||||||
|
// found under any wrapper), which must yield an empty list, not a panic.
|
||||||
|
func TestRecentListProjectNoItems(t *testing.T) {
|
||||||
|
const raw = `{"result":{"totalCount":0},"success":true}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if out := recentListProject(data); out["count"].(int) != 0 {
|
||||||
|
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
|
||||||
|
func TestRecentListProjectTopLevel(t *testing.T) {
|
||||||
|
const raw = `{"recentItems":[{"name":"周报","nodeId":"n1"}],"hasMore":false}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if out := recentListProject(data); out["count"].(int) != 1 {
|
||||||
|
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package minutes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCallListProjectItemListShape guards against projection-data-loss end to
|
||||||
|
// end: list_by_keyword_and_time_range nests the minutes under result.itemList,
|
||||||
|
// so the resolver must probe "itemList"; and each projected row must carry a
|
||||||
|
// usable id. The real backend item uses taskUuid, but the API also emits
|
||||||
|
// minutesId — both must map to the projected taskUuid or callers get rows with
|
||||||
|
// a title but no id to act on.
|
||||||
|
func TestCallListProjectItemListShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"itemList":[
|
||||||
|
{"taskUuid":"7632756964333736","title":"周会纪要"},
|
||||||
|
{"minutesId":"m2","title":"评审纪要"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
got := callListProject(data)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.itemList 有 2 条,投影返回 %d 条", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["taskUuid"] != "7632756964333736" {
|
||||||
|
t.Fatalf("taskUuid-shaped item lost its id: %v", got[0])
|
||||||
|
}
|
||||||
|
if got[1]["taskUuid"] != "m2" {
|
||||||
|
t.Fatalf("minutesId-shaped item did not map to taskUuid: %v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ func callListProject(data map[string]any) []map[string]any {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
row := map[string]any{}
|
row := map[string]any{}
|
||||||
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok {
|
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "minutesId", "minutes_id", "id"); ok {
|
||||||
row["taskUuid"] = v
|
row["taskUuid"] = v
|
||||||
}
|
}
|
||||||
if v, ok := callListFirst(m, "title", "name"); ok {
|
if v, ok := callListFirst(m, "title", "name"); ok {
|
||||||
@@ -144,7 +144,10 @@ func callListProject(data map[string]any) []map[string]any {
|
|||||||
// callListResolveList locates the list payload inside the response, tolerating a
|
// callListResolveList locates the list payload inside the response, tolerating a
|
||||||
// bare top-level array or nesting under result/data/list/items/records containers.
|
// bare top-level array or nesting under result/data/list/items/records containers.
|
||||||
func callListResolveList(data map[string]any) []any {
|
func callListResolveList(data map[string]any) []any {
|
||||||
for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} {
|
// list_by_keyword_and_time_range nests the minutes under result.itemList;
|
||||||
|
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
|
||||||
|
// return empty despite the backend returning minutes.
|
||||||
|
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -154,7 +157,7 @@ func callListResolveList(data map[string]any) []any {
|
|||||||
}
|
}
|
||||||
// container may itself wrap the list one level deeper
|
// container may itself wrap the list one level deeper
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} {
|
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package oa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
|
||||||
|
// class: list_user_visible_process nests the forms under result.processCodeList.
|
||||||
|
// The resolver MUST probe that exact key — otherwise the whole list silently
|
||||||
|
// projects to empty (exit 0, no error envelope) while the backend has data.
|
||||||
|
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
|
||||||
|
// Faithful list_user_visible_process shape (as returned by the backend).
|
||||||
|
const raw = `{"result":{"processCodeList":[
|
||||||
|
{"processCode":"PROC-1","processName":"请假","dirName":"假勤管理"},
|
||||||
|
{"processCode":"PROC-2","processName":"补卡申请","dirName":"假勤管理"},
|
||||||
|
{"processCode":"PROC-3","processName":"加班","dirName":"假勤管理"}
|
||||||
|
],"totalCount":-1},"success":true}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
forms := listFormsProject(data)
|
||||||
|
if len(forms) != 3 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层有 3 张表单,投影返回 %d 张 (forms=%v)", len(forms), forms)
|
||||||
|
}
|
||||||
|
for _, f := range forms {
|
||||||
|
if f["processCode"] == nil || f["name"] == nil {
|
||||||
|
t.Fatalf("projected form missing processCode/name: %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
|
||||||
|
func TestListFormsProjectBareArrayShape(t *testing.T) {
|
||||||
|
const raw = `{"result":[
|
||||||
|
{"processCode":"PROC-9","processName":"通用审批"}
|
||||||
|
]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if forms := listFormsProject(data); len(forms) != 1 {
|
||||||
|
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
|
||||||
|
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
|
||||||
|
// instance tools nest the list under result.values; the resolver must probe
|
||||||
|
// "values" or all four commands silently return empty despite backend records.
|
||||||
|
func TestOAInstanceResolveListValuesShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"hasMore":false,"values":[
|
||||||
|
{"processInstanceId":"i-1","title":"测试用户的请假"},
|
||||||
|
{"processInstanceId":"i-2","title":"测试用户的报销"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if got := oaInstanceResolveList(data); len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.values 有 2 条,resolver 返回 %d 条", len(got))
|
||||||
|
}
|
||||||
|
// End-to-end through a real projection that uses the shared resolver.
|
||||||
|
if instances := listSubmittedProject(data); len(instances) != 2 {
|
||||||
|
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,11 @@ func oaFormResolveList(data map[string]any) []any {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return []any{}
|
return []any{}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} {
|
// list_user_visible_process nests the forms under result.processCodeList;
|
||||||
|
// probing must include that exact key (both at the top level in case the
|
||||||
|
// envelope is already unwrapped, and one level deeper under result/data) or
|
||||||
|
// the whole list silently projects to empty.
|
||||||
|
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -146,7 +150,7 @@ func oaFormResolveList(data map[string]any) []any {
|
|||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} {
|
for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
@@ -285,7 +289,12 @@ func oaInstanceResolveList(data map[string]any) []any {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return []any{}
|
return []any{}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} {
|
// The approval instance tools (list_pending_approvals, get_done_tasks,
|
||||||
|
// get_submitted_instances, get_noticed_instances) all nest the instance list
|
||||||
|
// under result.values; "values" MUST be in the probe set or every one of
|
||||||
|
// +list-pending / +list-executed / +list-submitted / +list-cc silently
|
||||||
|
// projects to empty despite the backend returning records.
|
||||||
|
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -294,7 +303,7 @@ func oaInstanceResolveList(data map[string]any) []any {
|
|||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} {
|
for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ func reportEntryListResolveList(data map[string]any) []any {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return []any{}
|
return []any{}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} {
|
// get_received_report_list / get_send_report_list nest the list under
|
||||||
|
// result.report_list (snake_case); "report_list" MUST be probed — the
|
||||||
|
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
|
||||||
|
// empty despite the backend returning reports.
|
||||||
|
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
|
||||||
v, ok := data[key]
|
v, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -140,7 +144,7 @@ func reportEntryListResolveList(data map[string]any) []any {
|
|||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
if inner, ok := v.(map[string]any); ok {
|
if inner, ok := v.(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} {
|
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
|
||||||
|
// get_received_report_list / get_send_report_list nest the list under
|
||||||
|
// result.report_list (snake_case); the resolver must probe "report_list" or
|
||||||
|
// +inbox-list / +outbox-list silently return empty despite the backend having
|
||||||
|
// reports.
|
||||||
|
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"report_list":[
|
||||||
|
{"reportId":"r1","templateName":"日报"},
|
||||||
|
{"reportId":"r2","templateName":"周报"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if got := reportEntryListResolveList(data); len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.report_list 有 2 条,resolver 返回 %d 条", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,12 @@ var CreatedTodos = shortcut.Shortcut{
|
|||||||
`dws todo +created-todos`,
|
`dws todo +created-todos`,
|
||||||
},
|
},
|
||||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
// Step 1 — list my todos as creator. pageNum/pageSize are strings and
|
// Step 1 — list my todos as creator via the shared pager. This keeps
|
||||||
// roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where
|
// pageSize at todoPageSize(=20): the backend silently returns an empty
|
||||||
// "creator" is an accepted role-type enum value.
|
// page for pageSize>20, so the previous hard-coded pageSize=50 made this
|
||||||
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{
|
// command always return empty even when the creator had todos. The pager
|
||||||
"pageNum": "1",
|
// also merges every page instead of only the first.
|
||||||
"pageSize": "50",
|
cards, err := shortcutListAllTodoCards(rt, map[string]any{
|
||||||
"roleTypes": []string{"creator"},
|
"roleTypes": []string{"creator"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +69,6 @@ var CreatedTodos = shortcut.Shortcut{
|
|||||||
|
|
||||||
// Step 2 — project each card to {subject, taskId, dueTime}, parsing
|
// Step 2 — project each card to {subject, taskId, dueTime}, parsing
|
||||||
// fields defensively (helpers reused from todo_done.go / overdue.go).
|
// fields defensively (helpers reused from todo_done.go / overdue.go).
|
||||||
cards := shortcutTodoCards(data)
|
|
||||||
created := make([]map[string]any, 0, len(cards))
|
created := make([]map[string]any, 0, len(cards))
|
||||||
for _, m := range cards {
|
for _, m := range cards {
|
||||||
subject, _ := m["subject"].(string)
|
subject, _ := m["subject"].(string)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package smart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errStubTool = errors.New("stub tool error")
|
||||||
|
|
||||||
|
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
|
||||||
|
// an email field and bare string arrays, each at the top level or one level
|
||||||
|
// under a result/data wrapper. The nested bare-string case regressed silently
|
||||||
|
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
|
||||||
|
// demand an explicit --email.
|
||||||
|
func TestSearchMailPickMailbox(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, raw, want string
|
||||||
|
}{
|
||||||
|
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
|
||||||
|
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
|
||||||
|
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
|
||||||
|
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
|
||||||
|
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := searchMailPickMailbox(data); got != tc.want {
|
||||||
|
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
|
||||||
|
// container is probed — otherwise +resolve-space silently finds no candidates.
|
||||||
|
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
|
||||||
|
const raw = `{"result":{"wikiSpaces":[
|
||||||
|
{"spaceId":"s1","name":"研发知识库"},
|
||||||
|
{"spaceId":"s2","name":"产品知识库"}
|
||||||
|
]}}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := resolveSpaceItems(data); len(got) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,resolver 返回 %d 条", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fake MCP caller for end-to-end shortcut projection tests ---
|
||||||
|
|
||||||
|
type stubMailboxCaller struct {
|
||||||
|
byTool map[string]string
|
||||||
|
errTool string // tool name that should return a transport error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *stubMailboxCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||||
|
if tool == f.errTool {
|
||||||
|
return nil, errStubTool
|
||||||
|
}
|
||||||
|
text := f.byTool[tool]
|
||||||
|
if text == "" {
|
||||||
|
text = `{"result":{}}`
|
||||||
|
}
|
||||||
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||||
|
}
|
||||||
|
func (f *stubMailboxCaller) Format() string { return "json" }
|
||||||
|
func (f *stubMailboxCaller) DryRun() bool { return false }
|
||||||
|
func (f *stubMailboxCaller) Fields() string { return "" }
|
||||||
|
func (f *stubMailboxCaller) JQ() string { return "" }
|
||||||
|
|
||||||
|
func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(fake)
|
||||||
|
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||||
|
root.PersistentFlags().Bool("yes", false, "")
|
||||||
|
root.PersistentFlags().Bool("dry-run", false, "")
|
||||||
|
root.PersistentFlags().String("format", "json", "")
|
||||||
|
root.AddCommand(shortcut.Commands()...)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
root.SetOut(&buf)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs(argv)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("execute %v: %v", argv, err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
helpers.InitDeps(fake)
|
||||||
|
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||||
|
root.PersistentFlags().Bool("yes", false, "")
|
||||||
|
root.PersistentFlags().Bool("dry-run", false, "")
|
||||||
|
root.PersistentFlags().String("format", "json", "")
|
||||||
|
root.AddCommand(shortcut.Commands()...)
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs(argv)
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSmartSearchMailNoMailbox covers the empty-mailbox path: when
|
||||||
|
// list_user_mailboxes yields no address, +search-mail must surface a clear
|
||||||
|
// "provide --email" validation error rather than silently proceeding.
|
||||||
|
func TestSmartSearchMailNoMailbox(t *testing.T) {
|
||||||
|
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||||
|
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
|
||||||
|
}}
|
||||||
|
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
|
||||||
|
t.Fatalf("want no-mailbox validation error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSmartSearchMailBackendError covers the transport-error branch of the
|
||||||
|
// mailbox resolver.
|
||||||
|
func TestSmartSearchMailBackendError(t *testing.T) {
|
||||||
|
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "list_user_mailboxes"}
|
||||||
|
if err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json"); err == nil {
|
||||||
|
t.Fatal("want backend error propagated, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreatedTodosBackendError covers the error branch of +created-todos when the
|
||||||
|
// paged todo fetch fails.
|
||||||
|
func TestCreatedTodosBackendError(t *testing.T) {
|
||||||
|
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "get_user_todos_in_current_org"}
|
||||||
|
if err := runShortcutErr(t, fake, "todo", "+created-todos", "--format", "json"); err == nil {
|
||||||
|
t.Fatal("want backend error propagated, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreatedTodosProjectsCards runs +created-todos end to end through a fake MCP
|
||||||
|
// caller. get_user_todos_in_current_org nests cards under result.todoCards; the
|
||||||
|
// shared pager (pageSize=20, not the old 50 that silently returned empty) must
|
||||||
|
// surface them into the projected "created" list.
|
||||||
|
func TestCreatedTodosProjectsCards(t *testing.T) {
|
||||||
|
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||||
|
"get_user_todos_in_current_org": `{"result":{"todoCards":[
|
||||||
|
{"subject":"待办A","taskId":"111","dueTime":1784686500000},
|
||||||
|
{"subject":"待办B","taskId":"222"}
|
||||||
|
]}}`,
|
||||||
|
}}
|
||||||
|
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
|
||||||
|
var d map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(out), &d); err != nil {
|
||||||
|
t.Fatalf("output not json: %q", out)
|
||||||
|
}
|
||||||
|
created, _ := d["created"].([]any)
|
||||||
|
if len(created) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 2 条待办,投影 created=%d (%s)", len(created), out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "111") || !strings.Contains(out, "222") {
|
||||||
|
t.Fatalf("projected rows missing taskId: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSmartSearchMailAutoResolvesMailbox runs +search-mail without --email so the
|
||||||
|
// shortcut must auto-resolve the mailbox from list_user_mailboxes
|
||||||
|
// (result.emailAccounts) before searching. Before the fix this returned
|
||||||
|
// "未找到可用邮箱" because emailAccounts was not probed.
|
||||||
|
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
|
||||||
|
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||||
|
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
|
||||||
|
"search_emails": `{"result":{"messages":[{"subject":"周报","from":"a@x.com"}]}}`,
|
||||||
|
}}
|
||||||
|
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:周报", "--format", "json")
|
||||||
|
if strings.Contains(out, "未找到可用邮箱") || out == "" {
|
||||||
|
t.Fatalf("mailbox auto-resolve failed: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "周报") {
|
||||||
|
t.Fatalf("search result not projected: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} {
|
for _, key := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "records"} {
|
||||||
raw, ok := data[key]
|
raw, ok := data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -112,23 +112,49 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records")
|
if addr := searchMailPickMailbox(data); addr != "" {
|
||||||
for _, m := range boxes {
|
return addr, nil
|
||||||
|
}
|
||||||
|
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchMailPickMailbox resolves the first usable mailbox address from a
|
||||||
|
// list_user_mailboxes response. It tolerates every observed shape: objects with
|
||||||
|
// an email field (result.emailAccounts[].email) and bare string arrays
|
||||||
|
// (result.emailAccounts[]), each at the top level or one level under a
|
||||||
|
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
|
||||||
|
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
|
||||||
|
// report that no mailbox exists.
|
||||||
|
func searchMailPickMailbox(data map[string]any) string {
|
||||||
|
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
|
||||||
|
for _, m := range searchMailUnwrapList(data, containers...) {
|
||||||
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
|
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
|
||||||
return addr, nil
|
return addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Some gateways return the addresses as a bare string list.
|
scanStrings := func(m map[string]any) string {
|
||||||
for _, key := range []string{"mailboxes", "list", "items", "data", "result"} {
|
for _, key := range containers {
|
||||||
if arr, ok := data[key].([]any); ok {
|
if arr, ok := m[key].([]any); ok {
|
||||||
for _, it := range arr {
|
for _, it := range arr {
|
||||||
if s, ok := it.(string); ok && s != "" {
|
if s, ok := it.(string); ok && s != "" {
|
||||||
return s, nil
|
return s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
|
if s := scanStrings(data); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for _, wrap := range []string{"result", "data"} {
|
||||||
|
if inner, ok := data[wrap].(map[string]any); ok {
|
||||||
|
if s := scanStrings(inner); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchMailMessages extracts the message list from a search_emails response.
|
// searchMailMessages extracts the message list from a search_emails response.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
|
||||||
|
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
|
||||||
|
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
|
||||||
|
// return empty despite the backend returning spaces.
|
||||||
|
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||||
|
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
|
||||||
|
{"workspaceId":"w1","name":"研发知识库"},
|
||||||
|
{"workspaceId":"w2","name":"产品知识库"}
|
||||||
|
]},"success":true}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if spaces := spaceListProject(data); len(spaces) != 2 {
|
||||||
|
t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,投影返回 %d 条 (%v)", len(spaces), spaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
|
||||||
|
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
|
||||||
|
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"研发知识库"}]}`
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
if spaces := spaceListProject(data); len(spaces) != 1 {
|
||||||
|
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,12 +94,15 @@ func spaceListProject(data map[string]any) []map[string]any {
|
|||||||
// wikiSpaceRawList locates the space array across candidate container keys,
|
// wikiSpaceRawList locates the space array across candidate container keys,
|
||||||
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
|
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
|
||||||
func wikiSpaceRawList(data map[string]any) []any {
|
func wikiSpaceRawList(data map[string]any) []any {
|
||||||
for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} {
|
// list_wikiSpaces / search_wikiSpaces nest the space list under
|
||||||
|
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
|
||||||
|
// MUST be probed or +space-list / +space-search silently return empty.
|
||||||
|
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
|
||||||
if arr, ok := data[k].([]any); ok {
|
if arr, ok := data[k].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
if inner, ok := data[k].(map[string]any); ok {
|
if inner, ok := data[k].(map[string]any); ok {
|
||||||
for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} {
|
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
|
||||||
if arr, ok := inner[ik].([]any); ok {
|
if arr, ok := inner[ik].([]any); ok {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,25 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from shortcut_real_result import classify_failure, summarize_failure_categories, classify_real_status, summarize_results
|
from shortcut_real_result import (
|
||||||
|
classify_failure,
|
||||||
|
summarize_failure_categories,
|
||||||
|
classify_real_status,
|
||||||
|
summarize_results,
|
||||||
|
projection_audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json"
|
OUT_PATH = Path(
|
||||||
|
os.environ.get("SHORTCUT_REAL_RESULTS_PATH", ROOT / "docs" / "shortcut-real-write-results.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_report() -> dict:
|
def load_report() -> dict:
|
||||||
@@ -39,6 +48,12 @@ def main() -> int:
|
|||||||
p.add_argument("--risk", default="write")
|
p.add_argument("--risk", default="write")
|
||||||
p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run")
|
p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run")
|
||||||
p.add_argument("--timeout", type=int, default=35)
|
p.add_argument("--timeout", type=int, default=35)
|
||||||
|
p.add_argument(
|
||||||
|
"--backend-raw",
|
||||||
|
help="Path to a JSON file holding the lower-layer MCP tool response for "
|
||||||
|
"this shortcut (captured separately). Used only in memory to compare the "
|
||||||
|
"upper projection against the lower layer; never persisted.",
|
||||||
|
)
|
||||||
p.add_argument("argv", nargs=argparse.REMAINDER)
|
p.add_argument("argv", nargs=argparse.REMAINDER)
|
||||||
ns = p.parse_args()
|
ns = p.parse_args()
|
||||||
argv = ns.argv
|
argv = ns.argv
|
||||||
@@ -47,6 +62,12 @@ def main() -> int:
|
|||||||
if not argv:
|
if not argv:
|
||||||
raise SystemExit("missing command argv")
|
raise SystemExit("missing command argv")
|
||||||
|
|
||||||
|
# Lower layer stays in memory only — we persist derived counts, never the raw
|
||||||
|
# PII-bearing backend payload.
|
||||||
|
backend_raw = None
|
||||||
|
if ns.backend_raw:
|
||||||
|
backend_raw = json.loads(Path(ns.backend_raw).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
started = time.time()
|
started = time.time()
|
||||||
status = "real-error"
|
status = "real-error"
|
||||||
stdout = ""
|
stdout = ""
|
||||||
@@ -57,7 +78,7 @@ def main() -> int:
|
|||||||
stdout = proc.stdout.strip()
|
stdout = proc.stdout.strip()
|
||||||
stderr = proc.stderr.strip()
|
stderr = proc.stderr.strip()
|
||||||
exit_code = proc.returncode
|
exit_code = proc.returncode
|
||||||
status = classify_real_status(exit_code, stdout)
|
status = classify_real_status(exit_code, stdout, backend_raw=backend_raw)
|
||||||
except subprocess.TimeoutExpired as e:
|
except subprocess.TimeoutExpired as e:
|
||||||
status = "timeout"
|
status = "timeout"
|
||||||
if isinstance(e.stdout, str):
|
if isinstance(e.stdout, str):
|
||||||
@@ -83,7 +104,17 @@ def main() -> int:
|
|||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"recorded_at": dt.datetime.now().isoformat(),
|
"recorded_at": dt.datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
category, fixability, note = classify_failure(result)
|
# Compare upper projection vs lower layer in memory; persist only the derived
|
||||||
|
# verdict (kind + counts), never the raw backend payload.
|
||||||
|
if backend_raw is not None:
|
||||||
|
audit = projection_audit({**result, "backend": backend_raw})
|
||||||
|
if audit is not None:
|
||||||
|
result["projection_audit"] = audit
|
||||||
|
|
||||||
|
# classify_failure inspects the lower layer to flag projection-data-loss, so
|
||||||
|
# hand it a copy carrying the in-memory backend; the persisted `result` keeps
|
||||||
|
# only the verdict.
|
||||||
|
category, fixability, note = classify_failure({**result, "backend": backend_raw})
|
||||||
if category != "passed":
|
if category != "passed":
|
||||||
result["failure_category"] = category
|
result["failure_category"] = category
|
||||||
result["fixability"] = fixability
|
result["fixability"] = fixability
|
||||||
|
|||||||
@@ -80,13 +80,120 @@ def payload_indicates_error(stdout: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def classify_real_status(exit_code: int | None, stdout: str, current_status: str | None = None) -> str:
|
# ---------------------------------------------------------------------------
|
||||||
|
# 上层 / 下层数据一致性(投影保真)
|
||||||
|
#
|
||||||
|
# 警示案例:contact +list-roles、oa +list-forms 在 exit 0、无 error 信封的情况下
|
||||||
|
# 依然静默返空——底层 MCP 明明有 57 个角色 / 93 张表单,投影层因为容器 key 对不上
|
||||||
|
# (result[].labels[] 未下钻 / 缺 processCodeList)把数据全吃掉了。只看上层(便捷层
|
||||||
|
# 投影输出)永远发现不了这类问题;**shortcut 务必对比上层投影与下层原始后端数据**。
|
||||||
|
#
|
||||||
|
# 判定规则因此新增一条铁律:一个只读/列表类 shortcut,若上层投影为空,必须能拿下层
|
||||||
|
# 原始响应来佐证「本就没有数据」。拿不到下层、或下层明明有数据,都不能算 real-ok。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 便捷层投影输出里承载业务列表的常见容器字段名。
|
||||||
|
_PROJECTION_LIST_KEYS = (
|
||||||
|
"roles", "forms", "users", "user", "items", "list", "records", "files",
|
||||||
|
"docs", "nodes", "messages", "conversations", "groups", "events",
|
||||||
|
"attendees", "rooms", "threads", "folders", "tags", "templates", "contacts",
|
||||||
|
"spaces", "tasks", "todos", "created", "members", "depts", "departments",
|
||||||
|
"sheets", "bases", "tables", "views", "apps", "minutes", "reports",
|
||||||
|
"comments", "attachments", "instances", "robots", "bots", "results",
|
||||||
|
"workflows", "permissions", "versions", "calendars", "cards",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_projection_items(stdout: str) -> int | None:
|
||||||
|
"""从便捷层投影输出里数出条目数(上层)。
|
||||||
|
|
||||||
|
返回 None 表示这段输出不像一个「列表型」投影(例如详情命令、纯文本、写操作
|
||||||
|
回执),此时不参与空投影判定。识别到列表型投影时返回其条目数(可能为 0)。
|
||||||
|
"""
|
||||||
|
best: int | None = None
|
||||||
|
for value in _json_values(stdout):
|
||||||
|
if isinstance(value, list):
|
||||||
|
best = max(best or 0, len(value))
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
count_field = value.get("count")
|
||||||
|
if isinstance(count_field, bool):
|
||||||
|
count_field = None
|
||||||
|
if isinstance(count_field, int):
|
||||||
|
best = max(best or 0, count_field)
|
||||||
|
for key in _PROJECTION_LIST_KEYS:
|
||||||
|
member = value.get(key)
|
||||||
|
if isinstance(member, list):
|
||||||
|
best = max(best or 0, len(member))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def backend_record_count(raw: Any, depth: int = 0) -> int:
|
||||||
|
"""递归估算下层原始后端响应里的业务条目数(下层真值)。
|
||||||
|
|
||||||
|
取任意深度下「最大的一个对象数组」的长度作为「后端是否有数据」的代理指标——
|
||||||
|
足以判定投影是否把非空的底层数据吃成了空。
|
||||||
|
"""
|
||||||
|
if depth > 6 or raw is None:
|
||||||
|
return 0
|
||||||
|
best = 0
|
||||||
|
if isinstance(raw, list):
|
||||||
|
obj_items = sum(1 for item in raw if isinstance(item, dict))
|
||||||
|
best = max(best, obj_items)
|
||||||
|
for item in raw:
|
||||||
|
best = max(best, backend_record_count(item, depth + 1))
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
for value in raw.values():
|
||||||
|
best = max(best, backend_record_count(value, depth + 1))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_payload(result: dict[str, Any]) -> Any:
|
||||||
|
"""取出随 result 一起记录的下层原始后端响应(若采集了的话)。
|
||||||
|
|
||||||
|
约定字段:backend_raw / raw_backend / backend / lower_layer / backend_stdout。
|
||||||
|
backend_stdout 是字符串时按 JSON 解析。"""
|
||||||
|
for key in ("backend_raw", "raw_backend", "backend", "lower_layer"):
|
||||||
|
if key in result and result[key] not in (None, "", {}, []):
|
||||||
|
return result[key]
|
||||||
|
text = result.get("backend_stdout")
|
||||||
|
if isinstance(text, str) and text.strip():
|
||||||
|
values = _json_values(text)
|
||||||
|
if values:
|
||||||
|
return values[0] if len(values) == 1 else values
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_layers(shortcut_stdout: str, backend_raw: Any) -> tuple[bool, int | None, int]:
|
||||||
|
"""对比上层投影与下层原始后端数据。
|
||||||
|
|
||||||
|
返回 (是否投影吃了数据, 上层条目数, 下层条目数)。当上层是列表型投影且为空、
|
||||||
|
而下层明显有对象数组时,判定为投影数据丢失(True)。"""
|
||||||
|
upper = count_projection_items(shortcut_stdout)
|
||||||
|
lower = backend_record_count(backend_raw)
|
||||||
|
lost = upper == 0 and lower > 0
|
||||||
|
return lost, upper, lower
|
||||||
|
|
||||||
|
|
||||||
|
def classify_real_status(
|
||||||
|
exit_code: int | None,
|
||||||
|
stdout: str,
|
||||||
|
current_status: str | None = None,
|
||||||
|
*,
|
||||||
|
backend_raw: Any = None,
|
||||||
|
) -> str:
|
||||||
if current_status in {"timeout", "held"}:
|
if current_status in {"timeout", "held"}:
|
||||||
return current_status
|
return current_status
|
||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
return "real-error"
|
return "real-error"
|
||||||
if payload_indicates_error(stdout):
|
if payload_indicates_error(stdout):
|
||||||
return "real-error"
|
return "real-error"
|
||||||
|
# 上下层比对:下层有数据但上层投影为空 = 投影吃数据,绝不能算通过。
|
||||||
|
if backend_raw is not None:
|
||||||
|
lost, _upper, _lower = compare_layers(stdout, backend_raw)
|
||||||
|
if lost:
|
||||||
|
return "real-error"
|
||||||
return "real-ok"
|
return "real-ok"
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +204,22 @@ def _haystack(result: dict[str, Any]) -> str:
|
|||||||
def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]:
|
def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]:
|
||||||
"""Return (category, fixability, note) for a real-test result."""
|
"""Return (category, fixability, note) for a real-test result."""
|
||||||
status = result.get("status")
|
status = result.get("status")
|
||||||
|
|
||||||
|
# 上下层比对优先:即便进程 exit 0、无 error 信封、status 被记成 real-ok,只要
|
||||||
|
# 采集到的下层原始后端有数据而上层投影为空,就是投影把数据吃了——判定为
|
||||||
|
# projection-data-loss,属于 shortcut 自身可修的 bug(改投影层的容器 key/下钻)。
|
||||||
|
backend_raw = _backend_payload(result)
|
||||||
|
if backend_raw is not None and result.get("exit_code") in (0, None):
|
||||||
|
lost, upper, lower = compare_layers(result.get("stdout") or "", backend_raw)
|
||||||
|
if lost:
|
||||||
|
return (
|
||||||
|
"projection-data-loss",
|
||||||
|
"cli-projection-fix-needed",
|
||||||
|
f"上层便捷层投影为空({upper} 条),但下层原始后端有数据(约 {lower} 条);"
|
||||||
|
"投影层的容器 key/嵌套下钻与真实响应结构不匹配,把非空数据吃成了空。"
|
||||||
|
"需修投影层解析,并补「喂真实响应结构、断言非空」的单测。",
|
||||||
|
)
|
||||||
|
|
||||||
if status == "real-ok":
|
if status == "real-ok":
|
||||||
return ("passed", "fixed", "真实后端执行成功。")
|
return ("passed", "fixed", "真实后端执行成功。")
|
||||||
if status == "timeout":
|
if status == "timeout":
|
||||||
@@ -263,6 +386,38 @@ def summarize_failure_categories(results: list[dict[str, Any]]) -> dict[str, int
|
|||||||
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
|
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
|
||||||
|
|
||||||
|
|
||||||
|
def projection_audit(result: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""投影保真审计:对只读/列表类 shortcut 强制「上下层比对」这条铁律。
|
||||||
|
|
||||||
|
返回 None 表示无需关注;否则返回一个 warning dict 供报告展示:
|
||||||
|
- projection-data-loss:下层有数据、上层空 —— 确定的投影吃数据 bug;
|
||||||
|
- empty-projection-unverified:上层投影为空但缺下层采集 —— 无法证明「本就为空」,
|
||||||
|
必须补采下层原始响应(原始 MCP 响应 / 对应 leaf 命令)再比对,禁止直接判 real-ok。
|
||||||
|
"""
|
||||||
|
stdout = result.get("stdout") or ""
|
||||||
|
upper = count_projection_items(stdout)
|
||||||
|
if upper is None or upper > 0:
|
||||||
|
return None # 非列表型投影,或上层本就有数据 —— 最常见的正常情形。
|
||||||
|
backend_raw = _backend_payload(result)
|
||||||
|
if backend_raw is None:
|
||||||
|
return {
|
||||||
|
"kind": "empty-projection-unverified",
|
||||||
|
"upper_count": upper,
|
||||||
|
"backend_count": None,
|
||||||
|
"note": "便捷层投影为空,但未采集下层原始后端响应,无法证明底层确实无数据;"
|
||||||
|
"按铁律必须补采下层后再比对,不能直接判 real-ok。",
|
||||||
|
}
|
||||||
|
lost, _upper, lower = compare_layers(stdout, backend_raw)
|
||||||
|
if lost:
|
||||||
|
return {
|
||||||
|
"kind": "projection-data-loss",
|
||||||
|
"upper_count": upper,
|
||||||
|
"backend_count": lower,
|
||||||
|
"note": "下层原始后端有数据但上层投影为空:投影层解析与真实响应结构不匹配。",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE)
|
_OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE)
|
||||||
_ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b")
|
_ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b")
|
||||||
_OSS_QUERY_KEY_RE = re.compile(
|
_OSS_QUERY_KEY_RE = re.compile(
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Self-checking tests for scripts/shortcut_real_result.py.
|
||||||
|
|
||||||
|
Run: python3 scripts/shortcut_real_result_test.py
|
||||||
|
|
||||||
|
Focus: the upper/lower layer comparison rules added after the
|
||||||
|
contact +list-roles / oa +list-forms projection-data-loss case, plus a
|
||||||
|
regression guard that pre-existing exit-code / error-envelope behaviour is
|
||||||
|
unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from shortcut_real_result import (
|
||||||
|
backend_record_count,
|
||||||
|
classify_failure,
|
||||||
|
classify_real_status,
|
||||||
|
compare_layers,
|
||||||
|
count_projection_items,
|
||||||
|
payload_indicates_error,
|
||||||
|
projection_audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Faithful backend shapes from the real case.
|
||||||
|
GROUPED_ROLES = {
|
||||||
|
"result": [
|
||||||
|
{"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]},
|
||||||
|
{"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
PROCESS_CODE_LIST = {
|
||||||
|
"result": {
|
||||||
|
"processCodeList": [
|
||||||
|
{"processCode": "PROC-1", "processName": "请假"},
|
||||||
|
{"processCode": "PROC-2", "processName": "加班"},
|
||||||
|
],
|
||||||
|
"totalCount": -1,
|
||||||
|
},
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []})
|
||||||
|
FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]})
|
||||||
|
DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"})
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
if not cond:
|
||||||
|
raise AssertionError(f"FAIL: {name}")
|
||||||
|
print(f" ok: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_projection_items():
|
||||||
|
check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0)
|
||||||
|
check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2)
|
||||||
|
check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None)
|
||||||
|
check("bare array -> len", count_projection_items("[1,2,3]") == 3)
|
||||||
|
check("empty string -> None", count_projection_items("") is None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_record_count():
|
||||||
|
# backend_record_count is a "has data" proxy = size of the largest single
|
||||||
|
# object array found at any depth (not the flattened total).
|
||||||
|
check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2)
|
||||||
|
check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2)
|
||||||
|
check("empty backend -> 0", backend_record_count({"result": []}) == 0)
|
||||||
|
check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_layers():
|
||||||
|
lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES)
|
||||||
|
check("roles: data loss detected", lost and upper == 0 and lower == 2)
|
||||||
|
lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES)
|
||||||
|
check("roles: no loss when upper populated", not lost2)
|
||||||
|
lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []})
|
||||||
|
check("roles: no loss when backend also empty", not lost3 and lower3 == 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_real_status():
|
||||||
|
# Backward-compat: without backend_raw, empty projection is still real-ok.
|
||||||
|
check(
|
||||||
|
"legacy empty projection stays real-ok",
|
||||||
|
classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok",
|
||||||
|
)
|
||||||
|
# With lower layer that has data, empty projection becomes real-error.
|
||||||
|
check(
|
||||||
|
"empty projection + backend data -> real-error",
|
||||||
|
classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error",
|
||||||
|
)
|
||||||
|
# Populated projection stays ok even with backend.
|
||||||
|
check(
|
||||||
|
"full projection + backend -> real-ok",
|
||||||
|
classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok",
|
||||||
|
)
|
||||||
|
# Regression: non-zero exit and error envelope untouched.
|
||||||
|
check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error")
|
||||||
|
check(
|
||||||
|
"error envelope -> real-error",
|
||||||
|
classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error",
|
||||||
|
)
|
||||||
|
check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout")
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_failure_projection_loss():
|
||||||
|
result = {
|
||||||
|
"status": "real-ok", # process exited 0, looked fine
|
||||||
|
"exit_code": 0,
|
||||||
|
"service": "contact",
|
||||||
|
"command": "+list-roles",
|
||||||
|
"stdout": EMPTY_ROLES_PROJECTION,
|
||||||
|
"backend_stdout": json.dumps(GROUPED_ROLES),
|
||||||
|
}
|
||||||
|
category, fixability, _ = classify_failure(result)
|
||||||
|
check("projection-data-loss category", category == "projection-data-loss")
|
||||||
|
check("cli fixable", fixability == "cli-projection-fix-needed")
|
||||||
|
|
||||||
|
# No loss when projection is populated.
|
||||||
|
ok_result = dict(result, stdout=FULL_ROLES_PROJECTION)
|
||||||
|
check("populated -> passed", classify_failure(ok_result)[0] == "passed")
|
||||||
|
|
||||||
|
# Regression: a normal real-ok with no backend capture still passes.
|
||||||
|
check(
|
||||||
|
"plain real-ok passes",
|
||||||
|
classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_audit():
|
||||||
|
# Empty projection, no backend -> must-verify warning.
|
||||||
|
warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION})
|
||||||
|
check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified")
|
||||||
|
|
||||||
|
# Empty projection + backend data -> data loss.
|
||||||
|
warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES})
|
||||||
|
check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss")
|
||||||
|
|
||||||
|
# Populated projection -> no warning.
|
||||||
|
check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None)
|
||||||
|
|
||||||
|
# Detail (non-list) projection -> no warning.
|
||||||
|
check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_indicates_error_regression():
|
||||||
|
check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION))
|
||||||
|
check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1})))
|
||||||
|
check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0})))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_run_wires_lower_layer():
|
||||||
|
"""End-to-end: record_real_shortcut_run.py must capture the lower layer and
|
||||||
|
flag an exit-0 empty projection as projection-data-loss (not real-ok)."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
recorder = os.path.join(here, "record_real_shortcut_run.py")
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
backend = os.path.join(d, "backend.json")
|
||||||
|
out = os.path.join(d, "out.json")
|
||||||
|
# Lower layer has 3 roles nested under result[].labels[].
|
||||||
|
with open(backend, "w") as f:
|
||||||
|
json.dump(GROUPED_ROLES, f)
|
||||||
|
env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out)
|
||||||
|
# Upper command: an empty projection (exit 0, no error envelope).
|
||||||
|
argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"]
|
||||||
|
p = subprocess.run(
|
||||||
|
["python3", recorder, "--service", "contact", "--command", "+list-roles",
|
||||||
|
"--risk", "read", "--backend-raw", backend, "--"] + argv,
|
||||||
|
capture_output=True, text=True, env=env,
|
||||||
|
)
|
||||||
|
check("recorder exits non-zero on data loss", p.returncode == 2)
|
||||||
|
rec = json.loads(p.stdout)
|
||||||
|
check("status flipped to real-error", rec["status"] == "real-error")
|
||||||
|
check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss")
|
||||||
|
check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss")
|
||||||
|
# Raw backend payload must NOT be persisted.
|
||||||
|
persisted = json.load(open(out))
|
||||||
|
blob = json.dumps(persisted, ensure_ascii=False)
|
||||||
|
check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tests = [
|
||||||
|
test_count_projection_items,
|
||||||
|
test_backend_record_count,
|
||||||
|
test_compare_layers,
|
||||||
|
test_classify_real_status,
|
||||||
|
test_classify_failure_projection_loss,
|
||||||
|
test_projection_audit,
|
||||||
|
test_payload_indicates_error_regression,
|
||||||
|
test_record_run_wires_lower_layer,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"{t.__name__}:")
|
||||||
|
t()
|
||||||
|
print(f"\nAll {len(tests)} test groups passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package scripts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShortcutRealResultSelfTest wires scripts/shortcut_real_result_test.py into
|
||||||
|
// `go test ./...` so the upper/lower projection-audit rules (and the end-to-end
|
||||||
|
// record_real_shortcut_run.py integration) are enforced in CI, not manual-only.
|
||||||
|
func TestShortcutRealResultSelfTest(t *testing.T) {
|
||||||
|
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo root: %v", err)
|
||||||
|
}
|
||||||
|
py, err := exec.LookPath("python3")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("python3 not available: %v", err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(py, filepath.Join("scripts", "shortcut_real_result_test.py"))
|
||||||
|
cmd.Dir = root
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("shortcut_real_result_test.py failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user