Files
larksuite-cli/internal/schema/assembler_test.go
sang-neo03 9e2be14301 feat(schema): output json spec envelope for all API commands (#1048)
* 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.
2026-05-27 12:04:01 +08:00

782 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 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
}