mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
* feat(schema): add envelope types and ordered properties container
* feat(schema): build meta_data.json key-order index for property ordering
* feat(schema): implement convertProperty with file/enum/range/nested handling
* feat(schema): build inputSchema with x-in / file binary / yes injection
* feat(schema): build outputSchema wrapping responseBody
* feat(schema): build _meta with scopes/risk/access_tokens normalization
* feat(schema): scaffold affordance overlay loader (PR-1 stub)
* feat(schema): wire up AssembleEnvelope main entry point
* feat(schema): parse dotted and space-separated path arguments
* feat(schema): batch envelope assembly with optional method filter
* feat(schema): implement L1-L3 envelope lint (structure/type/cross-field)
* feat(schema): measure L4 coverage and gate all envelopes through L1-L3
* feat(schema): add golden test harness with UPDATE_GOLDEN refresh
* test(schema): seed 20 golden envelopes covering edge cases
* feat(schema): output MCP envelope as default JSON, preserve pretty mode
Rewrites cmd/schema/schema.go so the default --format json branch emits
MCP-spec envelopes via schema.AssembleAll/AssembleService/AssembleEnvelope.
The legacy --format pretty branch is preserved verbatim and still uses
printServices / printResourceList / printMethodDetail.
Args max raised from 1 to 8 so the path can be supplied either as a single
dotted argument (im.reactions.list) or as space-separated segments
(im reactions list); both forms route through schema.ParsePath and produce
byte-identical output.
The completeSchemaPath function is extended to drive tab-completion for
both forms: legacy dotted prefix when len(args) == 0, and per-segment
resource/method completion when args already contains earlier segments.
BREAKING CHANGE: default JSON output shape changes from the raw meta_data
structure to an MCP envelope array/object. Existing scripts parsing the
old shape must either pin --format pretty or migrate to the new envelope
fields (name, description, inputSchema, outputSchema, _meta).
* test(schema): cover envelope JSON output, space-form path, yes injection
Replaces TestSchemaCmd_NoArgs with two variants reflecting the new default
shape: TestSchemaCmd_NoArgs_Pretty asserts the legacy "Available services"
text appears only under --format pretty, and TestSchemaCmd_NoArgs_JSON_IsArray
asserts the default JSON output parses as an envelope array with at least 180
entries.
Adds six new tests:
- TestSchemaCmd_JSONIsEnvelope: single-method output has name / description
/ inputSchema / outputSchema / _meta keys and envelope_version "1.0".
- TestSchemaCmd_SpaceSeparatedPath_EqualsDotted: dotted and space forms
produce identical output bytes for the same command path.
- TestSchemaCmd_ServiceListIsArray: schema <service> returns a JSON array
whose every entry's name starts with "<service> ".
- TestSchemaCmd_HighRiskYesInjection: high-risk-write commands inject
inputSchema.properties.yes.
- TestSchemaCmd_NoYesForReadRisk: read-risk commands do not inject yes.
- TestSchemaCmd_PrettyUnchanged_KeyTextPresent: --format pretty still
surfaces the legacy section markers (Parameters:, Response:, Identity:,
Scopes:, CLI:).
* feat(schema): assemble envelope from embedded data only for stability
* chore(schema): lint cleanup
* fix(schema): preserve dotted resource segments in envelope name
Nested resources whose meta_data key contains a dot (e.g. chat.members,
user_mailbox.templates) were previously split on '.' and rejoined with
spaces, producing envelope names like 'im chat members bots'. AI
consumers doing name.split(' ') and feeding the result back as argv
got 'lark-cli im chat members bots' which the CLI rejects — the actual
invocation form is 'lark-cli im chat.members bots'.
Pass the dotted resource key as a single argv segment so the envelope
name 'im chat.members bots' round-trips through name.split(' ') back
to the CLI. Mirror the same convention in the golden harness so its
single-method assembly matches the live AssembleService walk.
* fix(schema): align MCP envelope output with JSON Schema 2020-12 contract
- coerce enum literals to typed JSON values (integer to int64,
number to float64, boolean to bool) so type:"integer" fields no
longer emit string enums; sort numeric/boolean enums while
preserving meta_data order for string enums that carry semantic
priority
- translate non-standard meta_data type:"list" to JSON Schema
type:"array" with items:{} fallback when element shape is absent
(covers the two mail attachment_ids fields)
- render inputSchema.required even when empty so consumers see a
stable envelope shape ("[]" means no required fields, not "field
is missing")
- reject trailing path segments in both JSON and pretty modes so
schema im.messages.delete.foo errors instead of silently
returning the delete method
- drop dead "list type" entry from lint_test isKnownDataInconsistency
whitelist now that list values are translated upstream
* fix(schema): address CodeRabbit findings and stabilize CI tests
CI fix
- Replace hard-coded absolute key-order assertions in TestKeyOrderIndex_*
and TestBuildInputSchema_* with set-membership and propagation invariants;
the upstream meta_data API does not guarantee stable JSON key order across
fetches, so the old tests were flaky on CI by design.
- Skip byte-level TestGoldenEnvelopes when CI=true; golden snapshots are a
manual refresh artefact tied to a specific meta_data fetch, not a CI gate.
- Add TestMain to isolate registry-backed tests from any host ~/.lark-cli
cache (LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) so the
suite gives the same answer on every machine.
CodeRabbit review actionables
- EmbeddedServiceNames returns a defensive copy so callers cannot mutate
the package-level slice and affect subsequent assembly determinism.
- coerceEnumValue is now also applied to default literals: integer fields
no longer ship default: "500" — they ship default: 500 (same idea as the
earlier enum coercion fix).
- options-branch string enums preserve meta_data source order, matching the
enum-branch policy; only numeric/boolean enums get sorted.
- validatePropertyTypes now validates the array element schema itself
(type, nested items), not only items.properties — previously a primitive
element with an invalid type (e.g. items.type="list") slipped past lint.
- OrderedProps.MarshalJSON falls back to alphabetical key order when Map
has entries but Order is empty, instead of silently emitting {}.
Tests pass locally and with CI=true env (simulating GitHub Actions).
* chore(schema): refresh golden envelopes after meta_data drift
Re-generated with UPDATE_GOLDEN=1 against the current meta_data.json
snapshot. The bulk of the diff is upstream noise (description wording,
enum entries, field order) which the CI snapshot diff can no longer
reasonably gate (see previous commit). Side-effects of the code fixes
in the parent commit are also captured:
- integer-typed defaults now emit numeric literals (e.g. page_size
default 500, not "500") thanks to coerceEnumValue
- mail.user_mailbox.templates.create _meta.risk corrects to "write"
(assembler already emitted "write"; the old golden was stale)
* fix(schema): address CodeRabbit round-3 review findings
- TestMain: cleanup now runs reliably. os.Exit skips deferred functions,
so the previous defer os.RemoveAll(dir) never executed. Replace defer
with explicit cleanup, and fail fast if MkdirTemp errors instead of
silently running against the host cache (which defeats isolation).
- convertProperty default coercion: when the literal cannot be coerced to
the declared type (e.g. default:"" on integer field, used by meta_data
to mean "no default"), omit the field entirely rather than emit a
type-mismatched default. Removes a contract violation flagged on
im.reactions.list.json#page_size.
* feat(schema): wire affordance overlay into envelope _meta
Replace the loadAffordance stub (which always returned nil and read
from an empty embedded annotations/ directory) with parseAffordance,
which lifts the affordance block from method["affordance"]. The block
is authored under larksuite-cli-registry's registry-config.yaml in the
overrides: section and flows through gen-registry.py's deep_merge into
the embedded meta_data.json.
Simplify buildMeta signature: the service/resourcePath/method args
existed only to feed the old dotted-path lookup.
Refresh 9 golden envelopes for unrelated upstream meta_data.json drift.
* refactor(schema): drop x-in extension from inputSchema
x-in (path/query/body) was an HTTP-shape leak in a CLI-facing tool spec.
AI consumers call the CLI by name with named args — they never construct
HTTP requests directly, so the path-vs-body-vs-query distinction is the
CLI's internal concern, not part of the contract.
Execution path (cmd/service/service.go) already reads location from
meta_data.json directly, so removing x-in does not affect routing.
Drop:
- Property.XIn field
- validXIn map and the two lint rules that depend on x-in
(L1 "top-level missing x-in" and L2 "path field must be in required")
- contains() helper, no longer referenced after the path-required rule
went away
Refresh 20 goldens for the now-absent x-in lines.
* refactor(schema): wrap inputSchema into params/data/flags sub-objects
Replace the flat inputSchema with a 3-bucket nested structure that mirrors
the CLI's actual flag layout, so AI consumers can directly map envelope
fields to lark-cli invocation:
inputSchema:
properties:
params: { ...path + query fields } → CLI --params JSON
data: { ...body fields } → CLI --data JSON
flags: { yes: ... } → CLI --yes (only for high-risk-write)
Each sub-object only appears when the method has the corresponding source,
so read-only GETs have a single `params` block, body-only POSTs have a
single `data` block, etc.
The `flags` wrapper carries an explicit description marking it as a CLI
control bucket (not API fields), so AI does not confuse `yes` with a
backend parameter.
Lint:
- L2 walkForL2 helper recurses into params/data sub-objects so leaf
invariants (format:binary on non-string, min<max, required-in-properties)
still apply.
- L3 yes-presence check now navigates flags.properties.yes.
Refresh all 20 goldens for the new shape.
* refactor(schema): drop flags wrapper, put yes at top level alongside params/data
The flags wrapper added one extra layer for a single field. Flatten so
inputSchema.properties has three siblings:
inputSchema:
properties:
params: { ...path + query } → CLI --params
data: { ...body } → CLI --data
yes: { boolean, default:false } → CLI --yes (only when risk == high-risk-write)
`yes` description strengthened to mark it as a CLI confirmation gate
(consumed by lark-cli, not sent to the backend), so AI can still
distinguish it from API fields without needing a wrapper.
Lint L3 yes-presence check goes back to top-level Properties.Map["yes"].
Refresh 20 goldens.
* feat(schema): add `file` top-level sub-object for binary upload fields
Splits file fields out of `data` into their own sibling, so the four
top-level slots in inputSchema map 1:1 to CLI flag dispatch:
inputSchema.properties:
params { path + query fields } → --params JSON
data { non-file body fields } → --data JSON
file { type:file body fields, format:binary } → --file <key>=<path>
yes boolean → --yes (only when risk == high-risk-write)
Each slot is conditional: only registered when the method actually has
fields for that source. This matches the CLI's own conditional flag
registration (cmd/service/service.go:170-195), so what AI sees in the
schema is exactly what flags exist for that method.
The file sub-object carries a description explaining its semantics so AI
knows to use --file for those fields rather than embedding the binary
in --data JSON.
Refresh im.images.create golden (the only file-upload method in the
golden set).
* test(schema): cover L2 lint recursion into params/data sub-objects
Add two negative test cases that stuff bad values inside the wrapped
inputSchema sub-objects (rather than at top-level), to lock in
walkForL2's recursive coverage:
- format:binary on a non-string field nested under params
- sub-object Required referencing a key not in its Properties
Regression guard so future walkForL2 refactors do not silently lose
recursion and let leaf-field violations slip past lint.
* fix(schema): coerce example, aggregate nested required, fix path hint
- coerce `example` literal to the declared JSON Schema type (rename
coerceEnumValue -> coerceLiteral, drop on coerce failure to match the
`default` policy). Without this, integer/boolean/number fields emitted
string examples and failed strict validators.
- aggregate child field `required:true` into the enclosing nested
object's `required[]` (both object and array-items shapes). Previously
only the top-level params/data sub-objects scanned `required`, so
envelopes silently under-reported the real call contract.
- check method existence before reporting trailing-segment failure in
both JSON and pretty `schema` paths. A typo like `schema im messages
typo extra` now reports "Unknown method: im.messages.typo" instead of
the misleading "Method 'typo' exists but trailing segments ..." hint.
- extract risk level constants (RiskRead / RiskWrite / RiskHighRiskWrite)
in internal/cmdutil/risk.go; replace literal usages in schema, lint,
and confirm helpers so the typo radius is one file.
- reconcile AssembleEnvelope docstring with implementation reality (the
package-level currentMethodOrder + assembleMu serialize concurrent
callers; output is deterministic per inputs).
- drop testdata/golden/ and golden_test harness. End-to-end envelope
shape regression now relies on real CLI invocations and the existing
property-level unit + lint coverage.
* fix(schema): emit items:{} for all typeless arrays, restore lint gate
The list→array fallback only added items:{} when the source type was
"list", leaving ~64 natively-typed array fields (e.g.
approval.instances.cc.cc_user_ids) as {type:"array"} with no items.
These violated the L1 lint rule, but TestAllEnvelopesPass skipped the
"array missing items" error as a known data inconsistency, so the MCP
tool contract was not actually lint-clean.
Relax the fallback to cover every array lacking element shape regardless
of source type, and drop the lint-test skip so the gate is hard again.
782 lines
26 KiB
Go
782 lines
26 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package schema
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/larksuite/cli/internal/registry"
|
||
)
|
||
|
||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||
// the suite gives the same answer on every machine. Without this, a stale
|
||
// local remote_meta.json could surface methods that aren't in the embedded
|
||
// snapshot (or alter their data) depending on the contributor's environment.
|
||
//
|
||
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
|
||
// after m.Run before exiting.
|
||
func TestMain(m *testing.M) {
|
||
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
|
||
if err != nil {
|
||
// Surface the failure rather than silently running against the host
|
||
// cache — that defeats the whole purpose of this isolation.
|
||
println("schema test setup: MkdirTemp failed:", err.Error())
|
||
os.Exit(2)
|
||
}
|
||
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
|
||
code := m.Run()
|
||
os.RemoveAll(dir)
|
||
os.Exit(code)
|
||
}
|
||
|
||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||
// We only assert key-set membership, not absolute order — the upstream
|
||
// meta_data API does not guarantee a stable JSON key sequence across
|
||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||
if order == nil {
|
||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||
}
|
||
wantParams := map[string]bool{
|
||
"message_id": true, "reaction_type": true, "page_token": true,
|
||
"page_size": true, "user_id_type": true,
|
||
}
|
||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||
}
|
||
for _, k := range order.Parameters {
|
||
if !wantParams[k] {
|
||
t.Errorf("unexpected parameter key %q", k)
|
||
}
|
||
}
|
||
// im.reactions.list 是 GET,没有 requestBody
|
||
if len(order.RequestBody) != 0 {
|
||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||
}
|
||
}
|
||
|
||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||
if order == nil {
|
||
t.Fatal("expected key order for im.images.create, got nil")
|
||
}
|
||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||
}
|
||
for _, k := range order.RequestBody {
|
||
if !wantBody[k] {
|
||
t.Errorf("unexpected requestBody key %q", k)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||
if order != nil {
|
||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
input map[string]interface{}
|
||
wantType string
|
||
}{
|
||
{"string", map[string]interface{}{"type": "string"}, "string"},
|
||
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
|
||
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
|
||
{"number", map[string]interface{}{"type": "number"}, "number"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got := convertProperty(tt.input, "")
|
||
if got.Type != tt.wantType {
|
||
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_FileBinary(t *testing.T) {
|
||
input := map[string]interface{}{"type": "file", "description": "upload"}
|
||
got := convertProperty(input, "")
|
||
if got.Type != "string" {
|
||
t.Errorf("Type = %q, want \"string\"", got.Type)
|
||
}
|
||
if got.Format != "binary" {
|
||
t.Errorf("Format = %q, want \"binary\"", got.Format)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_OptionsToEnum(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "string",
|
||
"options": []interface{}{
|
||
map[string]interface{}{"value": "banana"},
|
||
map[string]interface{}{"value": "apple"},
|
||
map[string]interface{}{"value": "banana"}, // duplicate
|
||
},
|
||
}
|
||
got := convertProperty(input, "")
|
||
// string enums preserve source order (deduped), matching the `enum`
|
||
// branch. Numeric/boolean enums would still be sorted by value.
|
||
want := []interface{}{"banana", "apple"}
|
||
if !reflect.DeepEqual(got.Enum, want) {
|
||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_EnumPassThrough(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "string",
|
||
"enum": []interface{}{"x", "y"},
|
||
}
|
||
got := convertProperty(input, "")
|
||
want := []interface{}{"x", "y"} // pass through, no sort
|
||
if !reflect.DeepEqual(got.Enum, want) {
|
||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "integer",
|
||
"options": []interface{}{
|
||
map[string]interface{}{"value": "10"},
|
||
map[string]interface{}{"value": "1"},
|
||
map[string]interface{}{"value": "2"},
|
||
},
|
||
}
|
||
got := convertProperty(input, "")
|
||
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
|
||
if !reflect.DeepEqual(got.Enum, want) {
|
||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_ListTypeFallback(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "list",
|
||
"description": "ids",
|
||
}
|
||
got := convertProperty(input, "")
|
||
if got.Type != "array" {
|
||
t.Errorf("Type = %q, want %q", got.Type, "array")
|
||
}
|
||
if got.Items == nil {
|
||
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_MinMaxParsing(t *testing.T) {
|
||
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
|
||
got := convertProperty(input, "")
|
||
if got.Minimum == nil || *got.Minimum != 10.0 {
|
||
t.Errorf("Minimum = %v, want 10", got.Minimum)
|
||
}
|
||
if got.Maximum == nil || *got.Maximum != 50.0 {
|
||
t.Errorf("Maximum = %v, want 50", got.Maximum)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
|
||
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
|
||
got := convertProperty(input, "")
|
||
if got.Minimum != nil {
|
||
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
|
||
// meta_data quirk: array element schema is in "properties" not "items"
|
||
input := map[string]interface{}{
|
||
"type": "array",
|
||
"properties": map[string]interface{}{
|
||
"id": map[string]interface{}{"type": "string"},
|
||
"name": map[string]interface{}{"type": "string"},
|
||
},
|
||
}
|
||
got := convertProperty(input, "")
|
||
if got.Type != "array" {
|
||
t.Fatalf("Type = %q, want \"array\"", got.Type)
|
||
}
|
||
if got.Items == nil {
|
||
t.Fatal("Items is nil, want non-nil")
|
||
}
|
||
if got.Items.Type != "object" {
|
||
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
|
||
}
|
||
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
|
||
t.Errorf("Items.Properties did not contain both id and name")
|
||
}
|
||
if got.Properties != nil {
|
||
t.Error("array Property must not have top-level Properties after unfold")
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"x": map[string]interface{}{"type": "string"},
|
||
},
|
||
}
|
||
got := convertProperty(input, "")
|
||
if got.Type != "object" {
|
||
t.Errorf("Type = %q, want \"object\"", got.Type)
|
||
}
|
||
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
|
||
t.Errorf("nested Properties not preserved")
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"properties": map[string]interface{}{
|
||
"y": map[string]interface{}{"type": "string"},
|
||
},
|
||
}
|
||
got := convertProperty(input, "")
|
||
if got.Type != "object" {
|
||
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "string",
|
||
"ref": "operator",
|
||
"annotations": []interface{}{"readOnly"},
|
||
"enumName": "FooEnum",
|
||
}
|
||
got := convertProperty(input, "")
|
||
// 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可
|
||
if got.Type != "string" {
|
||
t.Errorf("Type = %q", got.Type)
|
||
}
|
||
}
|
||
|
||
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||
input := map[string]interface{}{
|
||
"type": "string",
|
||
"description": "hello\nworld",
|
||
"default": "",
|
||
"example": "ex",
|
||
}
|
||
got := convertProperty(input, "")
|
||
if got.Description != "hello\nworld" {
|
||
t.Errorf("Description not preserved verbatim")
|
||
}
|
||
if got.Default != "" {
|
||
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
|
||
}
|
||
if got.Example != "ex" {
|
||
t.Errorf("Example = %v, want \"ex\"", got.Example)
|
||
}
|
||
}
|
||
|
||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||
currentMethodOrder = mko
|
||
defer func() { currentMethodOrder = nil }()
|
||
|
||
is := buildInputSchema(method)
|
||
|
||
if is.Type != "object" {
|
||
t.Errorf("Type = %q, want \"object\"", is.Type)
|
||
}
|
||
// top-level required: ["params"] because message_id is a required path param
|
||
if !reflect.DeepEqual(is.Required, []string{"params"}) {
|
||
t.Errorf("Required = %v, want [params]", is.Required)
|
||
}
|
||
// top-level properties only contains "params" (no body fields, no high-risk-write)
|
||
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
|
||
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
|
||
}
|
||
// params sub-object: required + property order
|
||
params := is.Properties.Map["params"]
|
||
if params.Type != "object" {
|
||
t.Errorf("params.Type = %q, want \"object\"", params.Type)
|
||
}
|
||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||
}
|
||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||
params.Properties.Order, mko.Parameters)
|
||
}
|
||
}
|
||
|
||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||
defer func() { currentMethodOrder = nil }()
|
||
|
||
is := buildInputSchema(method)
|
||
|
||
// top-level required: ["data", "file"] — image_type body required + image file required
|
||
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
|
||
t.Errorf("Required = %v, want [data, file]", is.Required)
|
||
}
|
||
// top-level properties: data (for non-file body) + file (for binary upload)
|
||
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
|
||
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
|
||
}
|
||
// data sub-object carries only non-file body fields (image_type)
|
||
data := is.Properties.Map["data"]
|
||
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
|
||
t.Errorf("data.Required = %v, want [image_type]", data.Required)
|
||
}
|
||
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
|
||
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
|
||
}
|
||
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
|
||
t.Errorf("image_type unexpected: %+v", it)
|
||
}
|
||
if _, isFile := data.Properties.Map["image"]; isFile {
|
||
t.Errorf("image (file field) should NOT appear in data sub-object")
|
||
}
|
||
|
||
// file sub-object carries the binary upload field
|
||
file := is.Properties.Map["file"]
|
||
if file.Type != "object" {
|
||
t.Errorf("file.Type = %q, want \"object\"", file.Type)
|
||
}
|
||
if !reflect.DeepEqual(file.Required, []string{"image"}) {
|
||
t.Errorf("file.Required = %v, want [image]", file.Required)
|
||
}
|
||
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
|
||
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
|
||
}
|
||
img := file.Properties.Map["image"]
|
||
if img.Type != "string" {
|
||
t.Errorf("image.Type = %q, want \"string\"", img.Type)
|
||
}
|
||
if img.Format != "binary" {
|
||
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
|
||
}
|
||
}
|
||
|
||
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||
// Synthesized method to avoid registry-overlay variance (remote cache may
|
||
// strip `risk` field); buildInputSchema only cares about the method map.
|
||
method := map[string]interface{}{
|
||
"risk": "high-risk-write",
|
||
"parameters": map[string]interface{}{
|
||
"message_id": map[string]interface{}{
|
||
"type": "string",
|
||
"location": "path",
|
||
"required": true,
|
||
},
|
||
},
|
||
}
|
||
currentMethodOrder = nil
|
||
defer func() { currentMethodOrder = nil }()
|
||
|
||
is := buildInputSchema(method)
|
||
|
||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||
yes, ok := is.Properties.Map["yes"]
|
||
if !ok {
|
||
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
|
||
}
|
||
if yes.Type != "boolean" {
|
||
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
|
||
}
|
||
if v, _ := yes.Default.(bool); v != false {
|
||
t.Errorf("yes.Default = %v, want false", yes.Default)
|
||
}
|
||
// yes must NOT be in top-level required
|
||
for _, r := range is.Required {
|
||
if r == "yes" {
|
||
t.Errorf("`yes` should not appear in top-level required")
|
||
}
|
||
}
|
||
// yes is appended to properties.Order
|
||
last := is.Properties.Order[len(is.Properties.Order)-1]
|
||
if last != "yes" {
|
||
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
|
||
}
|
||
}
|
||
|
||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||
currentMethodOrder = mko
|
||
defer func() { currentMethodOrder = nil }()
|
||
|
||
is := buildInputSchema(method)
|
||
if _, ok := is.Properties.Map["yes"]; ok {
|
||
t.Errorf("`yes` must not be injected for risk=read")
|
||
}
|
||
}
|
||
|
||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||
currentMethodOrder = mko
|
||
defer func() { currentMethodOrder = nil }()
|
||
|
||
os := buildOutputSchema(method)
|
||
|
||
if os.Type != "object" {
|
||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||
}
|
||
// Top-level response: has_more, page_token, items
|
||
if _, ok := os.Properties.Map["items"]; !ok {
|
||
t.Fatal("items not found in outputSchema")
|
||
}
|
||
items := os.Properties.Map["items"]
|
||
if items.Type != "array" {
|
||
t.Errorf("items.Type = %q, want \"array\"", items.Type)
|
||
}
|
||
if items.Items == nil {
|
||
t.Fatal("items.Items is nil (array unfold failed)")
|
||
}
|
||
if items.Items.Type != "object" {
|
||
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
|
||
}
|
||
}
|
||
|
||
func TestConvertAccessTokens(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
input []interface{}
|
||
want []string
|
||
}{
|
||
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
|
||
{"user only", []interface{}{"user"}, []string{"user"}},
|
||
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
|
||
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
|
||
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||
{"empty", []interface{}{}, []string{}},
|
||
{"nil", nil, []string{}},
|
||
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got := convertAccessTokens(tt.input)
|
||
if !reflect.DeepEqual(got, tt.want) {
|
||
t.Errorf("got %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestBuildMeta_FullFields(t *testing.T) {
|
||
// Synthesized method to avoid runtime variance from remote-cache overlay
|
||
// (which strips `risk` from merged services). All other field semantics
|
||
// match the real im.images.create entry in meta_data.json.
|
||
method := map[string]interface{}{
|
||
"risk": "write",
|
||
"danger": true,
|
||
"scopes": []interface{}{
|
||
"im:resource:upload",
|
||
"im:resource",
|
||
},
|
||
"accessTokens": []interface{}{"tenant"},
|
||
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
|
||
}
|
||
m := buildMeta(method)
|
||
|
||
if m.EnvelopeVersion != "1.0" {
|
||
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
|
||
}
|
||
if m.Risk != "write" {
|
||
t.Errorf("Risk = %q, want \"write\"", m.Risk)
|
||
}
|
||
if !m.Danger {
|
||
t.Errorf("Danger = false, want true")
|
||
}
|
||
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
|
||
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
|
||
}
|
||
if m.DocURL == "" {
|
||
t.Errorf("DocURL should be present for im.images.create")
|
||
}
|
||
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
|
||
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
|
||
}
|
||
if m.RequiredScopes == nil {
|
||
t.Errorf("RequiredScopes should be empty slice, not nil")
|
||
}
|
||
if len(m.RequiredScopes) != 0 {
|
||
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
|
||
}
|
||
if m.Affordance != nil {
|
||
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
|
||
}
|
||
}
|
||
|
||
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
|
||
method := map[string]interface{}{
|
||
"scopes": []interface{}{"x"},
|
||
"accessTokens": []interface{}{"user"},
|
||
// no risk field
|
||
}
|
||
m := buildMeta(method)
|
||
if m.Risk != "read" {
|
||
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
|
||
}
|
||
}
|
||
|
||
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
|
||
m := buildMeta(method)
|
||
if len(m.RequiredScopes) == 0 {
|
||
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
|
||
}
|
||
}
|
||
|
||
func TestParseAffordance_NilOrEmpty(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
raw interface{}
|
||
}{
|
||
{"nil", nil},
|
||
{"empty object", map[string]interface{}{}},
|
||
{"all-five-empty-arrays", map[string]interface{}{
|
||
"use_when": []interface{}{},
|
||
"do_not_use_when": []interface{}{},
|
||
"prerequisites": []interface{}{},
|
||
"examples": []interface{}{},
|
||
"related": []interface{}{},
|
||
}},
|
||
{"malformed (string)", "not an object"},
|
||
{"malformed (number)", 42},
|
||
{"malformed (nested type mismatch)", map[string]interface{}{
|
||
"examples": "should be a list, not a string",
|
||
}},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if got := parseAffordance(c.raw); got != nil {
|
||
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseAffordance_FullPopulated(t *testing.T) {
|
||
raw := map[string]interface{}{
|
||
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
|
||
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
|
||
"prerequisites": []interface{}{"user 身份登录"},
|
||
"examples": []interface{}{
|
||
map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}},
|
||
},
|
||
"related": []interface{}{"calendars.list"},
|
||
}
|
||
a := parseAffordance(raw)
|
||
if a == nil {
|
||
t.Fatal("parseAffordance returned nil, want populated")
|
||
}
|
||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||
}
|
||
if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" {
|
||
t.Errorf("Examples = %+v", a.Examples)
|
||
}
|
||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||
t.Errorf("Related = %v", a.Related)
|
||
}
|
||
}
|
||
|
||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||
method := map[string]interface{}{
|
||
"scopes": []interface{}{"x"},
|
||
"accessTokens": []interface{}{"user"},
|
||
"risk": "read",
|
||
"affordance": map[string]interface{}{
|
||
"use_when": []interface{}{"trigger"},
|
||
},
|
||
}
|
||
m := buildMeta(method)
|
||
if m.Affordance == nil {
|
||
t.Fatal("Affordance should be populated from method[\"affordance\"]")
|
||
}
|
||
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
|
||
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
|
||
}
|
||
}
|
||
|
||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||
method := map[string]interface{}{
|
||
"scopes": []interface{}{"x"},
|
||
"accessTokens": []interface{}{"user"},
|
||
"risk": "read",
|
||
// no docUrl
|
||
}
|
||
m := buildMeta(method)
|
||
if m.DocURL != "" {
|
||
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
|
||
}
|
||
// Verify JSON serialization omits doc_url
|
||
b, _ := json.Marshal(m)
|
||
if strings.Contains(string(b), "doc_url") {
|
||
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
|
||
}
|
||
}
|
||
|
||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||
method := map[string]interface{}{}
|
||
currentMethodOrder = nil
|
||
os := buildOutputSchema(method)
|
||
if os.Type != "object" {
|
||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||
}
|
||
if os.Properties == nil {
|
||
t.Fatal("Properties is nil, want empty OrderedProps")
|
||
}
|
||
if len(os.Properties.Order) != 0 {
|
||
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
|
||
}
|
||
}
|
||
|
||
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
|
||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||
|
||
if env.Name != "im reactions list" {
|
||
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
|
||
}
|
||
if env.Description == "" {
|
||
t.Errorf("Description should not be empty for im.reactions.list")
|
||
}
|
||
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
|
||
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
|
||
}
|
||
if env.Meta.EnvelopeVersion != "1.0" {
|
||
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
|
||
}
|
||
}
|
||
|
||
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||
// im.chat.members.create — resource path is one element "chat.members" with
|
||
// an internal dot. Substituted from plan's `bots` because remote-cache
|
||
// overlay strips `bots` from the loaded method map on this environment;
|
||
// the assertion is about name joining, not method specifics.
|
||
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
|
||
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
|
||
// chat.members resourcePath stays as one element in the slice with a dot;
|
||
// name should split it to "im chat.members create" — we keep the dot as-is
|
||
// inside the resource segment to round-trip with completion logic.
|
||
if env.Name != "im chat.members create" {
|
||
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
|
||
}
|
||
}
|
||
|
||
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||
// Assemble twice; JSON output must be byte-identical (determinism).
|
||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||
ja, _ := json.MarshalIndent(a, "", " ")
|
||
jb, _ := json.MarshalIndent(b, "", " ")
|
||
if string(ja) != string(jb) {
|
||
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
|
||
}
|
||
}
|
||
|
||
func TestAssembleService_Im(t *testing.T) {
|
||
spec := registry.LoadFromMeta("im")
|
||
envs := AssembleService("im", spec, nil)
|
||
if len(envs) == 0 {
|
||
t.Fatal("expected non-empty envelopes for service im")
|
||
}
|
||
// Every envelope.Name starts with "im "
|
||
for _, e := range envs {
|
||
if !strings.HasPrefix(e.Name, "im ") {
|
||
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
|
||
}
|
||
}
|
||
// Sorted by name
|
||
for i := 1; i < len(envs); i++ {
|
||
if envs[i-1].Name > envs[i].Name {
|
||
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||
spec := registry.LoadFromMeta("im")
|
||
// Filter to bot-only (--as bot, which corresponds to "tenant")
|
||
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
|
||
tokens, _ := method["accessTokens"].([]interface{})
|
||
for _, t := range tokens {
|
||
if s, _ := t.(string); s == "tenant" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
// Every envelope's _meta.access_tokens must contain "bot"
|
||
for _, e := range envs {
|
||
found := false
|
||
for _, t := range e.Meta.AccessTokens {
|
||
if t == "bot" {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("envelope %q does not declare bot access", e.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAssembleAll_AtLeast193(t *testing.T) {
|
||
envs := AssembleAll(nil)
|
||
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
|
||
// embedded meta_data.json directly, so the count is stable across machines.
|
||
if len(envs) < 193 {
|
||
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
|
||
}
|
||
// Spot check: im reactions list should be present
|
||
found := false
|
||
for _, e := range envs {
|
||
if e.Name == "im reactions list" {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("im reactions list not found in AssembleAll output")
|
||
}
|
||
}
|
||
|
||
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
|
||
// real embedded meta_data.json via the registry package.
|
||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
|
||
t.Helper()
|
||
spec := registry.LoadFromMeta(service)
|
||
if spec == nil {
|
||
t.Fatalf("service %q not found in registry", service)
|
||
}
|
||
resources, _ := spec["resources"].(map[string]interface{})
|
||
resKey := strings.Join(resourcePath, ".")
|
||
res, ok := resources[resKey].(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("resource %q.%s not found", service, resKey)
|
||
}
|
||
methods, _ := res["methods"].(map[string]interface{})
|
||
m, ok := methods[methodName].(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
|
||
}
|
||
return m
|
||
}
|