Files
larksuite__cli/shortcuts/base/app_test.go
xiaomi-bytedance 723f884e9b feat(base): support BaseApp application mode (#2231)
* feat(base): add BaseApp workspace, page and block shortcuts

Implement the CLI layer of the BaseApp CLI/OpenAPI protocol design: 17 new
shortcuts covering workspace entities, blank app creation, page CRUD and page
block CRUD, plus skill references and dry-run E2E for each.

The data_config validator moves to a neutral block_data_config.go with chart
logic unchanged; list and richText dispatch are new branches, so dashboard
behaviour is untouched. Command spaces stay separate — dashboard commands never
take --app-token and app block commands never take --dashboard-id. The one
exception is +app-block-get-data, which shares the dashboard endpoint, execute
and dry-run hooks and therefore takes --base-token instead of --app-token.

This phase ships no +app-block-delete and no page arrange command; both the
help text and the skill docs spell out that a block type cannot be changed
after creation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(base): implement app mode shortcuts and list components

* feat: support deleting BaseApp via drive delete

* fix: 修复 workspace scope

* fix: correct BaseApp permission scopes

* feat: 新增 moveIn workspace 逻辑

* feat(base): support multi-datasource data_config for BaseApp charts

BaseApp page charts follow section 8 图表协议 of the App CLI RPC 协议,
which differs from dashboard charts by supporting multiple data sources:
base_token is a single top-level value shared by every source, while
table_name/series/count_all/group_by/filter move into each data_sources[]
element (plus top-level data_source_mode and sort). The per-source value
semantics are identical to dashboard charts, so each data_sources[] element
reuses normalizeDataConfig / validateChartDataConfig; the wrapper only adds
the top-level structure. Dashboard charts keep the flat shape; the list
protocol is untouched.

- block_data_config.go: add normalizeAppChartDataConfig /
  validateAppChartDataConfig / validateAppBlockDataConfig
- app_block_create/update: route chart blocks to the multi-datasource
  normalize/validate; refresh tips and examples
- reference doc: rewrite the chart section for the multi-datasource shape
- unit + e2e tests: migrate chart cases to data_sources; add a
  multi-datasource combo case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(base): map BaseApp richText block type to the wire type "text"

The rich-text widget's API type is "text" (App CLI RPC 协议 §10), but the
CLI exposes the friendlier "richText" alias and was sending it verbatim, so
the backend rejected +app-block-create --type richText with "type is
invalid". Map richText -> text when building the request body; the
user-facing --type richText is unchanged. Add TestAppRichTextTypeMapsToText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(base): remove internal protocol doc link/reference from skills

The BaseApp skill references pointed at an internal Lark doc (deep link with
a private token) as the source of truth, which must not ship in this repo.
Drop the link and the doc name entirely from the reference markdown and from
code comments; describe behavior in neutral terms ("服务端协议 / 服务端返回和校验")
instead. No functional change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: create workspace for BaseApp when omitted

* fix(base): align BaseApp block protocol

* fix: 删掉废弃的 workspace-entity-remove

* fix(base): align app get reference response

* fix(base): align app shortcuts with API contract

* fix(base): refine app mode shortcut contracts

* fix: use entity_type for workspace entity filtering

* fix: return workspace URLs for base workspace ops

* fix(base): enforce unique app block names

* fix(base): use chart token for app block data

* 明确baseapp边界,不导向到dashboard-arrange

* docs(base): clarify app copy is unsupported

* docs(base): define unsupported app page operations

* fix(base): align app block text type with dashboard

AppMode 的文本组件此前对外叫 richText,发送时再映射成 wire 上的 text,
而读取方向没有反向映射:写进去用 richText、读回来是 text,同一个 CLI
表面自相矛盾,回填或幂等复建时会被枚举校验拒掉。

统一成 text,与 Dashboard 文本组件同名同义:
- appBlockTypes/isAppBlockType/textBlockTypes 去掉 richText
- 删除 appBlockBody 里的 richText → text 发送期映射
- help、枚举、示例、tips 与 baseapp block data_config reference 同步
- 新增回归测试,确保 richText 不再被接受也不再出现在枚举里

richText 不保留别名:+app-* 尚未随已发布版本对外,无存量调用方。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(base): resolve BaseApp URLs

Migrate the net changes from bitable/base_cli!1308 onto the current BaseApp development branch.

* fix(base): remove app block list type filter

* docs: preserve explicit intent when reusing BaseApp blocks

* fix(base): route +app-block-get-data to base_apps endpoint

Move the shortcut off the dashboard route and onto the dedicated
BaseApp block-data endpoint:

- URL: /open-apis/base/v3/base_apps/:app_token/blocks/:block_id/data
- base_token is passed as a required query parameter per the new IDL
- Refresh --block-id description and tips to list all producers of the
  chart_token (create/list/get) and note the cht… prefix
- Update the dryrun test to expect the new URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: clarify unsupported BaseApp copy paths

* fix: front-load BaseApp copy stop rule

* fix: surface unsupported PageGroup operations

* fix: preserve PageGroup support boundary

* docs(base): clarify unsupported app block handling

* docs(base): clarify how to read text block content

Text blocks have no /data endpoint; calling +app-block-get-data on
one returns a generic server 500. Point readers at +app-block-get,
whose data_config.text carries the Markdown source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: clarify immutable BaseApp block types

* docs(base): correct chart date filter format

* docs(base): explain inaccessible app pages

* docs: require workspace removal lookup

* fix(base): return workspace move-in result faithfully

* chore(base): adapt BaseApp changes to upstream main

* fix(base): align app mode changes with upstream scope

* fix(base): address app mode review feedback

* test(base): assert app data transport error contract

* fix(base): satisfy app mode merge requirements

* refactor(base): align app mode filenames

* docs: fix BaseApp rename guidance

* fix(base): remove unsupported workspace icon

* docs(base): clarify app mode concepts and config reuse

---------

Co-authored-by: weibiao.x <weibiao.x@bytedance.com>
Co-authored-by: zhangbinkai.zbk <zhangbinkai.zbk@bytedance.com>
Co-authored-by: yurunjie <yurunjie.xx@bytedance.com>
Co-authored-by: Codex <codex@example.com>
2026-08-13 16:20:34 +08:00

712 lines
26 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDryRunWorkspaceOps(t *testing.T) {
ctx := context.Background()
createRT := newBaseTestRuntime(map[string]string{"name": "Growth"}, nil, nil)
assertDryRunContains(t, dryRunWorkspaceCreate(ctx, createRT), "POST /open-apis/base/v3/workspaces", `"name":"Growth"`)
listRT := newBaseTestRuntime(map[string]string{"workspace-token": "ws_x", "type": "BaseApp"}, nil, map[string]int{"page-size": 50})
assertDryRunContains(t, dryRunWorkspaceEntityList(ctx, listRT), "GET /open-apis/base/v3/workspaces/ws_x/entities", "page_size=50", "entity_type=baseapp")
moveInRT := newBaseTestRuntime(map[string]string{"workspace-token": "ws_x", "entity-token": "bascn_1"}, nil, nil)
assertDryRunContains(t, dryRunWorkspaceMoveIn(ctx, moveInRT), "POST /open-apis/base/v3/workspaces/ws_x/move_in", `"entity_token":"bascn_1"`)
}
func TestDryRunBaseappOps(t *testing.T) {
ctx := context.Background()
createRT := newBaseTestRuntime(map[string]string{"name": "Sales app", "workspace-token": "ws_x", "theme-style": "cloudBlue"}, nil, nil)
assertDryRunContains(t, dryRunBaseappCreate(ctx, createRT),
"POST /open-apis/base/v3/base_apps",
`"name":"Sales app"`,
`"workspace_token":"ws_x"`,
`"theme":{"theme_style":"cloudBlue"}`,
)
createOut := dryRunBaseappCreate(ctx, createRT).Format()
for _, unwanted := range []string{"/workspaces/ws_x/move_in", "/base/v3/bases", `"base_token"`} {
if strings.Contains(createOut, unwanted) {
t.Fatalf("atomic app create must not contain %q:\n%s", unwanted, createOut)
}
}
getRT := newBaseTestRuntime(map[string]string{"app-token": "app_x"}, nil, nil)
assertDryRunContains(t, dryRunBaseappGet(ctx, getRT), "GET /open-apis/base/v3/base_apps/app_x")
}
func TestBaseAppGetOnlyAcceptsAppToken(t *testing.T) {
if len(BaseAppGet.Flags) != 1 || BaseAppGet.Flags[0].Name != "app-token" {
t.Fatalf("+app-get flags=%v", BaseAppGet.Flags)
}
}
func TestAppRefContainsBase(t *testing.T) {
if !appRefContainsBase(map[string]interface{}{"bas_x": []interface{}{"Orders"}}, "bas_x") {
t.Fatal("expected ref key to identify a referenced Base")
}
if appRefContainsBase(map[string]interface{}{"bas_y": []interface{}{"Orders"}}, "bas_x") {
t.Fatal("unexpected Base match")
}
}
func TestAppCreateFlags(t *testing.T) {
flags := map[string]common.Flag{}
for _, flag := range BaseAppCreate.Flags {
flags[flag.Name] = flag
}
if len(flags) != 3 || !flags["name"].Required || !flags["workspace-token"].Required {
t.Fatalf("+app-create flags=%v, want required name/workspace-token and optional theme-style", BaseAppCreate.Flags)
}
for _, removed := range []string{"base-token", "base-name", "table-name"} {
if _, ok := flags[removed]; ok {
t.Fatalf("+app-create must not expose --%s", removed)
}
}
}
func TestBaseAppCopyBoundaryInShortcutHelp(t *testing.T) {
copyHelp := BaseBaseCopy.Description + " " + strings.Join(BaseBaseCopy.Tips, " ")
if !strings.Contains(copyHelp, "not a BaseApp") || !strings.Contains(copyHelp, "BaseApp/AppMode copy is unsupported") {
t.Fatalf("+base-copy help must reject BaseApp copy substitution: %q", copyHelp)
}
createHelp := BaseAppCreate.Description + " " + strings.Join(BaseAppCreate.Tips, " ")
if !strings.Contains(createHelp, "not a copy") || !strings.Contains(createHelp, "does not copy an existing BaseApp") {
t.Fatalf("+app-create help must reject BaseApp copy substitution: %q", createHelp)
}
}
func TestDryRunBaseappPageOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"app-token": "app_x"}, nil, map[string]int{"page-size": 20})
assertDryRunContains(t, dryRunBaseappPageList(ctx, listRT), "GET /open-apis/base/v3/base_apps/app_x/pages", "page_size=20")
getRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "page-id": "pg_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseappPageGet(ctx, getRT), "GET /open-apis/base/v3/base_apps/app_x/pages/pg_1")
createRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "name": "Overview"}, nil, nil)
createOut := dryRunBaseappPageCreate(ctx, createRT).Format()
assertDryRunContains(t, dryRunBaseappPageCreate(ctx, createRT), "POST /open-apis/base/v3/base_apps/app_x/pages", `"name":"Overview"`)
if strings.Contains(createOut, "page_group_id") {
t.Fatalf("page create must not send page_group_id:\n%s", createOut)
}
renameRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "page-id": "pg_1", "name": "Sales"}, nil, nil)
assertDryRunContains(t, dryRunBaseappPageRename(ctx, renameRT), "PATCH /open-apis/base/v3/base_apps/app_x/pages/pg_1", `"name":"Sales"`)
deleteRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "page-id": "pg_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseappPageDelete(ctx, deleteRT), "DELETE /open-apis/base/v3/base_apps/app_x/pages/pg_1")
}
func TestBaseAppPageListExplainsEmptyNamePermission(t *testing.T) {
tips := strings.Join(BaseAppPageList.Tips, "\n")
for _, want := range []string{`name=""`, "no permission", "untitled page"} {
if !strings.Contains(tips, want) {
t.Fatalf("+app-page-list tips must explain empty-name permission semantics; missing %q in:\n%s", want, tips)
}
}
}
func TestDryRunAppBlockOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "page-id": "pg_1"}, nil, map[string]int{"page-size": 20})
assertDryRunContains(t, dryRunAppBlockList(ctx, listRT), "GET /open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks", "page_size=20")
getRT := newBaseTestRuntime(map[string]string{"app-token": "app_x", "page-id": "pg_1", "block-id": "wid_1"}, nil, nil)
assertDryRunContains(t, dryRunAppBlockGet(ctx, getRT), "GET /open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks/wid_1")
createRT := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"page-id": "pg_1",
"name": "Sales by month",
"type": "line",
"data-config": `{"base_token":"basx","data_sources":[{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}]}]}`,
}, nil, nil)
assertDryRunContains(t, dryRunAppBlockCreate(ctx, createRT),
"POST /open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks",
`"type":"line"`,
`"name":"Sales by month"`,
`"base_token":"basx"`,
`"data_sources"`,
)
listCreateRT := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"page-id": "pg_1",
"name": "Orders",
"type": "list",
"sub-type": "card",
"data-config": `{"base_token":"bas_x","table_name":"Orders","fields":[],"card_config":{}}`,
}, nil, nil)
assertDryRunContains(t, dryRunAppBlockCreate(ctx, listCreateRT), `"type":"list"`, `"sub_type":"card"`, `"base_token":"bas_x"`)
standardListRT := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"page-id": "pg_1",
"name": "Orders",
"type": "list",
"data-config": `{"base_token":"bas_x","table_name":"Orders"}`,
}, nil, nil)
if out := dryRunAppBlockCreate(ctx, standardListRT).Format(); strings.Contains(out, `"sub_type"`) {
t.Fatalf("default standard sub_type must be omitted:\n%s", out)
}
updateRT := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"page-id": "pg_1",
"block-id": "wid_1",
"name": "Monthly sales",
"data-config": `{"filter":{"conjunction":"and","conditions":[]}}`,
}, nil, nil)
updateDR := dryRunAppBlockUpdate(ctx, updateRT)
assertDryRunContains(t, updateDR, "PATCH /open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks/wid_1", `"name":"Monthly sales"`, `"conjunction":"and"`)
if out := updateDR.Format(); strings.Contains(out, `"type"`) {
t.Fatalf("update must not send a block type:\n%s", out)
}
if out := updateDR.Format(); strings.Contains(out, `"table_name"`) || strings.Contains(out, `"series"`) {
t.Fatalf("update must not inject omitted data_config fields:\n%s", out)
}
}
// The rich-text widget is spelled "text" on both the CLI surface and the wire,
// matching the dashboard block spelling; nothing is rewritten on send.
func TestAppTextTypeIsSentVerbatim(t *testing.T) {
ctx := context.Background()
rt := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"page-id": "pg_1",
"name": "说明",
"type": "text",
"data-config": `{"text":"hi"}`,
}, nil, nil)
dr := dryRunAppBlockCreate(ctx, rt)
assertDryRunContains(t, dr, `"type":"text"`, `"text":"hi"`)
if out := dr.Format(); strings.Contains(out, `"richText"`) {
t.Fatalf("richText must not appear anywhere:\n%s", out)
}
}
// richText was the old CLI-only alias; app block types are now spelled exactly
// like dashboard block types, so it must no longer be accepted.
func TestAppBlockTypeRejectsRichText(t *testing.T) {
if isAppBlockType("richText") {
t.Fatal("richText must no longer be a valid app block type")
}
for _, blockType := range appBlockTypes() {
if blockType == "richText" {
t.Fatal("richText must not be advertised in the app block type enum")
}
}
if !isAppBlockType("text") {
t.Fatal("text must be a valid app block type")
}
}
func TestAppBlockGetDataUsesAppPath(t *testing.T) {
ctx := context.Background()
rt := newBaseTestRuntime(map[string]string{
"app-token": "app_x",
"base-token": "bas_x",
"block-id": "cht_chart",
}, nil, nil)
appOut := BaseAppBlockGetData.DryRun(ctx, rt).Format()
if !strings.Contains(appOut, "GET /open-apis/base/v3/base_apps/app_x/blocks/cht_chart/data?base_token=bas_x") {
t.Fatalf("unexpected path:\n%s", appOut)
}
}
func TestAppBlockGetDataRequiredFlags(t *testing.T) {
required := map[string]bool{}
for _, flag := range BaseAppBlockGetData.Flags {
if flag.Required {
required[flag.Name] = true
}
}
if !required["app-token"] || !required["base-token"] || !required["block-id"] {
t.Fatalf("required flags=%v want app-token, base-token and block-id", required)
}
for _, flag := range BaseAppBlockGetData.Flags {
if flag.Name == "block-id" && !strings.Contains(flag.Desc, "chart_token") {
t.Fatalf("--block-id must describe its chart_token contract: %v", flag)
}
}
for _, flag := range BaseAppBlockGetData.Flags {
if flag.Name == "page-id" {
t.Fatalf("page-id must not be accepted: %v", BaseAppBlockGetData.Flags)
}
}
}
func TestAppUnsupportedBlockTips(t *testing.T) {
for _, shortcut := range []common.Shortcut{
BaseAppBlockList,
BaseAppBlockGet,
BaseAppBlockGetData,
BaseAppBlockUpdate,
} {
help := strings.Join(shortcut.Tips, "\n")
if !strings.Contains(help, "type=unsupported") || !strings.Contains(help, "will return an error") {
t.Fatalf("%s must explain that unsupported blocks cannot be read or updated: %q", shortcut.Command, help)
}
}
}
// The app and dashboard command spaces must not cross: dashboard commands never
// take --app-token, and app block commands never take --dashboard-id.
func TestAppAndDashboardCommandSpacesDoNotCross(t *testing.T) {
appBlockCommands := map[string]bool{
"+app-block-list": true, "+app-block-get": true,
"+app-block-create": true, "+app-block-update": true,
}
for _, shortcut := range Shortcuts() {
for _, flag := range shortcut.Flags {
if strings.HasPrefix(shortcut.Command, "+dashboard-") && flag.Name == "app-token" {
t.Fatalf("%s must not accept --app-token", shortcut.Command)
}
if appBlockCommands[shortcut.Command] && flag.Name == "dashboard-id" {
t.Fatalf("%s must not accept --dashboard-id", shortcut.Command)
}
}
}
}
func TestBaseappRisksAndScopes(t *testing.T) {
cases := map[string]struct {
shortcut common.Shortcut
risk string
scope string
}{
"+workspace-move-in": {BaseWorkspaceMoveIn, "write", "base:workspace:update"},
"+app-page-delete": {BaseAppPageDelete, "high-risk-write", "base:appmode_page:delete"},
"+app-block-get-data": {BaseAppBlockGetData, "read", "base:appmode_block:read"},
}
if got := strings.Join(BaseAppCreate.Scopes, ","); got != "base:appmode:create,base:workspace:update" {
t.Errorf("+app-create scopes=%v", BaseAppCreate.Scopes)
}
if got := strings.Join(BaseAppBlockCreate.Scopes, ","); got != "base:appmode_block:create,base:appmode_block:read" {
t.Errorf("+app-block-create scopes=%v", BaseAppBlockCreate.Scopes)
}
for name, tc := range map[string]struct {
shortcut common.Shortcut
want string
}{
"+app-page-create": {BaseAppPageCreate, "base:appmode_page:create,base:appmode_page:read"},
"+app-page-update": {BaseAppPageRename, "base:appmode_page:update,base:appmode_page:read"},
"+app-block-update": {BaseAppBlockUpdate, "base:appmode_block:update,base:appmode_block:read"},
} {
if got := strings.Join(tc.shortcut.Scopes, ","); got != tc.want {
t.Errorf("%s scopes=%v want=%s", name, tc.shortcut.Scopes, tc.want)
}
}
for name, tc := range cases {
if tc.shortcut.Risk != tc.risk {
t.Errorf("%s risk=%q want=%q", name, tc.shortcut.Risk, tc.risk)
}
if len(tc.shortcut.Scopes) != 1 || tc.shortcut.Scopes[0] != tc.scope {
t.Errorf("%s scopes=%v want=[%s]", name, tc.shortcut.Scopes, tc.scope)
}
}
}
func TestValidateListDataConfig(t *testing.T) {
t.Run("accepts a minimal list config", func(t *testing.T) {
problems := validateAppListDataConfig("standard", map[string]interface{}{
"base_token": "basx",
"table_name": "Orders",
})
if len(problems) != 0 {
t.Fatalf("problems=%v", problems)
}
})
t.Run("accepts omitted optional fields for every subtype", func(t *testing.T) {
for _, subType := range appListSubTypes {
problems := validateAppListDataConfig(subType, map[string]interface{}{
"base_token": "basx",
"table_name": "Orders",
})
if len(problems) != 0 {
t.Fatalf("%s problems=%v", subType, problems)
}
}
})
t.Run("accepts explicitly empty optional arrays", func(t *testing.T) {
for _, tc := range []struct {
subType string
key string
}{
{subType: "standard", key: "columns"},
{subType: "grouped", key: "group_by"},
{subType: "collapsible", key: "sort_by"},
{subType: "card", key: "fields"},
{subType: "detail", key: "fields"},
} {
cfg := map[string]interface{}{
"base_token": "basx",
"table_name": "Orders",
tc.key: []interface{}{},
}
if problems := validateAppListDataConfig(tc.subType, cfg); len(problems) != 0 {
t.Fatalf("%s.%s problems=%v", tc.subType, tc.key, problems)
}
}
})
t.Run("requires a data source", func(t *testing.T) {
problems := validateAppListDataConfig("card", map[string]interface{}{})
if len(problems) != 2 || !strings.Contains(strings.Join(problems, " "), "base_token") {
t.Fatalf("problems=%v", problems)
}
})
t.Run("rejects fields from another subtype", func(t *testing.T) {
problems := validateAppListDataConfig("grouped", map[string]interface{}{
"base_token": "basx",
"table_name": "Orders",
"card_config": map[string]interface{}{},
})
if len(problems) != 1 || !strings.Contains(problems[0], "card_config") {
t.Fatalf("problems=%v", problems)
}
})
t.Run("does not apply chart rules to list blocks", func(t *testing.T) {
problems := validateAppListDataConfig("detail", map[string]interface{}{"base_token": "basx", "table_name": "Orders"})
for _, problem := range problems {
if strings.Contains(problem, "series") || strings.Contains(problem, "count_all") {
t.Fatalf("chart rule leaked into list validation: %v", problems)
}
}
})
t.Run("validates optional nested fields only when present", func(t *testing.T) {
tests := []struct {
name string
subType string
extra map[string]interface{}
want string
}{
{
name: "filter requires conjunction",
subType: "standard",
extra: map[string]interface{}{"filter": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status", "operator": "is", "value": "Open"}}}},
want: "filter.conjunction",
},
{
name: "filter requires conditions",
subType: "standard",
extra: map[string]interface{}{"filter": map[string]interface{}{"conjunction": "and"}},
want: "filter.conditions",
},
{
name: "sort item requires field name",
subType: "standard",
extra: map[string]interface{}{"sort_by": []interface{}{map[string]interface{}{"order": "asc"}}},
want: "sort_by[0].field_name",
},
{
name: "group order is enumerated",
subType: "grouped",
extra: map[string]interface{}{"group_by": []interface{}{map[string]interface{}{"field_name": "Status", "order": "up"}}},
want: "group_by[0].order",
},
{
name: "field column requires field name",
subType: "standard",
extra: map[string]interface{}{"columns": []interface{}{map[string]interface{}{"type": "field"}}},
want: "columns[0].field_name",
},
{
name: "combined column requires field names",
subType: "standard",
extra: map[string]interface{}{"columns": []interface{}{map[string]interface{}{"type": "combined", "field_names": []interface{}{}}}},
want: "columns[0].field_names",
},
{
name: "card fields are strings",
subType: "card",
extra: map[string]interface{}{"fields": []interface{}{123}},
want: "fields[0]",
},
{
name: "card config values are strings",
subType: "card",
extra: map[string]interface{}{"card_config": map[string]interface{}{"title_field_name": true}},
want: "card_config.title_field_name",
},
{
name: "detail config values are strings",
subType: "detail",
extra: map[string]interface{}{"detail_config": map[string]interface{}{"image_field_name": true}},
want: "detail_config.image_field_name",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := map[string]interface{}{"base_token": "basx", "table_name": "Orders"}
for key, value := range tc.extra {
cfg[key] = value
}
problems := validateAppListDataConfig(tc.subType, cfg)
if !strings.Contains(strings.Join(problems, " "), tc.want) {
t.Fatalf("problems=%v want substring %q", problems, tc.want)
}
})
}
})
t.Run("accepts valid optional nested fields", func(t *testing.T) {
problems := validateAppListDataConfig("standard", map[string]interface{}{
"base_token": "basx",
"table_name": "Orders",
"filter": map[string]interface{}{
"conjunction": "and",
"conditions": []interface{}{
map[string]interface{}{"field_name": "Status", "operator": "is", "value": "Open"},
},
},
"sort_by": []interface{}{map[string]interface{}{"field_name": "Created", "order": "desc"}},
"columns": []interface{}{
map[string]interface{}{"type": "field", "field_name": "Status"},
map[string]interface{}{"type": "combined", "field_names": []interface{}{"Owner", "Created"}},
},
})
if len(problems) != 0 {
t.Fatalf("problems=%v", problems)
}
})
}
func TestValidateTextDataConfigCoversAppAndDashboard(t *testing.T) {
if problems := validateBlockDataConfig("text", map[string]interface{}{"text": "# Title"}); len(problems) != 0 {
t.Fatalf("problems=%v", problems)
}
// App 与 Dashboard 共用同一个 text 拼写和同一条报错
problems := validateBlockDataConfig("text", map[string]interface{}{})
if len(problems) != 1 || problems[0] != "text 类型组件缺少必填字段 text" {
t.Fatalf("problems=%v", problems)
}
}
func TestValidateAppTextDataConfigIsOptional(t *testing.T) {
if problems := validateAppBlockDataConfig("text", map[string]interface{}{}); len(problems) != 0 {
t.Fatalf("problems=%v", problems)
}
}
func TestValidateAppChartRejectsFieldsOutsideProtocol(t *testing.T) {
problems := validateAppBlockDataConfig("line", map[string]interface{}{
"base_token": "basx",
"data_sources": []interface{}{
map[string]interface{}{"table_name": "Orders", "count_all": true},
},
"show_title": true,
})
if !strings.Contains(strings.Join(problems, " "), "show_title") {
t.Fatalf("problems=%v", problems)
}
}
func TestValidateAppChartProtocolConstraints(t *testing.T) {
valid := map[string]interface{}{
"table_name": "Orders",
"series": []interface{}{
map[string]interface{}{"field_name": "Amount", "rollup": "SUM"},
},
"group_by": []interface{}{
map[string]interface{}{
"field_name": "Month",
"mode": "integrated",
"sort": map[string]interface{}{"type": "value"},
},
},
"filter": map[string]interface{}{
"conjunction": "and",
"conditions": []interface{}{
map[string]interface{}{"field_name": "Status", "operator": "isNot", "value": "Closed"},
},
},
}
if problems := validateAppChartDataSourceConfig(valid); len(problems) != 0 {
t.Fatalf("valid config problems=%v", problems)
}
tooManySeries := make([]interface{}, 21)
for i := range tooManySeries {
tooManySeries[i] = map[string]interface{}{"field_name": "Amount", "rollup": "SUM"}
}
tooManyConditions := make([]interface{}, 51)
for i := range tooManyConditions {
tooManyConditions[i] = map[string]interface{}{"field_name": "Status", "operator": "is", "value": "Open"}
}
tooManyValues := make([]interface{}, 201)
for i := range tooManyValues {
tooManyValues[i] = "value"
}
tests := []struct {
name string
cfg map[string]interface{}
want string
}{
{
name: "count all must be true",
cfg: map[string]interface{}{"table_name": "Orders", "count_all": false},
want: "count_all",
},
{
name: "series maximum",
cfg: map[string]interface{}{"table_name": "Orders", "series": tooManySeries},
want: "1~20",
},
{
name: "group mode enum",
cfg: map[string]interface{}{
"table_name": "Orders",
"count_all": true,
"group_by": []interface{}{map[string]interface{}{"field_name": "Month", "mode": "unknown"}},
},
want: "enumerated|integrated",
},
{
name: "filter conjunction required",
cfg: map[string]interface{}{
"table_name": "Orders",
"count_all": true,
"filter": map[string]interface{}{
"conditions": []interface{}{map[string]interface{}{"field_name": "Status", "operator": "is", "value": "Open"}},
},
},
want: "filter.conjunction",
},
{
name: "filter condition maximum",
cfg: map[string]interface{}{
"table_name": "Orders",
"count_all": true,
"filter": map[string]interface{}{"conjunction": "and", "conditions": tooManyConditions},
},
want: "1~50",
},
{
name: "filter array value maximum",
cfg: map[string]interface{}{
"table_name": "Orders",
"count_all": true,
"filter": map[string]interface{}{
"conjunction": "and",
"conditions": []interface{}{
map[string]interface{}{"field_name": "Status", "operator": "is", "value": tooManyValues},
},
},
},
want: "最多 200",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
problems := validateAppChartDataSourceConfig(tc.cfg)
if !strings.Contains(strings.Join(problems, " "), tc.want) {
t.Fatalf("problems=%v want substring %q", problems, tc.want)
}
})
}
}
func TestNormalizeAppChartKeepsOptionalSortOrderOmitted(t *testing.T) {
normalized := normalizeAppChartDataConfig(map[string]interface{}{
"base_token": "basx",
"data_source_mode": "COMPARE",
"sort": map[string]interface{}{"type": "GROUP"},
"data_sources": []interface{}{
map[string]interface{}{
"table_name": "Orders",
"count_all": true,
"group_by": []interface{}{
map[string]interface{}{"field_name": "Month", "sort": map[string]interface{}{"type": "VALUE"}},
},
},
},
})
if normalized["data_source_mode"] != "compare" {
t.Fatalf("data_source_mode=%v", normalized["data_source_mode"])
}
topSort := normalized["sort"].(map[string]interface{})
if topSort["type"] != "group" {
t.Fatalf("top sort=%v", topSort)
}
groupSort := normalized["data_sources"].([]interface{})[0].(map[string]interface{})["group_by"].([]interface{})[0].(map[string]interface{})["sort"].(map[string]interface{})
if groupSort["type"] != "value" {
t.Fatalf("group sort=%v", groupSort)
}
if _, exists := groupSort["order"]; exists {
t.Fatalf("optional sort.order must stay omitted: %v", groupSort)
}
}
func TestNormalizeAppChartDefaultsGroupAndViewSortOrder(t *testing.T) {
for _, sortType := range []string{"group", "view"} {
t.Run(sortType, func(t *testing.T) {
normalized := normalizeAppChartDataConfig(map[string]interface{}{
"base_token": "basx",
"data_sources": []interface{}{
map[string]interface{}{
"table_name": "Orders",
"group_by": []interface{}{
map[string]interface{}{
"field_name": "Month",
"sort": map[string]interface{}{"type": sortType},
},
},
},
},
})
sortConfig := normalized["data_sources"].([]interface{})[0].(map[string]interface{})["group_by"].([]interface{})[0].(map[string]interface{})["sort"].(map[string]interface{})
if sortConfig["order"] != "asc" {
t.Fatalf("sort=%v, want default order asc", sortConfig)
}
})
}
}
func TestValidateAppTextRejectsUnknownFields(t *testing.T) {
problems := validateAppBlockDataConfig("text", map[string]interface{}{"text": "hello", "style": "bold"})
if !strings.Contains(strings.Join(problems, " "), "style") {
t.Fatalf("problems=%v", problems)
}
}
func TestContainsJSONNull(t *testing.T) {
if !containsJSONNull(map[string]interface{}{"filter": map[string]interface{}{"conditions": []interface{}{nil}}}) {
t.Fatal("nested null must be rejected")
}
if containsJSONNull(map[string]interface{}{"fields": []interface{}{}}) {
t.Fatal("empty arrays are not null")
}
}
func TestNormalizeEntityType(t *testing.T) {
if got, err := normalizeEntityType(" BaseApp "); err != nil || got != "baseapp" {
t.Fatalf("got=%q err=%v", got, err)
}
if _, err := normalizeEntityType("sheet"); err == nil {
t.Fatal("expected validation error for unsupported entity type")
}
}