Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01476727e9 | ||
|
|
8d34acbb38 |
@@ -1,7 +1,57 @@
|
||||
{
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"generated_at": "2026-08-24T20:14:49.172788",
|
||||
"count": 437,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+contract-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+obj-template-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-statistics-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-submit-detail",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+user-rules",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
@@ -2473,137 +2523,254 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+credentials-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+disable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+enable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-subscribe",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+permission-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-config",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-check-approval",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-config",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 425
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 482
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public
|
||||
// surface intentionally stays smaller than the atomic helper surface: only
|
||||
// reads with strict response contracts and safe live proof are available.
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const productAgoal = "agoal"
|
||||
|
||||
var agoalLoadLocation = time.LoadLocation
|
||||
|
||||
func agoalResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空")
|
||||
}
|
||||
rawSuccess, present := data["success"]
|
||||
if !present {
|
||||
return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态")
|
||||
}
|
||||
success, ok := rawSuccess.(bool)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message, _ := data["message"].(string)
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, agoalResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
for _, key := range []string{"error", "errorCode", "error_code"} {
|
||||
if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" {
|
||||
return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key))
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index))
|
||||
}
|
||||
if agoalFirstString(item, idKeys...) == "" {
|
||||
return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index))
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func agoalFirstString(item map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func agoalNonNegativeInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, typed >= 0
|
||||
case int64:
|
||||
if typed < 0 || int64(int(typed)) != typed {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return 0, false
|
||||
}
|
||||
return int(parsed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func agoalReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func agoalUnavailableSafety(write bool) contract.SafetySpec {
|
||||
if write {
|
||||
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
return agoalReadSafety()
|
||||
}
|
||||
|
||||
func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec {
|
||||
properties := map[string]any{
|
||||
"count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"},
|
||||
collection: map[string]any{
|
||||
"type": "array", "description": description,
|
||||
"items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true},
|
||||
},
|
||||
}
|
||||
required := []string{"count", collection}
|
||||
if paged {
|
||||
properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"}
|
||||
properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"}
|
||||
properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"}
|
||||
required = append(required, "page", "pageSize", "totalCount")
|
||||
}
|
||||
schema, _ := json.Marshal(map[string]any{
|
||||
"type": "object", "description": description, "additionalProperties": false,
|
||||
"properties": properties, "required": required,
|
||||
})
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: schema,
|
||||
SensitivePaths: append([]string(nil), sensitivePaths...),
|
||||
}
|
||||
}
|
||||
|
||||
var ReportStatisticsList = shortcut.Shortcut{
|
||||
Service: productAgoal,
|
||||
Command: "+report-statistics-list",
|
||||
Product: productAgoal,
|
||||
Description: "查询周月报规则提交统计",
|
||||
Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_report_statistics_list",
|
||||
CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list",
|
||||
},
|
||||
Description: "查询周月报规则提交统计",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询周月报规则提交统计",
|
||||
UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"},
|
||||
AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"},
|
||||
Examples: []string{"dws agoal +report-statistics-list --keyword <KEYWORD>"},
|
||||
},
|
||||
Result: agoalListResult("statistics", "周月报规则提交统计记录", false,
|
||||
"statistics.title", "statistics.lastModifier", "statistics.content.name"),
|
||||
},
|
||||
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("keyword") {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "statistics": items})
|
||||
},
|
||||
}
|
||||
|
||||
var ObjectTemplateList = shortcut.Shortcut{
|
||||
Service: productAgoal,
|
||||
Command: "+obj-template-list",
|
||||
Product: productAgoal,
|
||||
Description: "分页查询 Agoal 目标模板",
|
||||
Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_obj_template_list",
|
||||
CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list",
|
||||
},
|
||||
Description: "分页查询 Agoal 目标模板",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页查询 Agoal 目标模板",
|
||||
UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"},
|
||||
AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"},
|
||||
Examples: []string{"dws agoal +obj-template-list --keyword <KEYWORD> --page 1 --page-size 20"},
|
||||
},
|
||||
Result: agoalListResult("templates", "Agoal 目标模板查询结果", true,
|
||||
"templates.title", "templates.creator", "templates.dimensions.title"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于等于 1")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
wantPage := rt.Int("page")
|
||||
wantSize := rt.Int("page-size")
|
||||
params := map[string]any{"page": wantPage, "pageSize": wantSize}
|
||||
if rt.Changed("keyword") {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil {
|
||||
return err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象")
|
||||
}
|
||||
items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, pageOK := agoalNonNegativeInt(content["page"])
|
||||
pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"])
|
||||
total, totalOK := agoalNonNegativeInt(content["totalCount"])
|
||||
if !pageOK || !pageSizeOK || !totalOK {
|
||||
return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
|
||||
}
|
||||
if page != wantPage || pageSize != wantSize {
|
||||
return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致")
|
||||
}
|
||||
if total < len(items) {
|
||||
return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数")
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(items), "templates": items,
|
||||
"page": page, "pageSize": pageSize, "totalCount": total,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ContractFields = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+contract-fields", Product: productAgoal,
|
||||
Description: "查询经营合约字段配置",
|
||||
Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_contract_fields",
|
||||
CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields",
|
||||
},
|
||||
Description: "查询并严格筛选经营合约字段配置",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询经营合约字段配置",
|
||||
UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"},
|
||||
AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"},
|
||||
Examples: []string{"dws agoal +contract-fields --keyword <KEYWORD>"},
|
||||
},
|
||||
Result: agoalListResult("fields", "经营合约字段配置", false),
|
||||
},
|
||||
Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type")
|
||||
return rt.Output(map[string]any{"count": len(fields), "fields": fields})
|
||||
},
|
||||
}
|
||||
|
||||
var UserRules = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+user-rules", Product: productAgoal,
|
||||
Description: "查询用户 Agoal 规则周期",
|
||||
Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_user_rules",
|
||||
CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules",
|
||||
},
|
||||
Description: "查询用户规则周期并可按稳定 ruleId 精确筛选",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询用户 Agoal 规则周期",
|
||||
UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"},
|
||||
AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"},
|
||||
Examples: []string{"dws agoal +user-rules --rule-id <RULE_ID>"},
|
||||
},
|
||||
Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false,
|
||||
"rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"},
|
||||
{Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("user-id") {
|
||||
params["dingUserId"] = rt.Str("user-id")
|
||||
}
|
||||
raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil {
|
||||
return err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象")
|
||||
}
|
||||
rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wantRule := strings.TrimSpace(rt.Str("rule-id"))
|
||||
if wantRule != "" {
|
||||
filtered := make([]map[string]any, 0, 1)
|
||||
for _, rule := range rules {
|
||||
if agoalFirstString(rule, "id") == wantRule {
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(rules), "rules": rules})
|
||||
},
|
||||
}
|
||||
|
||||
var ReportSubmitDetail = shortcut.Shortcut{
|
||||
Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal,
|
||||
Description: "分页查询周月报人员提交详情",
|
||||
Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: agoalReadSafety(),
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: productAgoal, Name: "shortcut_report_submit_detail",
|
||||
CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail",
|
||||
},
|
||||
Description: "分页查询周月报人员提交详情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available",
|
||||
Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页查询周月报人员提交详情",
|
||||
UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"},
|
||||
AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"},
|
||||
Examples: []string{"dws agoal +report-submit-detail --template-id <TEMPLATE_ID> --submit-state NOT_SUBMITTED --page 1 --page-size 20"},
|
||||
},
|
||||
Result: agoalListResult("submissions", "周月报人员提交详情", true,
|
||||
"submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true},
|
||||
{Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}},
|
||||
{Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于等于 1")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
if rt.Changed("query-date") {
|
||||
if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
wantPage, wantSize := rt.Int("page"), rt.Int("page-size")
|
||||
baseParams := map[string]any{
|
||||
"templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")),
|
||||
}
|
||||
if rt.Changed("query-date") {
|
||||
// Validate has already accepted this exact immutable Cobra value.
|
||||
queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date"))
|
||||
baseParams["queryDate"] = queryDate
|
||||
}
|
||||
keyword := strings.TrimSpace(rt.Str("keyword"))
|
||||
if keyword == "" {
|
||||
items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total})
|
||||
}
|
||||
all, err := readAllAgoalSubmissions(rt, baseParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := filterAgoalSubmissions(all, keyword)
|
||||
start := len(filtered)
|
||||
pageOffset := wantPage - 1
|
||||
if pageOffset <= len(filtered)/wantSize {
|
||||
start = pageOffset * wantSize
|
||||
}
|
||||
end := start + wantSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
items := filtered[start:end]
|
||||
return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)})
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
agoalSubmissionReadPageSize = 100
|
||||
agoalSubmissionMaxPages = 20
|
||||
)
|
||||
|
||||
func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) {
|
||||
params := make(map[string]any, len(baseParams)+2)
|
||||
for key, value := range baseParams {
|
||||
params[key] = value
|
||||
}
|
||||
params["page"] = page
|
||||
params["pageSize"] = pageSize
|
||||
raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
content, ok := raw["content"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象")
|
||||
}
|
||||
items, err := requireAgoalSubmissionList(content["result"])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
gotPage, pageOK := agoalNonNegativeInt(content["page"])
|
||||
gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"])
|
||||
total, totalOK := agoalNonNegativeInt(content["totalCount"])
|
||||
if !pageOK || !sizeOK || !totalOK {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount")
|
||||
}
|
||||
if gotPage != page || gotSize != pageSize {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致")
|
||||
}
|
||||
if total < len(items) {
|
||||
return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数")
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
seen := map[string]bool{}
|
||||
wantTotal := -1
|
||||
for page := 1; page <= agoalSubmissionMaxPages; page++ {
|
||||
items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wantTotal < 0 {
|
||||
wantTotal = total
|
||||
} else if total != wantTotal {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化")
|
||||
}
|
||||
for _, item := range items {
|
||||
identity := agoalSubmissionIdentity(item)
|
||||
if seen[identity] {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份")
|
||||
}
|
||||
seen[identity] = true
|
||||
all = append(all, item)
|
||||
}
|
||||
if len(all) == wantTotal {
|
||||
return all, nil
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进")
|
||||
}
|
||||
}
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限")
|
||||
}
|
||||
|
||||
func agoalSubmissionIdentity(item map[string]any) string {
|
||||
if reportID := agoalFirstString(item, "reportId"); reportID != "" {
|
||||
return "report:" + reportID
|
||||
}
|
||||
user, _ := item["user"].(map[string]any)
|
||||
return "user:" + agoalFirstString(user, "dingUserId", "id")
|
||||
}
|
||||
|
||||
func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
user, _ := item["user"].(map[string]any)
|
||||
if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
for _, key := range keys {
|
||||
value, ok := item[key].(string)
|
||||
if ok && strings.Contains(strings.ToLower(value), keyword) {
|
||||
out = append(out, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requireAgoalSubmissionList(value any) ([]map[string]any, error) {
|
||||
rawItems, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rawItems))
|
||||
for index, raw := range rawItems {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index))
|
||||
}
|
||||
user, ok := item["user"].(map[string]any)
|
||||
if !ok || agoalFirstString(user, "dingUserId", "id") == "" {
|
||||
return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index))
|
||||
}
|
||||
projectedUser := map[string]any{}
|
||||
for _, key := range []string{"dingUserId", "id", "name", "workNo"} {
|
||||
if value, found := user[key]; found {
|
||||
projectedUser[key] = value
|
||||
}
|
||||
}
|
||||
projected := map[string]any{"user": projectedUser}
|
||||
for _, key := range []string{"reportId", "publishTime"} {
|
||||
if value, found := item[key]; found {
|
||||
projected[key] = value
|
||||
}
|
||||
}
|
||||
out = append(out, projected)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeAgoalQueryDate(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||
return parsed.Format("2006-01-02"), nil
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
location, loadErr := agoalLoadLocation("Asia/Shanghai")
|
||||
if loadErr != nil {
|
||||
location = time.FixedZone("CST", 8*60*60)
|
||||
}
|
||||
return parsed.In(location).Format("2006-01-02"), nil
|
||||
}
|
||||
return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间")
|
||||
}
|
||||
|
||||
type unavailableAgoalSpec struct {
|
||||
command string
|
||||
description string
|
||||
intent string
|
||||
tool string
|
||||
flags []shortcut.Flag
|
||||
write bool
|
||||
reason string
|
||||
}
|
||||
|
||||
func agoalStringFlag(name, description string, required bool) shortcut.Flag {
|
||||
return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required}
|
||||
}
|
||||
|
||||
func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut {
|
||||
flagNames := make([]string, 0, len(spec.flags))
|
||||
for _, flag := range spec.flags {
|
||||
flagNames = append(flagNames, flag.Name)
|
||||
}
|
||||
unavailable := func(*shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason)
|
||||
}
|
||||
return shortcut.Shortcut{
|
||||
Service: productAgoal, Command: spec.command, Product: productAgoal,
|
||||
Description: spec.description, Intent: spec.intent,
|
||||
Risk: func() shortcut.Risk {
|
||||
if spec.write {
|
||||
return shortcut.RiskHighWrite
|
||||
}
|
||||
return shortcut.RiskRead
|
||||
}(),
|
||||
Safety: agoalUnavailableSafety(spec.write),
|
||||
Flags: spec.flags,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: flagNames,
|
||||
Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足",
|
||||
}},
|
||||
SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason,
|
||||
Validate: unavailable,
|
||||
Execute: unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
var unavailableAgoalSpecs = []unavailableAgoalSpec{
|
||||
{command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{
|
||||
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
|
||||
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
|
||||
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
|
||||
{command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("profile-id", "战略解码稳定 ID", true),
|
||||
}, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
|
||||
{command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{
|
||||
{Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}},
|
||||
agoalStringFlag("scope-id", "部门或人员稳定 ID", true),
|
||||
}, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"},
|
||||
{command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("contract-id", "经营合约稳定 ID", true),
|
||||
}, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"},
|
||||
{command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true),
|
||||
}, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"},
|
||||
{command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{
|
||||
agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true),
|
||||
}, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"},
|
||||
{command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true),
|
||||
{Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}},
|
||||
agoalStringFlag("content", "完整计分卡 JSON 数组", true),
|
||||
}, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"},
|
||||
{command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{
|
||||
agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true),
|
||||
}, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"},
|
||||
{command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{
|
||||
agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true),
|
||||
{Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"},
|
||||
{Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"},
|
||||
{Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"},
|
||||
}, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"},
|
||||
}
|
||||
|
||||
func init() {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: productAgoal,
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放",
|
||||
UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"},
|
||||
AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"},
|
||||
},
|
||||
})
|
||||
items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail}
|
||||
for _, spec := range unavailableAgoalSpecs {
|
||||
items = append(items, unavailableAgoalShortcut(spec))
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].Availability != shortcut.AvailabilityUnavailable {
|
||||
items[index].OutputRollout = output.RolloutUnifiedActive
|
||||
}
|
||||
}
|
||||
shortcut.Register(items...)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{name: "int", value: 3, want: 3, ok: true},
|
||||
{name: "negative int", value: -1, want: -1},
|
||||
{name: "int64", value: int64(4), want: 4, ok: true},
|
||||
{name: "negative int64", value: int64(-1)},
|
||||
{name: "float", value: float64(5), want: 5, ok: true},
|
||||
{name: "negative float", value: float64(-1)},
|
||||
{name: "fraction", value: 1.5},
|
||||
{name: "huge float", value: math.MaxFloat64},
|
||||
{name: "json number", value: json.Number("6"), want: 6, ok: true},
|
||||
{name: "bad json number", value: json.Number("1.5")},
|
||||
{name: "negative json number", value: json.Number("-1")},
|
||||
{name: "wrong type", value: "7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := agoalNonNegativeInt(tc.value)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
items := []map[string]any{{"id": "one", "name": "Known"}}
|
||||
if got := filterAgoalItems(items, "", "name"); len(got) != 1 {
|
||||
t.Fatalf("empty item filter = %#v", got)
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, ""); len(got) != 1 {
|
||||
t.Fatalf("empty submission filter = %#v", got)
|
||||
}
|
||||
if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" {
|
||||
t.Fatalf("fallback identity = %q", got)
|
||||
}
|
||||
|
||||
for _, bad := range []any{
|
||||
nil,
|
||||
[]any{"bad"},
|
||||
[]any{map[string]any{"user": "bad"}},
|
||||
[]any{map[string]any{"user": map[string]any{"name": "missing identity"}}},
|
||||
} {
|
||||
if _, err := requireAgoalSubmissionList(bad); err == nil {
|
||||
t.Fatalf("malformed submission list accepted: %#v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) {
|
||||
if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" {
|
||||
t.Fatalf("date normalization = %q, %v", got, err)
|
||||
}
|
||||
if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" {
|
||||
t.Fatalf("RFC3339 normalization = %q, %v", got, err)
|
||||
}
|
||||
testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) {
|
||||
return nil, errors.New("fixture location failure")
|
||||
})
|
||||
if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" {
|
||||
t.Fatalf("fallback timezone normalization = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
response map[string]any
|
||||
}{
|
||||
{name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"},
|
||||
{name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}},
|
||||
{name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
{name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}},
|
||||
{name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}},
|
||||
{name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"},
|
||||
{name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}},
|
||||
{name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"},
|
||||
{name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}},
|
||||
{name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
{name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}},
|
||||
{name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}},
|
||||
{name: "rules transport", declaration: UserRules, tool: "get_user_rules"},
|
||||
{name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}},
|
||||
{name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}},
|
||||
{name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"},
|
||||
{name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}},
|
||||
{name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}},
|
||||
{name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}},
|
||||
{name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}},
|
||||
{name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}},
|
||||
{name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{}
|
||||
if tc.response != nil {
|
||||
caller.responses = map[string][]map[string]any{tc.tool: {tc.response}}
|
||||
}
|
||||
if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("failure fixture was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, caller); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) {
|
||||
responses := make([]map[string]any, 0, agoalSubmissionMaxPages)
|
||||
for page := 1; page <= agoalSubmissionMaxPages; page++ {
|
||||
responses = append(responses, map[string]any{"success": true, "content": map[string]any{
|
||||
"page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1,
|
||||
"result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}},
|
||||
}})
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, caller,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
|
||||
t.Fatal("bounded pagination limit was accepted as complete")
|
||||
}
|
||||
|
||||
scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"content": map[string]any{}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil {
|
||||
t.Fatal("scan page validation failure was accepted")
|
||||
}
|
||||
|
||||
dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" {
|
||||
t.Fatalf("queryDate = %#v", got)
|
||||
}
|
||||
|
||||
laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage,
|
||||
"--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing",
|
||||
"--page", strconv.Itoa(int(^uint(0)>>1))); err != nil {
|
||||
t.Fatalf("maximum page must project an empty local page without overflowing: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package agoal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type agoalCoverageCall struct {
|
||||
tool string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type agoalCoverageCaller struct {
|
||||
responses map[string][]map[string]any
|
||||
calls []agoalCoverageCall
|
||||
}
|
||||
|
||||
func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params})
|
||||
queue := c.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("unexpected agoal coverage call: " + tool)
|
||||
}
|
||||
payload := queue[0]
|
||||
c.responses[tool] = queue[1:]
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
|
||||
}
|
||||
|
||||
func (*agoalCoverageCaller) Format() string { return "json" }
|
||||
func (*agoalCoverageCaller) DryRun() bool { return false }
|
||||
func (*agoalCoverageCaller) Fields() string { return "" }
|
||||
func (*agoalCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: productAgoal}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{productAgoal, declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func agoalRegistered(command string) (shortcut.Shortcut, bool) {
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == productAgoal && item.Command == command {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return shortcut.Shortcut{}, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "empty", data: nil},
|
||||
{name: "missing success", data: map[string]any{"content": []any{}}},
|
||||
{name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}},
|
||||
{name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}},
|
||||
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil {
|
||||
t.Fatal("malformed response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{name: "missing collection", value: nil},
|
||||
{name: "wrong collection", value: map[string]any{}},
|
||||
{name: "bad element", value: []any{"bad"}},
|
||||
{name: "missing id", value: []any{map[string]any{"title": "template"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil {
|
||||
t.Fatal("malformed collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) {
|
||||
t.Run("report nonempty", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report legal zero", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_report_statistics": {{"success": true, "content": []any{}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("template nonempty page", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_obj_template": {{"success": true, "content": map[string]any{
|
||||
"page": 2, "pageSize": 5, "totalCount": 6,
|
||||
"result": []any{map[string]any{"id": "template"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("template rejects false pagination", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_obj_template": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 0,
|
||||
"result": []any{map[string]any{"id": "impossible"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil {
|
||||
t.Fatal("impossible pagination total was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) {
|
||||
t.Run("contract fields known and guaranteed zero", func(t *testing.T) {
|
||||
items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}}
|
||||
if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 {
|
||||
t.Fatalf("known field projection = %#v", got)
|
||||
}
|
||||
if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 {
|
||||
t.Fatalf("zero field projection = %#v", got)
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_op_contract_fields": {{"success": true, "content": []any{items[0]}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user rules exact filter", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{
|
||||
"rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
zero := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_user_rules": {{"success": true, "content": map[string]any{
|
||||
"rules": []any{map[string]any{"id": "rule-1"}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report submission page and privacy identity", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": 20, "totalCount": 1,
|
||||
"result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
|
||||
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20,
|
||||
}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scans then filters locally", func(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}},
|
||||
{"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}},
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, "known"); len(got) != 1 {
|
||||
t.Fatalf("known submission projection = %#v", got)
|
||||
}
|
||||
if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 {
|
||||
t.Fatalf("zero submission projection = %#v", got)
|
||||
}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": map[string]any{
|
||||
"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2,
|
||||
"result": []any{items[0], items[1]},
|
||||
}}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{
|
||||
"templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize,
|
||||
}}}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) {
|
||||
item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("duplicate submission pagination was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects changing total", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("changing pagination total was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report keyword scan rejects stalled page", func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {
|
||||
{"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}},
|
||||
{"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}},
|
||||
},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil {
|
||||
t.Fatal("stalled pagination was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) {
|
||||
items, err := requireAgoalSubmissionList([]any{map[string]any{
|
||||
"reportId": "report", "unreviewed": "drop",
|
||||
"user": map[string]any{"id": "user", "name": "Known", "email": "drop"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := items[0]["unreviewed"]; found {
|
||||
t.Fatal("unreviewed submission field was projected")
|
||||
}
|
||||
user := items[0]["user"].(map[string]any)
|
||||
if _, found := user["email"]; found {
|
||||
t.Fatal("unreviewed personnel field was projected")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content map[string]any
|
||||
}{
|
||||
{name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}},
|
||||
{name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}},
|
||||
{name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}},
|
||||
} {
|
||||
t.Run("report rejects "+tc.name, func(t *testing.T) {
|
||||
caller := &agoalCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_submit_detail": {{"success": true, "content": tc.content}},
|
||||
}}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail,
|
||||
caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil {
|
||||
t.Fatal("malformed submission page was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) {
|
||||
for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} {
|
||||
caller := &agoalCoverageCaller{}
|
||||
err := runAgoalCoverage(t, ObjectTemplateList, caller, args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("invalid args %v error = %#v", args, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"},
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"},
|
||||
{"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"},
|
||||
} {
|
||||
caller := &agoalCoverageCaller{}
|
||||
if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil {
|
||||
t.Fatalf("invalid submission args %v were accepted", args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) {
|
||||
total := 0
|
||||
public := 0
|
||||
unavailable := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != productAgoal {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
t.Errorf("unavailable %s must remain hidden/non-public", item.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
|
||||
}
|
||||
if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" {
|
||||
t.Errorf("public %s has incomplete Contract/Result", item.Command)
|
||||
}
|
||||
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
|
||||
t.Errorf("public %s has incomplete Safety", item.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 16 || public != 5 || unavailable != 11 {
|
||||
t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) {
|
||||
for _, spec := range unavailableAgoalSpecs {
|
||||
declaration, found := agoalRegistered(spec.command)
|
||||
if !found {
|
||||
t.Fatalf("registered unavailable command missing: %s", spec.command)
|
||||
}
|
||||
args := make([]string, 0)
|
||||
for _, flag := range declaration.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--"+flag.Name)
|
||||
switch flag.Name {
|
||||
case "scope-type":
|
||||
args = append(args, "PERSONAL")
|
||||
case "tracking-period-type":
|
||||
args = append(args, "MONTHLY")
|
||||
case "submit-state":
|
||||
args = append(args, "ON_TIME")
|
||||
default:
|
||||
if flag.Type != shortcut.FlagBool {
|
||||
args = append(args, "fixture")
|
||||
}
|
||||
}
|
||||
}
|
||||
caller := &agoalCoverageCaller{}
|
||||
err := runAgoalCoverage(t, declaration, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), spec.reason) {
|
||||
t.Errorf("%s unavailable error = %v", spec.command, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
|
||||
// Service packages — each registers its shortcuts from init().
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
|
||||
|
||||
+1164
-276
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,742 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devapp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func devAppSuccessResult(values map[string]any) map[string]any {
|
||||
return map[string]any{"success": true, "result": values}
|
||||
}
|
||||
|
||||
func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any {
|
||||
result := map[string]any{"success": true, key: items, "hasMore": more}
|
||||
if cursor != "" {
|
||||
result["nextCursor"] = cursor
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) {
|
||||
if devAppContainers(nil) != nil {
|
||||
t.Fatal("nil response unexpectedly produced containers")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{nil, false}, {false, false}, {true, true},
|
||||
{float64(0), false}, {float64(200), false}, {float64(500), true},
|
||||
{"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true},
|
||||
{map[string]any{}, false}, {map[string]any{"code": 1}, true},
|
||||
{[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true},
|
||||
} {
|
||||
if got := devAppErrorValue(tc.value); got != tc.want {
|
||||
t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := requireDevAppSuccess(map[string]any{
|
||||
"success": true, "result": map[string]any{"versionStatus": "FAILED"},
|
||||
}, "devapp/test"); err != nil {
|
||||
t.Fatalf("successful query with failed business state was rejected: %v", err)
|
||||
}
|
||||
if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) {
|
||||
t.Fatal("known envelope was not recognized")
|
||||
}
|
||||
if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) {
|
||||
t.Fatal("business field was treated as envelope-only")
|
||||
}
|
||||
if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil {
|
||||
t.Fatal("empty business object was accepted")
|
||||
}
|
||||
|
||||
object := map[string]any{"id": "actual", "name": "actual"}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil {
|
||||
t.Fatal("missing identity was accepted")
|
||||
}
|
||||
if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil {
|
||||
t.Fatal("mismatched identity was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil {
|
||||
t.Fatal("missing readback field was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil {
|
||||
t.Fatal("mismatched readback field was accepted")
|
||||
}
|
||||
if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil {
|
||||
t.Fatal("missing string field was accepted")
|
||||
}
|
||||
|
||||
deep := map[string]any{"success": true, "content": map[string]any{
|
||||
"result": map[string]any{"hasMore": true, "nextCursor": " next "},
|
||||
}}
|
||||
projection, err := devAppListProjection(deep, "items", nil, "devapp/test")
|
||||
if err != nil || projection["nextCursor"] != "next" {
|
||||
t.Fatalf("deep pagination projection=%#v err=%v", projection, err)
|
||||
}
|
||||
if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil {
|
||||
t.Fatal("failed list envelope was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) {
|
||||
t.Run("list all filters and projection", func(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"unified_app_id": "app", "appName": "name", "clientId": "client",
|
||||
"agent_id": "agent", "app_status": "normal", "modified_time": "now",
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")},
|
||||
}}
|
||||
err := runDevAppCoverage(t, ListApp, caller,
|
||||
"--name", "name", "--app-key", "client", "--app-group-id", "7",
|
||||
"--creator", "owner", "--robot-name", "robot", "--develop-type", "2",
|
||||
"--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc",
|
||||
"--cursor", "cursor", "--page-size", "9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner",
|
||||
"robotName": "robot", "developType": 2, "filterCoolApp": 1,
|
||||
"sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9,
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) {
|
||||
t.Fatalf("list params=%#v, want %#v", caller.calls, want)
|
||||
}
|
||||
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, ListApp, badPage); err == nil {
|
||||
t.Fatal("list without terminal pagination evidence was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
result map[string]any
|
||||
}{
|
||||
{"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}},
|
||||
{"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}},
|
||||
{"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}},
|
||||
{"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}},
|
||||
{"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}},
|
||||
{"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}},
|
||||
{"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}},
|
||||
{"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("read transport and identity failure", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("transport failure was accepted")
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("wrong object identity was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission list", func(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"permissionCode": "scope", "permissionName": "name", "interfaceName": "api",
|
||||
"auth_status": "AUTHED", "scope_type": "APP",
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")},
|
||||
}}
|
||||
err := runDevAppCoverage(t, PermissionList, caller,
|
||||
"--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope",
|
||||
"--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := permissionListFirst(item, "missing"); ok {
|
||||
t.Fatal("missing permission field unexpectedly resolved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member list success and failures", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("member transport failure was accepted")
|
||||
}
|
||||
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_members": {{"success": true, "members": "bad"}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("malformed member list was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
key string
|
||||
item map[string]any
|
||||
}{
|
||||
{"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}},
|
||||
{"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, ok := eventListFirst(map[string]any{}, "missing"); ok {
|
||||
t.Fatal("missing event field unexpectedly resolved")
|
||||
}
|
||||
if _, ok := versionListFirst(map[string]any{}, "missing"); ok {
|
||||
t.Fatal("missing version field unexpectedly resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) {
|
||||
t.Run("create all fields and failure branches", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{
|
||||
"unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon",
|
||||
})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, CreateApp, caller,
|
||||
"--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil {
|
||||
t.Fatal("create transport failure was accepted")
|
||||
}
|
||||
for name, responses := range map[string]map[string][]map[string]any{
|
||||
"receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}},
|
||||
"resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}},
|
||||
"readback failure": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
},
|
||||
"name mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
|
||||
},
|
||||
"description mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})},
|
||||
},
|
||||
"icon mismatch": {
|
||||
"create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{"--name", "created", "--yes"}
|
||||
if strings.Contains(name, "description") {
|
||||
args = append(args, "--desc", "desc")
|
||||
}
|
||||
if strings.Contains(name, "icon") {
|
||||
args = append(args, "--icon-media-id", "icon")
|
||||
}
|
||||
if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil {
|
||||
t.Fatal("invalid create sequence was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update all fields and dry run", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"update_dev_app": {map[string]any{"success": true}},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{
|
||||
"unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon",
|
||||
})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, caller,
|
||||
"--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{},
|
||||
"--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
|
||||
t.Fatal("update readback failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete guards and bounded readback", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil {
|
||||
t.Fatal("delete verification without selector was accepted")
|
||||
}
|
||||
missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete without appKey was accepted")
|
||||
}
|
||||
writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete transport failure was accepted")
|
||||
}
|
||||
stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete with present readback was accepted")
|
||||
}
|
||||
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {
|
||||
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
|
||||
devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"),
|
||||
},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete stalled pagination was accepted")
|
||||
}
|
||||
malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": "bad", "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete malformed readback was accepted")
|
||||
}
|
||||
badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})},
|
||||
"delete_dev_app": {map[string]any{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("delete pagination without termination was accepted")
|
||||
}
|
||||
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
|
||||
for page := 0; page < devAppMaxReadbackPages; page++ {
|
||||
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page))))
|
||||
}
|
||||
pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}}
|
||||
helpers.InitDeps(pageLimitCaller)
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
|
||||
t.Fatal("delete verification page limit was accepted")
|
||||
}
|
||||
helpers.InitDeps(&devAppCoverageCaller{})
|
||||
if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil {
|
||||
t.Fatal("delete verification transport failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials require client id", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("credential without client id was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("webapp all fields and failures", func(t *testing.T) {
|
||||
args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"}
|
||||
resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
"get_extension_webapp_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("webapp write failure was accepted")
|
||||
}
|
||||
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil {
|
||||
t.Fatal("webapp readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {map[string]any{"success": true}},
|
||||
"get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil {
|
||||
t.Fatal("webapp mismatch was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) {
|
||||
t.Run("robot validate and all fields", func(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--unified-app-id", "app", "--skills", "skill", "--yes"},
|
||||
{"--unified-app-id", "app", "--add-scope=false", "--yes"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"}
|
||||
resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("robot write failure was accepted")
|
||||
}
|
||||
badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil {
|
||||
t.Fatal("robot readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil {
|
||||
t.Fatal("robot mismatch was accepted")
|
||||
}
|
||||
boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil {
|
||||
t.Fatal("robot boolean mismatch was accepted")
|
||||
}
|
||||
skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil {
|
||||
t.Fatal("robot skills mismatch was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status previews and readback failures", func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("app status readback failure was accepted")
|
||||
}
|
||||
mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app": {map[string]any{"success": true}},
|
||||
"get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("app status mismatch was accepted")
|
||||
}
|
||||
if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"enable_dev_app_robot": {map[string]any{"success": true}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("robot status readback failure was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member mutations validate receipts and exact readback", func(t *testing.T) {
|
||||
member := map[string]any{"userId": "user", "memberType": "DEVELOPER"}
|
||||
add := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"add_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberAdd, add,
|
||||
"--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" ||
|
||||
!reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) ||
|
||||
add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" {
|
||||
t.Fatalf("member add calls=%#v", add.calls)
|
||||
}
|
||||
|
||||
remove := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"remove_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberRemove, remove,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" {
|
||||
t.Fatalf("member remove calls=%#v", remove.calls)
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} {
|
||||
unconfirmed := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, declaration, unconfirmed,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil {
|
||||
t.Fatalf("%s succeeded without confirmation", declaration.Command)
|
||||
}
|
||||
if len(unconfirmed.calls) != 0 {
|
||||
t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls)
|
||||
}
|
||||
dryRun := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, declaration, dryRun,
|
||||
"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dryRun.calls) != 0 {
|
||||
t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member mutations reject invalid input and false success", func(t *testing.T) {
|
||||
for _, values := range [][]string{nil, {""}, {"user", "user"}} {
|
||||
if _, err := validatedDevAppValues(values, "--user-ids"); err == nil {
|
||||
t.Fatalf("invalid member IDs accepted: %#v", values)
|
||||
}
|
||||
}
|
||||
if _, err := validatedDevAppMemberType(" "); err == nil {
|
||||
t.Fatal("blank member type was accepted")
|
||||
}
|
||||
if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" {
|
||||
t.Fatalf("normalized member type=%q err=%v", got, err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"},
|
||||
{"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{}
|
||||
if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil {
|
||||
t.Fatalf("invalid member arguments accepted: %#v", args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid member arguments made calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"}
|
||||
badAddResponses := []map[string][]map[string]any{
|
||||
{},
|
||||
{"add_dev_app_members": {{"success": false}}},
|
||||
{"add_dev_app_members": {{"success": true}}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}},
|
||||
{"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
}, false, "")}},
|
||||
}
|
||||
for index, responses := range badAddResponses {
|
||||
if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil {
|
||||
t.Fatalf("member add false success case %d was accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"remove_dev_app_members": {{"success": true}},
|
||||
"list_dev_app_members": {devAppSuccessList("members", []any{
|
||||
map[string]any{"userId": "user", "memberType": "DEVELOPER"},
|
||||
}, false, "")},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil {
|
||||
t.Fatal("member remove accepted target still present")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
writeTool string
|
||||
status any
|
||||
}{
|
||||
{"enable missing status", RobotEnable, "enable_dev_app_robot", nil},
|
||||
{"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resource := map[string]any{"unifiedAppId": "app"}
|
||||
if tc.status != nil {
|
||||
resource["robotStatus"] = tc.status
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.writeTool: {map[string]any{"success": true}},
|
||||
"get_extension_robot_config": {devAppSuccessResult(resource)},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil {
|
||||
t.Fatal("invalid robot status was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("event validation and readback failures", func(t *testing.T) {
|
||||
for _, values := range [][]string{nil, {""}, {"event", "event"}} {
|
||||
if _, err := validatedDevAppValues(values, "--event-codes"); err == nil {
|
||||
t.Fatalf("invalid event values accepted: %#v", values)
|
||||
}
|
||||
}
|
||||
if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) {
|
||||
t.Fatalf("normalized event values=%#v err=%v", got, err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("event write failure was accepted")
|
||||
}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, listResponses := range map[string][]map[string]any{
|
||||
"transport": nil,
|
||||
"malformed": {{"success": true, "list": "bad", "hasMore": false}},
|
||||
"projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")},
|
||||
"stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": listResponses,
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("invalid event readback was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": {
|
||||
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
|
||||
devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"),
|
||||
},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("stalled event cursor was accepted")
|
||||
}
|
||||
pages := make([]map[string]any, 0, devAppMaxReadbackPages)
|
||||
for page := 0; page < devAppMaxReadbackPages; page++ {
|
||||
pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page))))
|
||||
}
|
||||
pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {map[string]any{"success": true}},
|
||||
"list_dev_app_events": pages,
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil {
|
||||
t.Fatal("event readback page limit was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version create all fields and failures", func(t *testing.T) {
|
||||
args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil {
|
||||
t.Fatal("version write failure was accepted")
|
||||
}
|
||||
for name, responses := range map[string]map[string][]map[string]any{
|
||||
"receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}},
|
||||
"missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}},
|
||||
"readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}},
|
||||
"version mismatch": {
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})},
|
||||
},
|
||||
"description mismatch": {
|
||||
"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})},
|
||||
"get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil {
|
||||
t.Fatal("invalid version create sequence was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list pagination projection failures", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
key string
|
||||
idKey string
|
||||
}{
|
||||
{PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"},
|
||||
{EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"},
|
||||
{VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatalf("%s accepted missing pagination", tc.declaration.Command)
|
||||
}
|
||||
}
|
||||
malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("malformed event collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type devAppCoverageCall struct {
|
||||
tool string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type devAppCoverageCaller struct {
|
||||
responses map[string][]map[string]any
|
||||
calls []devAppCoverageCall
|
||||
}
|
||||
|
||||
func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params})
|
||||
queue := c.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("unexpected devapp coverage call: " + tool)
|
||||
}
|
||||
payload := queue[0]
|
||||
c.responses[tool] = queue[1:]
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
|
||||
}
|
||||
|
||||
func (*devAppCoverageCaller) Format() string { return "json" }
|
||||
func (*devAppCoverageCaller) DryRun() bool { return false }
|
||||
func (*devAppCoverageCaller) Fields() string { return "" }
|
||||
func (*devAppCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
root.SetArgs(append([]string{"devapp", declaration.Command}, args...))
|
||||
if err := root.Execute(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func devAppRegistered(command string) (shortcut.Shortcut, bool) {
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "devapp" && item.Command == command {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return shortcut.Shortcut{}, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "empty", data: nil},
|
||||
{name: "missing success", data: map[string]any{"list": []any{}}},
|
||||
{name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}},
|
||||
{name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}},
|
||||
{name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := listAppProject(tc.data); err == nil {
|
||||
t.Fatal("malformed response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "missing collection", data: map[string]any{"success": true, "hasMore": false}},
|
||||
{name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}},
|
||||
{name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}},
|
||||
{name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := listAppProject(tc.data); err == nil {
|
||||
t.Fatal("malformed collection was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
valid := []map[string]any{{"unifiedAppId": "app"}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "missing pagination", data: map[string]any{"success": true}},
|
||||
{name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}},
|
||||
{name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}},
|
||||
{name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}},
|
||||
{name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil {
|
||||
t.Fatal("malformed pagination was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) {
|
||||
writes := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{CreateApp, []string{"--name", "app"}},
|
||||
{UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}},
|
||||
{DeleteApp, []string{"--unified-app-id", "app"}},
|
||||
{EnableApp, []string{"--unified-app-id", "app"}},
|
||||
{DisableApp, []string{"--unified-app-id", "app"}},
|
||||
{WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}},
|
||||
{RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}},
|
||||
{RobotEnable, []string{"--unified-app-id", "app"}},
|
||||
{RobotDisable, []string{"--unified-app-id", "app"}},
|
||||
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}},
|
||||
{VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}},
|
||||
}
|
||||
for _, tc := range writes {
|
||||
t.Run(tc.declaration.Command, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed command called MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{UpdateApp, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{WebappConfig, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{RobotConfig, []string{"--unified-app-id", "app", "--yes"}},
|
||||
{EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}},
|
||||
} {
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, tc.declaration, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason == "confirmation_required" {
|
||||
t.Fatalf("invalid input error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid input called MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) {
|
||||
t.Run("credentials require stable identity client and secret", func(t *testing.T) {
|
||||
valid := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client", "appSecret": "secret",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil {
|
||||
t.Fatal("credential response without a secret was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) {
|
||||
declaration, ok := devAppRegistered("+credentials-get")
|
||||
if !ok || declaration.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout)
|
||||
}
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app_credentials": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret",
|
||||
"clientSecret": "test-client-secret", "secret": "test-secret",
|
||||
}}},
|
||||
}}
|
||||
encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(encoded, &envelope); err != nil {
|
||||
t.Fatalf("unified output is not JSON: %v", err)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unified output data type = %T, want object", envelope["data"])
|
||||
}
|
||||
if _, nested := data["result"]; nested {
|
||||
t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it")
|
||||
}
|
||||
for _, path := range declaration.Contract.Result.SensitivePaths {
|
||||
if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" {
|
||||
t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member projector supports known and guaranteed zero", func(t *testing.T) {
|
||||
members := []any{
|
||||
map[string]any{"userId": "user-1", "memberType": "OWNER"},
|
||||
map[string]any{"userId": "user-2", "memberType": "DEVELOPER"},
|
||||
}
|
||||
if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" {
|
||||
t.Fatalf("known member projection = %#v", got)
|
||||
}
|
||||
if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 {
|
||||
t.Fatalf("zero member projection = %#v", got)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"success": true, "members": []any{"bad"}},
|
||||
{"success": true, "members": []any{map[string]any{"name": "missing-id"}}},
|
||||
} {
|
||||
if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil {
|
||||
t.Fatalf("malformed member response accepted: %#v", bad)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
responses map[string][]map[string]any
|
||||
wantTools []string
|
||||
}{
|
||||
{
|
||||
name: "robot config", declaration: RobotConfig,
|
||||
args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"set_extension_robot_config": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}},
|
||||
},
|
||||
wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "robot enable", declaration: RobotEnable,
|
||||
args: []string{"--unified-app-id", "app", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"enable_dev_app_robot": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}},
|
||||
},
|
||||
wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "robot disable", declaration: RobotDisable,
|
||||
args: []string{"--unified-app-id", "app", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"disable_dev_app_robot": {{"success": true}},
|
||||
"get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}},
|
||||
},
|
||||
wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"},
|
||||
},
|
||||
{
|
||||
name: "event subscribe", declaration: EventSubscribe,
|
||||
args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {{"success": true}},
|
||||
"list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}},
|
||||
},
|
||||
wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"},
|
||||
},
|
||||
{
|
||||
name: "version create", declaration: VersionCreate,
|
||||
args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"},
|
||||
responses: map[string][]map[string]any{
|
||||
"create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}},
|
||||
"get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}},
|
||||
},
|
||||
wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: tc.responses}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTools := make([]string, 0, len(caller.calls))
|
||||
for _, call := range caller.calls {
|
||||
gotTools = append(gotTools, call.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(gotTools, tc.wantTools) {
|
||||
t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("event false receipt is rejected by readback", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"subscribe_dev_app_events": {{"success": true}},
|
||||
"list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil {
|
||||
t.Fatal("event subscription without exact readback was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []devAppCoverageCall{
|
||||
{tool: "create_dev_app", params: map[string]any{"name": "created"}},
|
||||
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update mismatch fails", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"update_dev_app": {{"success": true}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil {
|
||||
t.Fatal("write/readback mismatch was accepted")
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
writeTool string
|
||||
status string
|
||||
}{
|
||||
{name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"},
|
||||
{name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
tc.writeTool: {{"success": true}},
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "appStatus": tc.status,
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("webapp config", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"set_extension_webapp_config": {{"success": true}},
|
||||
"get_extension_webapp_config": {{"success": true, "result": map[string]any{
|
||||
"unifiedAppId": "app", "homepageUrl": "https://example.invalid",
|
||||
}}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete pre-read and absence", func(t *testing.T) {
|
||||
caller := &devAppCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}},
|
||||
"delete_dev_app": {{"success": true}},
|
||||
"list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}},
|
||||
}}
|
||||
if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []devAppCoverageCall{
|
||||
{tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
{tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}},
|
||||
{tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls, want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) {
|
||||
public := 0
|
||||
unavailable := 0
|
||||
total := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "devapp" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v",
|
||||
item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command))
|
||||
}
|
||||
if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" {
|
||||
t.Errorf("unavailable %s lacks concrete reason", item.Command)
|
||||
}
|
||||
if !item.Contract.Empty() {
|
||||
t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if shortcut.InPublicCatalog(item.Service, item.Command) {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
|
||||
t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout)
|
||||
}
|
||||
if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" {
|
||||
t.Errorf("public %s has incomplete Contract/Result", item.Command)
|
||||
}
|
||||
if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" {
|
||||
t.Errorf("public %s has incomplete Safety", item.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 30 || public != 25 || unavailable != 5 {
|
||||
t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) {
|
||||
for command, reason := range devAppUnavailableReasons {
|
||||
declaration, found := devAppRegistered(command)
|
||||
if !found {
|
||||
t.Fatalf("registered unavailable command missing: %s", command)
|
||||
}
|
||||
args := []string{}
|
||||
for _, flag := range declaration.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--"+flag.Name)
|
||||
if flag.Type != shortcut.FlagBool {
|
||||
args = append(args, "fixture")
|
||||
}
|
||||
}
|
||||
caller := &devAppCoverageCaller{}
|
||||
err := runDevAppCoverage(t, declaration, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("%s unavailable error = %v", command, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}},
|
||||
{name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
|
||||
{name: "exhausted", data: map[string]any{"hasMore": false}},
|
||||
{name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}},
|
||||
{name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}},
|
||||
{name: "exhausted", data: map[string]any{"success": true, "hasMore": false}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := devAppListProjection(tc.data, "items", items)
|
||||
got, err := devAppListProjection(tc.data, "items", items, "devapp/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" {
|
||||
t.Fatalf("hasMore not preserved: %#v", got)
|
||||
}
|
||||
@@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
if got := devAppPaginationCandidates(nil); got != nil {
|
||||
t.Fatalf("nil candidates=%#v", got)
|
||||
}
|
||||
deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
|
||||
if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" {
|
||||
deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
|
||||
if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" {
|
||||
t.Fatalf("deep projection=%#v", got)
|
||||
}
|
||||
deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}}
|
||||
if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" {
|
||||
t.Fatalf("deeper projection=%#v", got)
|
||||
}
|
||||
cyclic := map[string]any{"success": true}
|
||||
cyclic["content"] = cyclic
|
||||
if got := devAppPaginationCandidates(cyclic); len(got) != 13 {
|
||||
t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) {
|
||||
caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
|
||||
caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{})
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -12,10 +12,16 @@ import (
|
||||
|
||||
func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
|
||||
active := map[string]bool{
|
||||
"+list": true, "+get": true, "+webapp-get": true,
|
||||
"+permission-list": true, "+event-list": true, "+version-list": true,
|
||||
"+list": true, "+get": true, "+create": true, "+update": true,
|
||||
"+delete": true, "+enable": true, "+disable": true, "+webapp-get": true,
|
||||
"+webapp-config": true,
|
||||
"+permission-list": true, "+version-list": true,
|
||||
"+robot-get": true, "+version-get": true,
|
||||
"+version-check-approval": true, "+version-status": true,
|
||||
"+credentials-get": true, "+member-list": true,
|
||||
"+member-add": true, "+member-remove": true,
|
||||
"+robot-config": true, "+robot-enable": true, "+robot-disable": true,
|
||||
"+event-list": true, "+event-subscribe": true, "+version-create": true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range shortcut.All() {
|
||||
@@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) {
|
||||
t.Errorf("active pilot shortcut %s is not registered", name)
|
||||
}
|
||||
}
|
||||
for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} {
|
||||
for _, paginated := range []string{"+list", "+permission-list", "+version-list"} {
|
||||
if !seen[paginated] {
|
||||
t.Errorf("paginated shortcut %s is not registered", paginated)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ package shortcut
|
||||
// command discovery and skill generation.
|
||||
func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
"agoal\u0000+contract-fields": {},
|
||||
"agoal\u0000+obj-template-list": {},
|
||||
"agoal\u0000+report-statistics-list": {},
|
||||
"agoal\u0000+report-submit-detail": {},
|
||||
"agoal\u0000+user-rules": {},
|
||||
"aisearch\u0000+search-person": {},
|
||||
"aitable\u0000+advperm-disable": {},
|
||||
"aitable\u0000+advperm-enable": {},
|
||||
@@ -254,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"contact\u0000+search-user": {},
|
||||
"contact\u0000+team": {},
|
||||
"devapp\u0000+create": {},
|
||||
"devapp\u0000+credentials-get": {},
|
||||
"devapp\u0000+delete": {},
|
||||
"devapp\u0000+disable": {},
|
||||
"devapp\u0000+enable": {},
|
||||
"devapp\u0000+event-list": {},
|
||||
"devapp\u0000+event-subscribe": {},
|
||||
"devapp\u0000+get": {},
|
||||
"devapp\u0000+list": {},
|
||||
"devapp\u0000+member-add": {},
|
||||
"devapp\u0000+member-list": {},
|
||||
"devapp\u0000+member-remove": {},
|
||||
"devapp\u0000+permission-list": {},
|
||||
"devapp\u0000+robot-config": {},
|
||||
"devapp\u0000+robot-disable": {},
|
||||
"devapp\u0000+robot-enable": {},
|
||||
"devapp\u0000+robot-get": {},
|
||||
"devapp\u0000+update": {},
|
||||
"devapp\u0000+version-check-approval": {},
|
||||
"devapp\u0000+version-create": {},
|
||||
"devapp\u0000+version-get": {},
|
||||
"devapp\u0000+version-list": {},
|
||||
"devapp\u0000+version-status": {},
|
||||
|
||||
@@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error {
|
||||
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
|
||||
}
|
||||
|
||||
// OutputForTool stores a once-fetched MCP payload while retaining the tool name
|
||||
// for product-specific outcome normalization. It is intended for composite
|
||||
// shortcuts that must validate stable identity or collection shape before the
|
||||
// shared renderer sees the response; callers must not use it to issue a second
|
||||
// business request.
|
||||
func (rt *RuntimeContext) OutputForTool(tool string, payload any) error {
|
||||
if output.UsesUnifiedResult(rt.cmd) {
|
||||
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
|
||||
}
|
||||
if output.CommandRollout(rt.cmd) == output.RolloutDualValidate {
|
||||
if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON)
|
||||
}
|
||||
|
||||
func (rt *RuntimeContext) storePayload(tool string, payload any) error {
|
||||
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) {
|
||||
t.Fatalf("devapp result=%s", got.Outcome())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) {
|
||||
t.Run("unified", func(t *testing.T) {
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dual validate", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
output.SetCommandRollout(cmd, output.RolloutDualValidate)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dual validate failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled })
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
output.SetCommandRollout(cmd, output.RolloutDualValidate)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("dual validation error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+get"}
|
||||
cmd.SetContext(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
output.SetCommandRollout(cmd, output.RolloutLegacyOnly)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id"`) {
|
||||
t.Fatalf("legacy output=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ var hrbrainSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_pat.json
|
||||
var patSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_devapp.json
|
||||
var devAppSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_agoal.json
|
||||
var agoalSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -113,6 +119,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
devdocSemanticCatalogJSON,
|
||||
hrbrainSemanticCatalogJSON,
|
||||
patSemanticCatalogJSON,
|
||||
devAppSemanticCatalogJSON,
|
||||
agoalSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "agoal",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+report-statistics-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+obj-template-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。","risk":"read","public":true,"reviewed":true},
|
||||
"+strategy-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+strategy-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+strategy-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-fields": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+contract-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+contract-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-get": {"disposition":"schema_leaf","semantic_delta":"可访问部门与多个周期的 exact+raw 均返回无 success 的 null。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-entity-get": {"disposition":"schema_leaf","semantic_delta":"不存在双 ID 的 raw 响应为无 success 的 null,缺少安全实体 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+scorecard-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少计分卡旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+user-rules": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+user-objectives": {"disposition":"schema_leaf","semantic_delta":"当前用户全部可见规则/周期的 exact+raw 均只为空,缺少非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+report-submit-detail": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。","risk":"read","public":true,"reviewed":true},
|
||||
"+obj-template-upsert": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;无删除或版本恢复,无法验证 upsert 终态与零残留。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "devapp",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。","risk":"read","public":true,"reviewed":true},
|
||||
"+get": {"disposition":"semantic_adapter","semantic_delta":"拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+create": {"disposition":"semantic_adapter","semantic_delta":"创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。","risk":"write","public":true,"reviewed":true},
|
||||
"+update": {"disposition":"semantic_adapter","semantic_delta":"要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+enable": {"disposition":"semantic_adapter","semantic_delta":"启用后按同一 unifiedAppId 读回并要求 appStatus=normal。","risk":"write","public":true,"reviewed":true},
|
||||
"+disable": {"disposition":"semantic_adapter","semantic_delta":"停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+credentials-get": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。","risk":"read","public":true,"reviewed":true},
|
||||
"+webapp-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+webapp-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+permission-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+permission-add": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+permission-remove": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+member-add": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-remove": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+security-config": {"disposition":"schema_leaf","semantic_delta":"整组覆盖写入缺少读取旧值、精确写回和恢复原配置的安全闭环,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+robot-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+robot-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。","risk":"write","public":true,"reviewed":true},
|
||||
"+robot-enable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。","risk":"write","public":true,"reviewed":true},
|
||||
"+robot-disable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+event-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+event-subscribe": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+event-unsubscribe": {"disposition":"schema_leaf","semantic_delta":"缺少可恢复的稳定订阅 fixture 与逐项退订读回,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+version-create": {"disposition":"semantic_adapter","semantic_delta":"创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。","risk":"write","public":true,"reviewed":true},
|
||||
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-check-approval": {"disposition":"semantic_adapter","semantic_delta":"只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-publish": {"disposition":"schema_leaf","semantic_delta":"可能进入审批或线上生效,缺少安全测试租户与回滚合同,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+version-status": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
+798
@@ -0,0 +1,798 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Opt-in, real-data release evidence gate for the Dev -> DevApp -> Agoal
|
||||
# Shortcut delivery group. The current worktree binary supplies both exact
|
||||
# `+shortcut` leaves and their owning atomic command trees. Raw responses and
|
||||
# stable IDs stay in a repository-external temporary directory; stdout contains
|
||||
# only PII-free PASS labels and aggregate facts.
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
dws_bin=${DWS_SHORTCUT_GATE_BIN:-"$repo_root/dws"}
|
||||
selected_product=${1:-all}
|
||||
|
||||
case "$selected_product" in
|
||||
all | dev | devapp | agoal) ;;
|
||||
*)
|
||||
echo "usage: $0 [all|dev|devapp|agoal]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${DWS_SHORTCUT_LIVE_GATE:-} != 1 ]]; then
|
||||
echo "set DWS_SHORTCUT_LIVE_GATE=1 to authorize the real-data dual-layer gate" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
if [[ ! -t 0 ]]; then
|
||||
echo "the DevApp live gate requires an interactive terminal for explicit write and cleanup confirmation" >&2
|
||||
exit 2
|
||||
fi
|
||||
read -r -p "This gate creates, changes, and deletes isolated DevApp fixtures. Type RUN to authorize the writes and cleanup: " live_confirmation
|
||||
if [[ $live_confirmation != RUN ]]; then
|
||||
echo "live gate authorization declined" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if [[ ! -x $dws_bin ]]; then
|
||||
echo "current worktree binary is missing; run make build first" >&2
|
||||
exit 2
|
||||
fi
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "jq is required" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
member_user_id=""
|
||||
member_type=${DWS_DEVAPP_E2E_MEMBER_TYPE:-DEVELOPER}
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
member_user_id_file=${DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE:-}
|
||||
if [[ -z $member_user_id_file || ! -f $member_user_id_file ]]; then
|
||||
echo "set DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE to a private file containing one safe test-account userId" >&2
|
||||
exit 2
|
||||
fi
|
||||
IFS= read -r member_user_id <"$member_user_id_file" || true
|
||||
[[ -n $member_user_id ]] || {
|
||||
echo "the private DevApp member fixture is empty" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -n $member_type ]] || {
|
||||
echo "DWS_DEVAPP_E2E_MEMBER_TYPE must not be empty" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
evidence_dir=$(mktemp -d "${TMPDIR:-/tmp}/dws-shortcut-dual-live.XXXXXX")
|
||||
exact_app_id=""
|
||||
exact_app_name=""
|
||||
raw_app_id=""
|
||||
raw_app_name=""
|
||||
|
||||
fail() {
|
||||
echo "FAIL $1 (secure response retained only until trap cleanup)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_json() {
|
||||
local label=$1
|
||||
local output=$2
|
||||
shift 2
|
||||
if ! "$@" --format json >"$output" 2>"$output.stderr"; then
|
||||
fail "$label"
|
||||
fi
|
||||
jq -e . "$output" >/dev/null 2>&1 || fail "$label: non-JSON response"
|
||||
}
|
||||
|
||||
assert_jq() {
|
||||
local label=$1
|
||||
local expression=$2
|
||||
shift 2
|
||||
jq -e "$expression" "$@" >/dev/null 2>&1 || fail "$label"
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'PASS %s\n' "$1"
|
||||
}
|
||||
|
||||
assert_public_surface() {
|
||||
local product=$1
|
||||
local expected=$2
|
||||
local actual
|
||||
actual=$(jq -r --arg product "$product" '.results[] | select(.service == $product) | .command' "$repo_root/docs/shortcut-public-catalog.json" | LC_ALL=C sort)
|
||||
expected=$(printf '%s\n' "$expected" | sed '/^$/d' | LC_ALL=C sort)
|
||||
[[ $actual == "$expected" ]] || fail "$product public catalog differs from the exhaustive live matrix"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
if [[ -n $exact_app_id ]]; then
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes --format json >/dev/null 2>&1
|
||||
fi
|
||||
if [[ -n $raw_app_id ]]; then
|
||||
"$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes --format json >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf "$evidence_dir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
run_dev_gate() {
|
||||
# Dev owns routing/documentation only and registers no Shortcut leaves.
|
||||
assert_public_surface dev ""
|
||||
pass "DEV-INVENTORY public=0 fully-unlocked=0 hardened-blocked=0 routed=4 platform-unavailable=1"
|
||||
}
|
||||
|
||||
expect_confirmation() {
|
||||
local label=$1
|
||||
local output=$2
|
||||
shift 2
|
||||
local status=0
|
||||
# The outer gate intentionally requires a TTY for one explicit RUN
|
||||
# authorization. The command under test must not inherit that TTY here:
|
||||
# otherwise its own confirmation prompt blocks instead of returning the
|
||||
# machine-readable confirmation_required result that proves zero writes.
|
||||
"$@" --format json </dev/null >"$output" 2>"$output.stderr" || status=$?
|
||||
[[ $status -ne 0 ]] || fail "$label: unconfirmed command unexpectedly succeeded"
|
||||
jq -e '.ok == false and .error.type == "validation" and .error.subtype == "confirmation_required"' "$output" >/dev/null 2>&1 || fail "$label: missing confirmation_required"
|
||||
}
|
||||
|
||||
run_devapp_gate() {
|
||||
assert_public_surface devapp "+create
|
||||
+credentials-get
|
||||
+delete
|
||||
+disable
|
||||
+enable
|
||||
+event-list
|
||||
+event-subscribe
|
||||
+get
|
||||
+list
|
||||
+member-add
|
||||
+member-list
|
||||
+member-remove
|
||||
+permission-list
|
||||
+robot-config
|
||||
+robot-disable
|
||||
+robot-enable
|
||||
+robot-get
|
||||
+update
|
||||
+version-check-approval
|
||||
+version-create
|
||||
+version-get
|
||||
+version-list
|
||||
+version-status
|
||||
+webapp-config
|
||||
+webapp-get"
|
||||
local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A"
|
||||
local suffix="$(date +%s)-$$"
|
||||
exact_app_name="DWS-E2E-EXACT-$suffix"
|
||||
raw_app_name="DWS-E2E-RAW-$suffix"
|
||||
local exact_updated_name="DWS-E2E-UPDATED-$suffix"
|
||||
local raw_updated_name="DWS-E2E-RAW-UPDATED-$suffix"
|
||||
local bot_raw="DWS-E2E-BOT-RAW-$suffix"
|
||||
local bot_exact="DWS-E2E-BOT-EXACT-$suffix"
|
||||
local event_raw="bpms_task_change"
|
||||
local event_exact="$event_raw"
|
||||
|
||||
run_json "devapp create guard before exact query" "$evidence_dir/devapp-create-guard-before-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp create guard before raw query" "$evidence_dir/devapp-create-guard-before-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
expect_confirmation "devapp +create confirmation" "$evidence_dir/devapp-create-confirm.json" \
|
||||
"$dws_bin" devapp +create --name "$exact_app_name"
|
||||
run_json "devapp create guard after exact query" "$evidence_dir/devapp-create-guard-after-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp create guard after raw query" "$evidence_dir/devapp-create-guard-after-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
assert_jq "devapp +create confirmation zero-call state" '
|
||||
(input) as $br | (input) as $ae | (input) as $ar |
|
||||
.data.count == 0 and ($br.data.items | length) == 0 and
|
||||
$ae.data.count == 0 and ($ar.data.items | length) == 0
|
||||
' "$evidence_dir/devapp-create-guard-before-exact.json" "$evidence_dir/devapp-create-guard-before-raw.json" \
|
||||
"$evidence_dir/devapp-create-guard-after-exact.json" "$evidence_dir/devapp-create-guard-after-raw.json"
|
||||
|
||||
run_json "devapp exact create" "$evidence_dir/devapp-create-exact.json" \
|
||||
"$dws_bin" devapp +create --name "$exact_app_name" --desc "dual-live-exact" --yes
|
||||
exact_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-exact.json") || fail "devapp exact create stable ID"
|
||||
assert_jq "devapp exact create terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "create"' "$evidence_dir/devapp-create-exact.json"
|
||||
run_json "devapp exact create raw readback" "$evidence_dir/devapp-create-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$exact_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-exact-raw-readback.json" >/dev/null || fail "devapp exact create raw readback"
|
||||
|
||||
run_json "devapp raw create" "$evidence_dir/devapp-create-raw.json" \
|
||||
"$dws_bin" dev app create --name "$raw_app_name" --desc "dual-live-raw" --yes
|
||||
raw_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-raw.json") || fail "devapp raw create stable ID"
|
||||
assert_jq "devapp raw create terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-create-raw.json"
|
||||
run_json "devapp raw create exact readback" "$evidence_dir/devapp-create-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$raw_app_id"
|
||||
jq -e --arg id "$raw_app_id" --arg name "$raw_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-raw-exact-readback.json" >/dev/null || fail "devapp raw create exact readback"
|
||||
pass "DEVAPP +create confirm0/exact+raw terminal-receipt/stable-ID readback"
|
||||
|
||||
run_json "devapp guard snapshot app" "$evidence_dir/devapp-guard-app-before.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot webapp" "$evidence_dir/devapp-guard-webapp-before.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot robot" "$evidence_dir/devapp-guard-robot-before.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot events" "$evidence_dir/devapp-guard-events-before.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp guard snapshot versions" "$evidence_dir/devapp-guard-versions-before.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
|
||||
expect_confirmation "devapp +update confirmation" "$evidence_dir/devapp-update-confirm.json" \
|
||||
"$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "guard-must-not-write"
|
||||
expect_confirmation "devapp +delete confirmation" "$evidence_dir/devapp-delete-confirm.json" \
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +enable confirmation" "$evidence_dir/devapp-enable-confirm.json" \
|
||||
"$dws_bin" devapp +enable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +disable confirmation" "$evidence_dir/devapp-disable-confirm.json" \
|
||||
"$dws_bin" devapp +disable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +webapp-config confirmation" "$evidence_dir/devapp-webapp-confirm.json" \
|
||||
"$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/guard"
|
||||
expect_confirmation "devapp +robot-config confirmation" "$evidence_dir/devapp-robot-config-confirm.json" \
|
||||
"$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "guard-must-not-write" --mode STREAM
|
||||
expect_confirmation "devapp +robot-enable confirmation" "$evidence_dir/devapp-robot-enable-confirm.json" \
|
||||
"$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +robot-disable confirmation" "$evidence_dir/devapp-robot-disable-confirm.json" \
|
||||
"$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id"
|
||||
expect_confirmation "devapp +event-subscribe confirmation" "$evidence_dir/devapp-event-confirm.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact"
|
||||
expect_confirmation "devapp +version-create confirmation" "$evidence_dir/devapp-version-create-confirm.json" \
|
||||
"$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "guard-must-not-write"
|
||||
|
||||
run_json "devapp guard snapshot app after" "$evidence_dir/devapp-guard-app-after.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot webapp after" "$evidence_dir/devapp-guard-webapp-after.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot robot after" "$evidence_dir/devapp-guard-robot-after.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp guard snapshot events after" "$evidence_dir/devapp-guard-events-after.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp guard snapshot versions after" "$evidence_dir/devapp-guard-versions-after.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
assert_jq "devapp all public write confirmations preserve raw state" '
|
||||
(input) as $aa | (input) as $wb | (input) as $wa | (input) as $rb | (input) as $ra |
|
||||
(input) as $eb | (input) as $ea | (input) as $vb | (input) as $va |
|
||||
[.data.unifiedAppId,.data.name,.data.desc,.data.appStatus] == [$aa.data.unifiedAppId,$aa.data.name,$aa.data.desc,$aa.data.appStatus] and
|
||||
$wb.data == $wa.data and $rb.data == $ra.data and
|
||||
([$eb.data.events[].eventCode] | sort) == ([$ea.data.events[].eventCode] | sort) and
|
||||
([$vb.data.items[].versionId] | sort) == ([$va.data.items[].versionId] | sort)
|
||||
' "$evidence_dir/devapp-guard-app-before.json" "$evidence_dir/devapp-guard-app-after.json" \
|
||||
"$evidence_dir/devapp-guard-webapp-before.json" "$evidence_dir/devapp-guard-webapp-after.json" \
|
||||
"$evidence_dir/devapp-guard-robot-before.json" "$evidence_dir/devapp-guard-robot-after.json" \
|
||||
"$evidence_dir/devapp-guard-events-before.json" "$evidence_dir/devapp-guard-events-after.json" \
|
||||
"$evidence_dir/devapp-guard-versions-before.json" "$evidence_dir/devapp-guard-versions-after.json"
|
||||
|
||||
run_json "devapp exact list known" "$evidence_dir/devapp-list-known-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp raw list known" "$evidence_dir/devapp-list-known-raw.json" \
|
||||
"$dws_bin" dev app list --name "$exact_app_name" --page-size 20
|
||||
run_json "devapp exact list zero" "$evidence_dir/devapp-list-zero-exact.json" \
|
||||
"$dws_bin" devapp +list --name "$nonce" --page-size 20
|
||||
run_json "devapp raw list zero" "$evidence_dir/devapp-list-zero-raw.json" \
|
||||
"$dws_bin" dev app list --name "$nonce" --page-size 20
|
||||
jq -e --arg id "$exact_app_id" '
|
||||
(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and .data.apps[0].unifiedAppId == $id and
|
||||
$raw.ok == true and $raw.outcome == "success" and ($raw.data.items | length) == 1 and $raw.data.items[0].unifiedAppId == $id and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.apps | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.items | length) == 0 and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-list-known-exact.json" "$evidence_dir/devapp-list-known-raw.json" \
|
||||
"$evidence_dir/devapp-list-zero-exact.json" "$evidence_dir/devapp-list-zero-raw.json" >/dev/null || fail "devapp +list dual known/zero alignment"
|
||||
pass "DEVAPP +list exact+raw known-nonempty/guaranteed-zero stable-ID/cursor termination"
|
||||
|
||||
run_json "devapp exact get" "$evidence_dir/devapp-get-exact.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw get" "$evidence_dir/devapp-get-raw.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .ok == true and .data.unifiedAppId == $id and $raw.ok == true and $raw.data.unifiedAppId == $id and .data.appKey == $raw.data.appKey' "$evidence_dir/devapp-get-exact.json" "$evidence_dir/devapp-get-raw.json" >/dev/null || fail "devapp +get dual object read"
|
||||
pass "DEVAPP +get exact+raw stable-application-ID object alignment"
|
||||
|
||||
run_json "devapp raw update" "$evidence_dir/devapp-update-raw.json" \
|
||||
"$dws_bin" dev app update --unified-app-id "$exact_app_id" --name "$raw_updated_name" --desc "raw-update" --yes
|
||||
assert_jq "devapp raw update terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-update-raw.json"
|
||||
run_json "devapp raw update exact readback" "$evidence_dir/devapp-update-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$raw_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "raw-update"' "$evidence_dir/devapp-update-raw-exact-readback.json" >/dev/null || fail "devapp raw update exact readback"
|
||||
run_json "devapp exact update" "$evidence_dir/devapp-update-exact.json" \
|
||||
"$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "$exact_updated_name" --desc "exact-update" --yes
|
||||
assert_jq "devapp exact update terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "update"' "$evidence_dir/devapp-update-exact.json"
|
||||
run_json "devapp exact update raw readback" "$evidence_dir/devapp-update-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" --arg name "$exact_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "exact-update"' "$evidence_dir/devapp-update-exact-raw-readback.json" >/dev/null || fail "devapp exact update raw readback"
|
||||
pass "DEVAPP +update confirm0/exact+raw terminal-receipt/key-field readback"
|
||||
|
||||
run_json "devapp raw disable" "$evidence_dir/devapp-disable-raw.json" \
|
||||
"$dws_bin" dev app disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp raw disable exact readback" "$evidence_dir/devapp-disable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-disable-raw.json"
|
||||
assert_jq "devapp raw disable exact state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-raw-exact-readback.json"
|
||||
run_json "devapp exact enable" "$evidence_dir/devapp-enable-exact.json" \
|
||||
"$dws_bin" devapp +enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp exact enable raw readback" "$evidence_dir/devapp-enable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-enable-exact.json"
|
||||
assert_jq "devapp exact enable raw state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-exact-raw-readback.json"
|
||||
run_json "devapp exact disable" "$evidence_dir/devapp-disable-exact.json" \
|
||||
"$dws_bin" devapp +disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp exact disable raw readback" "$evidence_dir/devapp-disable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-disable-exact.json"
|
||||
assert_jq "devapp exact disable raw state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-exact-raw-readback.json"
|
||||
run_json "devapp raw enable" "$evidence_dir/devapp-enable-raw.json" \
|
||||
"$dws_bin" dev app enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp raw enable exact readback" "$evidence_dir/devapp-enable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-enable-raw.json"
|
||||
assert_jq "devapp raw enable exact state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-raw-exact-readback.json"
|
||||
pass "DEVAPP +disable confirm0/exact+raw terminal-receipt/disabled readback/restored"
|
||||
pass "DEVAPP +enable confirm0/exact+raw terminal-receipt/normal readback"
|
||||
|
||||
run_json "devapp exact credentials" "$evidence_dir/devapp-credentials-exact.json" \
|
||||
"$dws_bin" devapp +credentials-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw credentials" "$evidence_dir/devapp-credentials-raw.json" \
|
||||
"$dws_bin" dev app credentials get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.unifiedAppId == $id and
|
||||
(.data.appKey | type) == "string" and (.data.appKey | length) > 0 and
|
||||
(.data.appSecret | type) == "string" and (.data.appSecret | length) > 0 and
|
||||
$raw.ok == true and $raw.data.unifiedAppId == $id and
|
||||
.data.appKey == $raw.data.appKey and .data.appSecret == $raw.data.appSecret
|
||||
' "$evidence_dir/devapp-credentials-exact.json" "$evidence_dir/devapp-credentials-raw.json" >/dev/null || fail "devapp +credentials-get dual object read"
|
||||
pass "DEVAPP +credentials-get exact+raw stable-app/client/one-nonempty-secret alignment"
|
||||
|
||||
run_json "devapp raw webapp config" "$evidence_dir/devapp-webapp-config-raw.json" \
|
||||
"$dws_bin" dev app webapp config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/raw" --yes
|
||||
run_json "devapp raw webapp exact readback" "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp raw webapp terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-webapp-config-raw.json"
|
||||
assert_jq "devapp raw webapp exact readback" '.data.homepageUrl == "https://example.invalid/raw"' "$evidence_dir/devapp-webapp-config-raw-exact-readback.json"
|
||||
run_json "devapp exact webapp config" "$evidence_dir/devapp-webapp-config-exact.json" \
|
||||
"$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/exact" --yes
|
||||
run_json "devapp exact webapp raw readback" "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp exact webapp terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-webapp-config-exact.json"
|
||||
assert_jq "devapp exact webapp raw readback" '.data.homepageUrl == "https://example.invalid/exact"' "$evidence_dir/devapp-webapp-config-exact-raw-readback.json"
|
||||
pass "DEVAPP +webapp-config confirm0/exact+raw terminal-receipt/URL readback"
|
||||
run_json "devapp exact webapp get final" "$evidence_dir/devapp-webapp-get-exact.json" \
|
||||
"$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp raw webapp get final" "$evidence_dir/devapp-webapp-get-raw.json" \
|
||||
"$dws_bin" dev app webapp get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.homepageUrl == $raw.data.homepageUrl' "$evidence_dir/devapp-webapp-get-exact.json" "$evidence_dir/devapp-webapp-get-raw.json" >/dev/null || fail "devapp +webapp-get dual object read"
|
||||
pass "DEVAPP +webapp-get exact+raw stable-app/key-field object alignment"
|
||||
|
||||
run_json "devapp member raw known" "$evidence_dir/devapp-member-known-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
run_json "devapp member exact known" "$evidence_dir/devapp-member-known-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id"
|
||||
run_json "devapp member exact zero" "$evidence_dir/devapp-member-zero-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$nonce"
|
||||
jq -e --arg nonce "$nonce" '
|
||||
(input) as $raw | (input) as $zero |
|
||||
.ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.members | type) == "array" and
|
||||
([.data.members[].userId] | sort) == ([$raw.data.members[].userId] | sort) and
|
||||
$zero.ok == true and $zero.data.count == 0 and ($zero.data.members | length) == 0 and
|
||||
([$raw.data.members[].userId] | index($nonce)) == null
|
||||
' "$evidence_dir/devapp-member-known-exact.json" "$evidence_dir/devapp-member-known-raw.json" "$evidence_dir/devapp-member-zero-exact.json" >/dev/null || fail "devapp +member-list adapter alignment"
|
||||
pass "DEVAPP +member-list exact local-ID-filter+raw full-set known/guaranteed-zero stable-member alignment"
|
||||
|
||||
run_json "devapp member fixture exact before" "$evidence_dir/devapp-member-fixture-before-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
run_json "devapp member fixture raw before" "$evidence_dir/devapp-member-fixture-before-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-fixture-before-exact.json" "$evidence_dir/devapp-member-fixture-before-raw.json" >/dev/null || fail "devapp member fixture initially absent"
|
||||
|
||||
expect_confirmation "devapp +member-add confirmation" "$evidence_dir/devapp-member-add-confirm.json" \
|
||||
"$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type"
|
||||
expect_confirmation "devapp +member-remove confirmation" "$evidence_dir/devapp-member-remove-confirm.json" \
|
||||
"$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type"
|
||||
run_json "devapp member fixture exact after guards" "$evidence_dir/devapp-member-fixture-after-guards-exact.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
run_json "devapp member fixture raw after guards" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-fixture-after-guards-exact.json" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" >/dev/null || fail "devapp member confirmations made no writes"
|
||||
|
||||
run_json "devapp member exact add" "$evidence_dir/devapp-member-add-exact.json" \
|
||||
"$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member exact add raw readback" "$evidence_dir/devapp-member-add-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
jq -e --arg type "$member_type" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_add" and .data.resource.count == 1 and .data.resource.memberType == $type' \
|
||||
"$evidence_dir/devapp-member-add-exact.json" >/dev/null || fail "devapp member exact add terminal"
|
||||
jq -e --arg user "$member_user_id" --arg type "$member_type" '([.data.members[] | select(.userId == $user and .memberType == $type)] | length) == 1' \
|
||||
"$evidence_dir/devapp-member-add-exact-raw-readback.json" >/dev/null || fail "devapp member exact add raw stable-ID role readback"
|
||||
|
||||
run_json "devapp member raw remove" "$evidence_dir/devapp-member-remove-raw.json" \
|
||||
"$dws_bin" dev app member remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member raw remove exact readback" "$evidence_dir/devapp-member-remove-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
assert_jq "devapp member raw remove terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-remove-raw.json"
|
||||
assert_jq "devapp member raw remove exact absence" '.ok == true and .data.count == 0 and (.data.members | length) == 0' "$evidence_dir/devapp-member-remove-raw-exact-readback.json"
|
||||
|
||||
run_json "devapp member raw add" "$evidence_dir/devapp-member-add-raw.json" \
|
||||
"$dws_bin" dev app member add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member raw add exact readback" "$evidence_dir/devapp-member-add-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id"
|
||||
assert_jq "devapp member raw add terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-add-raw.json"
|
||||
jq -e --arg user "$member_user_id" --arg type "$member_type" \
|
||||
'.ok == true and .data.count == 1 and .data.members[0].userId == $user and .data.members[0].memberType == $type' \
|
||||
"$evidence_dir/devapp-member-add-raw-exact-readback.json" >/dev/null || fail "devapp member raw add exact stable-ID role readback"
|
||||
|
||||
run_json "devapp member exact remove" "$evidence_dir/devapp-member-remove-exact.json" \
|
||||
"$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes
|
||||
run_json "devapp member exact remove raw readback" "$evidence_dir/devapp-member-remove-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app member list --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp member exact remove terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_remove"' \
|
||||
"$evidence_dir/devapp-member-remove-exact.json"
|
||||
jq -e --arg user "$member_user_id" '([.data.members[].userId] | index($user)) == null' \
|
||||
"$evidence_dir/devapp-member-remove-exact-raw-readback.json" >/dev/null || fail "devapp member exact remove raw absence"
|
||||
pass "DEVAPP +member-add/+member-remove confirm0/exact+raw terminal-receipt/stable-ID role/absence readback"
|
||||
|
||||
run_json "devapp permission exact known" "$evidence_dir/devapp-permission-known-exact.json" \
|
||||
"$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --page-size 20
|
||||
run_json "devapp permission raw known" "$evidence_dir/devapp-permission-known-raw.json" \
|
||||
"$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --page-size 20
|
||||
run_json "devapp permission exact zero" "$evidence_dir/devapp-permission-zero-exact.json" \
|
||||
"$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20
|
||||
run_json "devapp permission raw zero" "$evidence_dir/devapp-permission-zero-raw.json" \
|
||||
"$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20
|
||||
assert_jq "devapp permission dual known/zero alignment" '
|
||||
(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.items | length) > 0 and
|
||||
([.data.permissions[].scopeValue] | sort) == ([$raw.data.items[].scopeValue] | sort) and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.permissions | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.items | length) == 0 and
|
||||
.meta.pagination.endpoint_exhausted == $raw.meta.pagination.endpoint_exhausted and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-permission-known-exact.json" "$evidence_dir/devapp-permission-known-raw.json" \
|
||||
"$evidence_dir/devapp-permission-zero-exact.json" "$evidence_dir/devapp-permission-zero-raw.json"
|
||||
pass "DEVAPP +permission-list exact+raw known-nonempty/guaranteed-zero stable-scope/cursor alignment"
|
||||
|
||||
run_json "devapp robot exact initial" "$evidence_dir/devapp-robot-get-exact.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
run_json "devapp robot raw initial" "$evidence_dir/devapp-robot-get-raw.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.robotStatus == $raw.data.robotStatus' "$evidence_dir/devapp-robot-get-exact.json" "$evidence_dir/devapp-robot-get-raw.json" >/dev/null || fail "devapp +robot-get initial dual object"
|
||||
pass "DEVAPP +robot-get exact+raw stable-app/status object alignment"
|
||||
|
||||
run_json "devapp robot raw config" "$evidence_dir/devapp-robot-config-raw.json" \
|
||||
"$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --desc "dual-live-raw" --mode STREAM --add-scope --yes
|
||||
run_json "devapp robot raw config exact readback" "$evidence_dir/devapp-robot-config-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw config terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-config-raw.json"
|
||||
jq -e --arg name "$bot_raw" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-raw-exact-readback.json" >/dev/null || fail "devapp robot raw config exact readback"
|
||||
run_json "devapp robot exact config" "$evidence_dir/devapp-robot-config-exact.json" \
|
||||
"$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "$bot_exact" --brief "dual-live-exact" --desc "dual-live-exact" --mode STREAM --yes
|
||||
run_json "devapp robot exact config raw readback" "$evidence_dir/devapp-robot-config-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact config terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-config-exact.json"
|
||||
jq -e --arg name "$bot_exact" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-exact-raw-readback.json" >/dev/null || fail "devapp robot exact config raw readback"
|
||||
pass "DEVAPP +robot-config confirm0/exact+raw terminal-receipt/config-field readback"
|
||||
|
||||
run_json "devapp robot raw enable" "$evidence_dir/devapp-robot-enable-raw.json" \
|
||||
"$dws_bin" dev app robot enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot raw enable exact readback" "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-enable-raw.json"
|
||||
assert_jq "devapp robot raw enable exact state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-raw-exact-readback.json"
|
||||
run_json "devapp robot exact disable" "$evidence_dir/devapp-robot-disable-exact.json" \
|
||||
"$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot exact disable raw readback" "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-disable-exact.json"
|
||||
assert_jq "devapp robot exact disable raw state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-exact-raw-readback.json"
|
||||
run_json "devapp robot raw reconfigure" "$evidence_dir/devapp-robot-reconfigure-raw.json" \
|
||||
"$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --mode STREAM --yes
|
||||
run_json "devapp robot exact enable" "$evidence_dir/devapp-robot-enable-exact.json" \
|
||||
"$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot exact enable raw readback" "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app robot get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-enable-exact.json"
|
||||
assert_jq "devapp robot exact enable raw state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-exact-raw-readback.json"
|
||||
run_json "devapp robot raw disable" "$evidence_dir/devapp-robot-disable-raw.json" \
|
||||
"$dws_bin" dev app robot disable --unified-app-id "$exact_app_id" --yes
|
||||
run_json "devapp robot raw disable exact readback" "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id"
|
||||
assert_jq "devapp robot raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-disable-raw.json"
|
||||
assert_jq "devapp robot raw disable exact state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-raw-exact-readback.json"
|
||||
pass "DEVAPP +robot-enable confirm0/exact+raw terminal-receipt/ONLINE readback"
|
||||
pass "DEVAPP +robot-disable confirm0/exact+raw terminal-receipt/UNCONFIGURED readback"
|
||||
|
||||
run_json "devapp event raw subscribe" "$evidence_dir/devapp-event-subscribe-raw.json" \
|
||||
"$dws_bin" dev app event subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes
|
||||
run_json "devapp event raw subscribe exact replay" "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes
|
||||
run_json "devapp event raw subscribe raw readback" "$evidence_dir/devapp-event-subscribe-raw-readback.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_raw" --page-size 100
|
||||
assert_jq "devapp event raw subscribe terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-event-subscribe-raw.json"
|
||||
assert_jq "devapp event raw subscribe exact replay" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json"
|
||||
jq -e --arg code "$event_raw" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-raw-readback.json" >/dev/null || fail "devapp event raw subscribe readback"
|
||||
run_json "devapp event exact subscribe" "$evidence_dir/devapp-event-subscribe-exact.json" \
|
||||
"$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" --yes
|
||||
run_json "devapp event exact subscribe raw readback" "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
assert_jq "devapp event exact subscribe terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-exact.json"
|
||||
jq -e --arg code "$event_exact" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" >/dev/null || fail "devapp event exact subscribe raw readback"
|
||||
pass "DEVAPP +event-subscribe confirm0/exact+raw terminal-receipt/eventCode readback"
|
||||
|
||||
run_json "devapp event exact known" "$evidence_dir/devapp-event-list-known-exact.json" \
|
||||
"$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
run_json "devapp event raw known" "$evidence_dir/devapp-event-list-known-raw.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100
|
||||
run_json "devapp event exact zero" "$evidence_dir/devapp-event-list-zero-exact.json" \
|
||||
"$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100
|
||||
run_json "devapp event raw zero" "$evidence_dir/devapp-event-list-zero-raw.json" \
|
||||
"$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100
|
||||
jq -e --arg code "$event_exact" '(input) as $raw | (input) as $ze | (input) as $zr |
|
||||
.ok == true and .data.count == 1 and .data.events[0].eventCode == $code and
|
||||
$raw.ok == true and ($raw.data.events | length) == 1 and $raw.data.events[0].eventCode == $code and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and
|
||||
$ze.ok == true and $ze.data.count == 0 and ($ze.data.events | length) == 0 and
|
||||
$zr.ok == true and ($zr.data.events | length) == 0 and
|
||||
$ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true' \
|
||||
"$evidence_dir/devapp-event-list-known-exact.json" "$evidence_dir/devapp-event-list-known-raw.json" \
|
||||
"$evidence_dir/devapp-event-list-zero-exact.json" "$evidence_dir/devapp-event-list-zero-raw.json" >/dev/null || fail "devapp +event-list dual known/zero alignment"
|
||||
pass "DEVAPP +event-list exact+raw known-nonempty/guaranteed-zero stable-event/cursor alignment"
|
||||
|
||||
run_json "devapp version exact zero before" "$evidence_dir/devapp-version-zero-exact.json" \
|
||||
"$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp version raw zero before" "$evidence_dir/devapp-version-zero-raw.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
assert_jq "devapp version guaranteed zero fixture" '(input) as $raw | .data.count == 0 and (.data.versions | length) == 0 and ($raw.data.items | length) == 0 and .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true' "$evidence_dir/devapp-version-zero-exact.json" "$evidence_dir/devapp-version-zero-raw.json"
|
||||
run_json "devapp version raw create" "$evidence_dir/devapp-version-create-raw.json" \
|
||||
"$dws_bin" dev app version create --unified-app-id "$exact_app_id" --desc "dual-live-raw" --yes
|
||||
local raw_version_id
|
||||
raw_version_id=$(jq -er '.data.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-raw.json") || fail "devapp raw version create stable ID"
|
||||
assert_jq "devapp raw version create terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-version-create-raw.json"
|
||||
run_json "devapp raw version exact readback" "$evidence_dir/devapp-version-create-raw-exact-readback.json" \
|
||||
"$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$raw_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$raw_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-raw-exact-readback.json" >/dev/null || fail "devapp raw version exact readback"
|
||||
run_json "devapp version exact create" "$evidence_dir/devapp-version-create-exact.json" \
|
||||
"$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "dual-live-exact" --yes
|
||||
local exact_version_id
|
||||
exact_version_id=$(jq -er '.data.resource.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-exact.json") || fail "devapp exact version create stable ID"
|
||||
assert_jq "devapp exact version create terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-version-create-exact.json"
|
||||
run_json "devapp exact version raw readback" "$evidence_dir/devapp-version-create-exact-raw-readback.json" \
|
||||
"$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-exact-raw-readback.json" >/dev/null || fail "devapp exact version raw readback"
|
||||
pass "DEVAPP +version-create confirm0/exact+raw terminal-receipt/app+version-ID readback"
|
||||
|
||||
local version_aligned=false
|
||||
for _ in 1 2 3 4 5; do
|
||||
run_json "devapp version exact known" "$evidence_dir/devapp-version-known-exact.json" \
|
||||
"$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100
|
||||
run_json "devapp version raw known" "$evidence_dir/devapp-version-known-raw.json" \
|
||||
"$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100
|
||||
if jq -e --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" '
|
||||
(input) as $raw |
|
||||
.data.count >= 1 and
|
||||
([.data.versions[].versionId] | index($raw_version)) != null and
|
||||
([.data.versions[].versionId] | index($exact_version)) != null and
|
||||
([.data.versions[].versionId] | sort) == ([$raw.data.items[].versionId] | sort) and
|
||||
.meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true
|
||||
' "$evidence_dir/devapp-version-known-exact.json" "$evidence_dir/devapp-version-known-raw.json" >/dev/null 2>&1; then
|
||||
version_aligned=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ $version_aligned != true ]]; then
|
||||
jq -n -c --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" \
|
||||
--slurpfile exact "$evidence_dir/devapp-version-known-exact.json" \
|
||||
--slurpfile raw "$evidence_dir/devapp-version-known-raw.json" '{
|
||||
diagnostic:"DEVAPP-VERSION-LIST",
|
||||
exact_count:$exact[0].data.count,
|
||||
exact_items:($exact[0].data.versions|length),
|
||||
raw_items:($raw[0].data.items|length),
|
||||
ids_equal:(([$exact[0].data.versions[].versionId]|sort)==([$raw[0].data.items[].versionId]|sort)),
|
||||
raw_version_visible:(([$exact[0].data.versions[].versionId]|index($raw_version))!=null),
|
||||
exact_version_visible:(([$exact[0].data.versions[].versionId]|index($exact_version))!=null),
|
||||
exact_pagination_type:($exact[0].meta.pagination|type),
|
||||
raw_pagination_type:($raw[0].meta.pagination|type),
|
||||
exact_exhausted:$exact[0].meta.pagination.endpoint_exhausted,
|
||||
raw_exhausted:$raw[0].meta.pagination.endpoint_exhausted
|
||||
}' >&2
|
||||
fail "devapp version known alignment"
|
||||
fi
|
||||
pass "DEVAPP +version-list exact+raw known-nonempty/empty-fixture-zero stable-version/cursor alignment"
|
||||
|
||||
run_json "devapp version exact get" "$evidence_dir/devapp-version-get-exact.json" \
|
||||
"$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw get" "$evidence_dir/devapp-version-get-raw.json" \
|
||||
"$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | .data.unifiedAppId == $app and .data.versionId == $version and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-get-exact.json" "$evidence_dir/devapp-version-get-raw.json" >/dev/null || fail "devapp +version-get dual object"
|
||||
pass "DEVAPP +version-get exact+raw stable-app+version-ID object alignment"
|
||||
|
||||
run_json "devapp version exact approval" "$evidence_dir/devapp-version-approval-exact.json" \
|
||||
"$dws_bin" devapp +version-check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw approval" "$evidence_dir/devapp-version-approval-raw.json" \
|
||||
"$dws_bin" dev app version check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-approval-exact.json" "$evidence_dir/devapp-version-approval-raw.json" >/dev/null || fail "devapp +version-check-approval dual object"
|
||||
pass "DEVAPP +version-check-approval exact+raw stable-app+version-ID pending/terminal alignment"
|
||||
|
||||
run_json "devapp version exact status" "$evidence_dir/devapp-version-status-exact.json" \
|
||||
"$dws_bin" devapp +version-status --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
run_json "devapp version raw status" "$evidence_dir/devapp-version-status-raw.json" \
|
||||
"$dws_bin" dev app version status --unified-app-id "$exact_app_id" --version-id "$exact_version_id"
|
||||
jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-status-exact.json" "$evidence_dir/devapp-version-status-raw.json" >/dev/null || fail "devapp +version-status dual object"
|
||||
pass "DEVAPP +version-status exact+raw stable-app+version-ID status alignment"
|
||||
|
||||
run_json "devapp raw delete" "$evidence_dir/devapp-delete-raw.json" \
|
||||
"$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes
|
||||
assert_jq "devapp raw delete terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-delete-raw.json"
|
||||
run_json "devapp raw delete exact absence" "$evidence_dir/devapp-delete-raw-exact-absence.json" \
|
||||
"$dws_bin" devapp +list --name "$raw_app_name" --page-size 20
|
||||
run_json "devapp raw delete raw absence" "$evidence_dir/devapp-delete-raw-raw-absence.json" \
|
||||
"$dws_bin" dev app list --name "$raw_app_name" --page-size 20
|
||||
assert_jq "devapp raw delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-raw-exact-absence.json" "$evidence_dir/devapp-delete-raw-raw-absence.json"
|
||||
raw_app_id=""
|
||||
|
||||
run_json "devapp exact delete" "$evidence_dir/devapp-delete-exact.json" \
|
||||
"$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes
|
||||
assert_jq "devapp exact delete terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "delete"' "$evidence_dir/devapp-delete-exact.json"
|
||||
run_json "devapp exact delete exact absence" "$evidence_dir/devapp-delete-exact-exact-absence.json" \
|
||||
"$dws_bin" devapp +list --name "$exact_updated_name" --page-size 20
|
||||
run_json "devapp exact delete raw absence" "$evidence_dir/devapp-delete-exact-raw-absence.json" \
|
||||
"$dws_bin" dev app list --name "$exact_updated_name" --page-size 20
|
||||
assert_jq "devapp exact delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-exact-exact-absence.json" "$evidence_dir/devapp-delete-exact-raw-absence.json"
|
||||
exact_app_id=""
|
||||
pass "DEVAPP +delete confirm0/exact+raw terminal-receipt/dual absence cleanup"
|
||||
|
||||
pass "DEVAPP-INVENTORY public=25 dual-layer-pass=25 unavailable=5 cleanup=zero-residual"
|
||||
}
|
||||
|
||||
run_agoal_gate() {
|
||||
assert_public_surface agoal "+contract-fields
|
||||
+obj-template-list
|
||||
+report-statistics-list
|
||||
+report-submit-detail
|
||||
+user-rules"
|
||||
local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A"
|
||||
local future_date="2099-12-31"
|
||||
|
||||
run_json "agoal report statistics exact known" "$evidence_dir/agoal-report-known-exact.json" \
|
||||
"$dws_bin" agoal +report-statistics-list
|
||||
run_json "agoal report statistics raw known" "$evidence_dir/agoal-report-known-raw.json" \
|
||||
"$dws_bin" agoal report list-statistics
|
||||
assert_jq "agoal report statistics known alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and
|
||||
(.data.count == ($raw.content | length)) and
|
||||
([.data.statistics[].templateId] | sort) == ([$raw.content[].templateId] | sort)
|
||||
' "$evidence_dir/agoal-report-known-exact.json" "$evidence_dir/agoal-report-known-raw.json"
|
||||
run_json "agoal report statistics exact zero" "$evidence_dir/agoal-report-zero-exact.json" \
|
||||
"$dws_bin" agoal +report-statistics-list --keyword "$nonce"
|
||||
run_json "agoal report statistics raw zero" "$evidence_dir/agoal-report-zero-raw.json" \
|
||||
"$dws_bin" agoal report list-statistics --keyword "$nonce"
|
||||
assert_jq "agoal report statistics zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and
|
||||
(.data.statistics | type) == "array" and (.data.statistics | length) == 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) == 0
|
||||
' "$evidence_dir/agoal-report-zero-exact.json" "$evidence_dir/agoal-report-zero-raw.json"
|
||||
pass "AGOAL +report-statistics-list exact+raw known-nonempty/guaranteed-zero stable-template alignment"
|
||||
|
||||
run_json "agoal template exact known" "$evidence_dir/agoal-template-known-exact.json" \
|
||||
"$dws_bin" agoal +obj-template-list --page 1 --page-size 20
|
||||
run_json "agoal template raw known" "$evidence_dir/agoal-template-known-raw.json" \
|
||||
"$dws_bin" agoal obj-template list --page 1 --page-size 20
|
||||
assert_jq "agoal template known alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and
|
||||
.data.totalCount == $raw.content.totalCount and
|
||||
([.data.templates[] | (.id // .templateId)] | sort) ==
|
||||
([$raw.content.result[] | (.id // .templateId)] | sort)
|
||||
' "$evidence_dir/agoal-template-known-exact.json" "$evidence_dir/agoal-template-known-raw.json"
|
||||
run_json "agoal template exact zero" "$evidence_dir/agoal-template-zero-exact.json" \
|
||||
"$dws_bin" agoal +obj-template-list --keyword "$nonce" --page 1 --page-size 20
|
||||
run_json "agoal template raw zero" "$evidence_dir/agoal-template-zero-raw.json" \
|
||||
"$dws_bin" agoal obj-template list --keyword "$nonce" --page 1 --page-size 20
|
||||
assert_jq "agoal template zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and
|
||||
(.data.templates | type) == "array" and (.data.templates | length) == 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize
|
||||
' "$evidence_dir/agoal-template-zero-exact.json" "$evidence_dir/agoal-template-zero-raw.json"
|
||||
pass "AGOAL +obj-template-list exact+raw known-nonempty/guaranteed-zero stable-ID/page alignment"
|
||||
|
||||
run_json "agoal contract fields raw" "$evidence_dir/agoal-fields-raw.json" \
|
||||
"$dws_bin" agoal contract fields
|
||||
local field_id
|
||||
field_id=$(jq -er '.content[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-fields-raw.json") || fail "agoal contract fields raw fixture"
|
||||
run_json "agoal contract fields exact known" "$evidence_dir/agoal-fields-known-exact.json" \
|
||||
"$dws_bin" agoal +contract-fields --keyword "$field_id"
|
||||
run_json "agoal contract fields exact zero" "$evidence_dir/agoal-fields-zero-exact.json" \
|
||||
"$dws_bin" agoal +contract-fields --keyword "$nonce"
|
||||
jq -e --arg field_id "$field_id" --arg nonce "$nonce" '
|
||||
(input) as $zero | (input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and
|
||||
.data.fields[0].id == $field_id and
|
||||
$zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and
|
||||
($zero.data.fields | length) == 0 and
|
||||
$raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) > 0 and
|
||||
([$raw.content[].id] | index($field_id)) != null and
|
||||
([$raw.content[] | [.id,.code,.title,.category,.type] | map(select(type == "string")) | join(" ") | ascii_downcase] |
|
||||
map(contains($nonce | ascii_downcase)) | any) == false
|
||||
' "$evidence_dir/agoal-fields-known-exact.json" "$evidence_dir/agoal-fields-zero-exact.json" "$evidence_dir/agoal-fields-raw.json" >/dev/null 2>&1 || fail "agoal contract fields adapter alignment"
|
||||
pass "AGOAL +contract-fields exact local-filter+raw full-set known/guaranteed-zero stable-ID alignment"
|
||||
|
||||
run_json "agoal user rules raw" "$evidence_dir/agoal-rules-raw.json" \
|
||||
"$dws_bin" agoal user rules
|
||||
local rule_id
|
||||
rule_id=$(jq -er '.content.rules[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-rules-raw.json") || fail "agoal user rules raw fixture"
|
||||
run_json "agoal user rules exact known" "$evidence_dir/agoal-rules-known-exact.json" \
|
||||
"$dws_bin" agoal +user-rules --rule-id "$rule_id"
|
||||
run_json "agoal user rules exact zero" "$evidence_dir/agoal-rules-zero-exact.json" \
|
||||
"$dws_bin" agoal +user-rules --rule-id "$nonce"
|
||||
jq -e --arg rule_id "$rule_id" --arg nonce "$nonce" '
|
||||
(input) as $zero | (input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 1 and .data.rules[0].id == $rule_id and
|
||||
$zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and
|
||||
($zero.data.rules | length) == 0 and
|
||||
$raw.success == true and ($raw.content.rules | type) == "array" and ($raw.content.rules | length) > 0 and
|
||||
([$raw.content.rules[].id] | index($rule_id)) != null and
|
||||
([$raw.content.rules[].id] | index($nonce)) == null
|
||||
' "$evidence_dir/agoal-rules-known-exact.json" "$evidence_dir/agoal-rules-zero-exact.json" "$evidence_dir/agoal-rules-raw.json" >/dev/null 2>&1 || fail "agoal user rules adapter alignment"
|
||||
pass "AGOAL +user-rules exact local-filter+raw full-set known/guaranteed-zero stable-rule alignment"
|
||||
|
||||
local template_id=""
|
||||
local submit_state=""
|
||||
while IFS= read -r candidate_template; do
|
||||
for candidate_state in ON_TIME LATE NOT_SUBMITTED; do
|
||||
run_json "agoal submission raw fixture scan" "$evidence_dir/agoal-submit-scan.json" \
|
||||
"$dws_bin" agoal report submit-detail --template-id "$candidate_template" --submit-state "$candidate_state" --page 1 --page-size 20 --timeout 60
|
||||
if jq -e '.success == true and (.content.result | type) == "array" and (.content.result | length) > 0' "$evidence_dir/agoal-submit-scan.json" >/dev/null; then
|
||||
template_id=$candidate_template
|
||||
submit_state=$candidate_state
|
||||
cp "$evidence_dir/agoal-submit-scan.json" "$evidence_dir/agoal-submit-known-raw.json"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done < <(jq -r '.content[].templateId' "$evidence_dir/agoal-report-known-raw.json")
|
||||
[[ -n $template_id && -n $submit_state ]] || fail "agoal submission known-nonempty fixture"
|
||||
run_json "agoal submission exact known" "$evidence_dir/agoal-submit-known-exact.json" \
|
||||
"$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state "$submit_state" --page 1 --page-size 20 --timeout 60
|
||||
assert_jq "agoal submission known alignment" '
|
||||
(input) as $raw |
|
||||
def stable_id: (.reportId // .user.dingUserId // .user.id);
|
||||
.ok == true and .outcome == "success" and .data.count > 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and
|
||||
.data.totalCount == $raw.content.totalCount and
|
||||
([.data.submissions[] | stable_id] | sort) == ([$raw.content.result[] | stable_id] | sort)
|
||||
' "$evidence_dir/agoal-submit-known-exact.json" "$evidence_dir/agoal-submit-known-raw.json"
|
||||
run_json "agoal submission exact guaranteed zero" "$evidence_dir/agoal-submit-zero-exact.json" \
|
||||
"$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60
|
||||
run_json "agoal submission raw guaranteed zero" "$evidence_dir/agoal-submit-zero-raw.json" \
|
||||
"$dws_bin" agoal report submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60
|
||||
assert_jq "agoal submission guaranteed zero alignment" '
|
||||
(input) as $raw |
|
||||
.ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and
|
||||
(.data.submissions | type) == "array" and (.data.submissions | length) == 0 and
|
||||
$raw.success == true and ($raw.content.result | type) == "array" and
|
||||
($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and
|
||||
.data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize
|
||||
' "$evidence_dir/agoal-submit-zero-exact.json" "$evidence_dir/agoal-submit-zero-raw.json"
|
||||
pass "AGOAL +report-submit-detail exact+raw known-nonempty/future guaranteed-zero stable-user/page alignment"
|
||||
|
||||
pass "AGOAL-INVENTORY public=5 dual-layer-pass=5 unavailable=11"
|
||||
}
|
||||
|
||||
if [[ $selected_product == all || $selected_product == dev ]]; then
|
||||
run_dev_gate
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == devapp ]]; then
|
||||
# Added below after the Agoal proof block so product execution remains
|
||||
# Dev -> DevApp -> Agoal when `all` is selected.
|
||||
run_devapp_gate
|
||||
fi
|
||||
if [[ $selected_product == all || $selected_product == agoal ]]; then
|
||||
run_agoal_gate
|
||||
fi
|
||||
@@ -40,6 +40,8 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devapp.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_agoal.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -43,13 +43,14 @@ cli_version: ">=1.0.15"
|
||||
|
||||
| 服务 | shortcut 数 | multi skill |
|
||||
|---|---:|---|
|
||||
| `agoal` | 5 | `—` |
|
||||
| `aisearch` | 1 | `—` |
|
||||
| `aitable` | 100 | `dingtalk-aitable` |
|
||||
| `attendance` | 8 | `dingtalk-misc` |
|
||||
| `calendar` | 27 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `contact` | 13 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-misc` |
|
||||
| `devapp` | 25 | `dingtalk-misc` |
|
||||
| `ding` | 1 | `dingtalk-misc` |
|
||||
| `doc` | 45 | `dingtalk-doc` |
|
||||
| `drive` | 28 | `dingtalk-drive` |
|
||||
|
||||
@@ -25,19 +25,25 @@
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws devapp +create` | write | 创建开放平台企业内部应用 |
|
||||
| `dws devapp +credentials-get` | read | 读取开放平台应用凭证 |
|
||||
| `dws devapp +delete` | high-risk-write | 删除开放平台企业内部应用(不可逆) |
|
||||
| `dws devapp +disable` | high-risk-write | 停用开放平台企业内部应用 |
|
||||
| `dws devapp +enable` | write | 启用开放平台企业内部应用 |
|
||||
| `dws devapp +event-list` | read | 查询应用已订阅的事件列表 |
|
||||
| `dws devapp +event-list` | read | 查询应用可用事件目录与订阅状态 |
|
||||
| `dws devapp +event-subscribe` | write | 订阅应用事件回调 |
|
||||
| `dws devapp +get` | read | 查询开放平台企业内部应用详情 |
|
||||
| `dws devapp +list` | read | 查询开放平台企业内部应用列表 |
|
||||
| `dws devapp +member-add` | write | 添加开放平台应用成员 |
|
||||
| `dws devapp +member-list` | read | 查询开放平台应用成员 |
|
||||
| `dws devapp +member-remove` | high-risk-write | 移除开放平台应用成员 |
|
||||
| `dws devapp +permission-list` | read | 查询开放平台应用权限列表 |
|
||||
| `dws devapp +robot-config` | write | 创建或更新现有应用的机器人配置(upsert) |
|
||||
| `dws devapp +robot-disable` | high-risk-write | 停用现有应用的机器人能力 |
|
||||
| `dws devapp +robot-enable` | write | 启用现有应用机器人能力(纯启用,无需配置字段) |
|
||||
| `dws devapp +robot-get` | read | 查询现有应用的机器人配置 |
|
||||
| `dws devapp +update` | write | 修改开放平台企业内部应用基础信息 |
|
||||
| `dws devapp +version-check-approval` | read | 预检版本发布是否需要审批(不实际发布) |
|
||||
| `dws devapp +version-create` | write | 基于当前配置创建应用新版本 |
|
||||
| `dws devapp +version-get` | read | 查询指定版本详情 |
|
||||
| `dws devapp +version-list` | read | 分页查询应用版本列表 |
|
||||
| `dws devapp +version-status` | read | 查询版本发布/审批状态 |
|
||||
|
||||
Reference in New Issue
Block a user