Files
larksuite__cli/shortcuts/apps/shortcuts_test.go
木杉 4488da0b14 feat(apps): add +user-id-convert shortcut for Miaoda↔Feishu ID conversion (#2270)
* feat(apps): add +user-id-convert shortcut for Miaoda↔Feishu ID conversion

Wrap the platform id_convert OpenAPI as a read-only shortcut that maps
Miaoda user_id ↔ Feishu open platform IDs (open_id / union_id / Feishu
user_id). It does one thing — conversion — with no local mapping table,
caching, permission pre-check, or direction guessing.

- --convert-type enum → server id_convert_type (10/11/20/21/40)
- --ids: csv / @file / stdin, 1-100 per call, not de-duped, input order
- reconstructs data.missed by diffing input positions against returned
  source_ids (server silently drops unresolved IDs), keyed by 0-based index
- meta counters (total/hit_count/missed_count) via pointer fields on
  output.Meta so an explicit missed_count: 0 survives omitempty

* test(apps): address review feedback on +user-id-convert

- reject empty --ids CSV entries (e.g. "a,,b") with a typed validation
  error instead of silently dropping them, since a dropped entry shifts
  every later result's 0-based index and breaks the position-keyed
  items/missed contract; add an interior-empty-element test
- reuse common.GetSlice / common.GetString for response projection
  (house convention) instead of local asSlice/asString helpers
- requireConvertValidation now asserts CategoryValidation +
  SubtypeInvalidArgument via errs.ProblemOf, keeping ValidationError.Param
- table-drive TestResolveConvertType over all five directions so every
  --convert-type → id_convert_type mapping (10/11/20/21/40) is protected

* fix(apps): split newline-delimited --ids for +user-id-convert @file/stdin

@file and - (stdin) input arrives verbatim from the framework as
one-ID-per-line text, but parseConvertIDs only split on commas, so such a
block was sent as a single malformed request ID. Treat a newline as
equivalent to a comma, tolerating a file's trailing newline while still
rejecting interior empty entries so position-keyed result indices stay
aligned. Add @file and stdin tests asserting the request body's ids are
split into discrete IDs.

* fix(apps): stringify numeric JSON IDs in +user-id-convert results

Responses decode with json.Number (client.ParseJSONResponse uses
dec.UseNumber()), so a server that emits source_id/target_id as bare
numbers — plausible for the numeric Miaoda user_id form — was silently
coerced to "" by buildConvertResult's strict string assertion: the
source_id got dropped (false not_found) and the target_id blanked
(false success).

Add common.GetStringLoose, which stringifies string/json.Number/int64/
float64 via literal text (large integer IDs keep full precision, never
routed through a lossy float64), and use it for both id reads. Cover it
with a package-level table test plus an end-to-end regression asserting a
numeric-JSON response yields intact, non-blank ids and no false miss.

Also exercise resolveConvertType's non-empty "not a valid direction"
branch directly, since the runner's enum gate preempts it in normal flow.

* test(common): tighten GetStringLoose numeric coverage

Add an int-branch case (was only covering int64) and swap the float64
fixture from 42 — which no formatter would render in exponent form — to
1e-7, whose fixed-point rendering "0.0000001" fails under the 'g' verb.
This turns the "no scientific notation" case into a real guard for the
'f' verb choice, per CodeRabbit review on c64cca39.
2026-08-11 19:14:52 +08:00

204 lines
6.6 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 apps
import (
"testing"
"github.com/spf13/cobra"
)
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
// 6 基础 + 1 init + 3 publish + 1 env-pull
// - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list)
// - 3 env(list/set/delete)
// - 23 db(table-list/table-schema/sql/dev-init/data-import/data-export/sync create/list/get/enable/disable/update/delete/changelog-list/
// audit-status/audit-enable/audit-disable/audit-list/
// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get)
// - 7 file(list/get/sign/download/upload/delete/quota-get)
// - 3 git-credential
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
// - 3 cache(get/delete/clear)
// - 3 plugin(install/uninstall/list)
// - 6 automation(list/get/create/update/enable/disable)
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)
// - 6 creative app member/permission settings
// - 7 db-sync(create/list/get/enable/disable/update/delete)
// - 1 user-id-convert = 96。
func TestAppsShortcuts_Returns96(t *testing.T) {
got := Shortcuts()
if len(got) != 96 {
t.Fatalf("Shortcuts() returned %d entries, want 96", len(got))
}
}
func TestAppsShortcuts_IncludesMemberCommandsWithExactSecurityMetadata(t *testing.T) {
want := map[string]struct {
risk string
scope string
}{
"+member-list": {risk: "read", scope: "spark:app:read"},
"+member-add": {risk: "high-risk-write", scope: "spark:app:write"},
"+member-update": {risk: "high-risk-write", scope: "spark:app:write"},
"+member-remove": {risk: "high-risk-write", scope: "spark:app:write"},
"+member-settings-get": {risk: "read", scope: "spark:app:read"},
"+member-settings-set": {risk: "high-risk-write", scope: "spark:app:write"},
}
for _, sc := range Shortcuts() {
expected, ok := want[sc.Command]
if !ok {
continue
}
delete(want, sc.Command)
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
if sc.Risk != expected.risk {
t.Errorf("%s risk = %q, want %q", sc.Command, sc.Risk, expected.risk)
}
if len(sc.Scopes) != 1 || sc.Scopes[0] != expected.scope {
t.Errorf("%s scopes = %#v, want [%q]", sc.Command, sc.Scopes, expected.scope)
}
if len(sc.AuthTypes) != 1 || sc.AuthTypes[0] != "user" {
t.Errorf("%s auth types = %#v, want [user]", sc.Command, sc.AuthTypes)
}
}
for command := range want {
t.Errorf("Shortcuts() missing %s", command)
}
}
func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) {
for _, sc := range Shortcuts() {
switch sc.Command {
case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete":
t.Fatalf("Shortcuts() must not register %s", sc.Command)
}
}
}
func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) {
for _, sc := range Shortcuts() {
switch sc.Command {
case "+metric-query", "+analytics-query":
t.Fatalf("Shortcuts() must not register %s", sc.Command)
}
}
}
func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) {
want := map[string]bool{
"+env-list": false,
"+env-set": false,
"+env-delete": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing canonical %s", cmd)
}
}
}
// 确认 5 个 session 生命周期命令都已挂载。
func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
want := map[string]bool{
"+session-create": false,
"+session-list": false,
"+session-get": false,
"+session-stop": false,
"+chat": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing %s", cmd)
}
}
}
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
want := map[string]bool{
"+role-list": false,
"+role-get": false,
"+role-create": false,
"+role-update": false,
"+role-delete": false,
"+role-member-list": false,
"+role-member-add": false,
"+role-member-remove": false,
"+role-match-list": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing %s", cmd)
}
}
}
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Command == "git-credential-helper" {
t.Fatalf("git credential helper must be installed as a hidden apps command, not as a shortcut")
}
}
}
// TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes 确认 git credential remove 是本地清理、不带任何 scope。
func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) {
if len(AppsGitCredentialRemove.Scopes) != 0 {
t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes)
}
}
// TestAppsGitCredentialList_IsLocalReadWithoutScopes 确认 git credential list 是本地读取、不带任何 scope。
func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) {
if len(AppsGitCredentialList.Scopes) != 0 {
t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes)
}
}
// TestInstallOnApps_AddsHiddenGitCredentialHelper 验证 InstallOnApps 挂载一个隐藏、带 RunE 且独立于 shortcut 管线的 git-credential-helper 命令。
func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) {
parent := &cobra.Command{Use: "apps"}
InstallOnApps(parent, nil)
cmd, _, err := parent.Find([]string{"git-credential-helper"})
if err != nil {
t.Fatalf("find helper returned error: %v", err)
}
if cmd == nil || cmd.Name() != "git-credential-helper" {
t.Fatalf("helper command not installed: %#v", cmd)
}
if !cmd.Hidden {
t.Fatalf("git credential helper must be hidden")
}
if cmd.RunE == nil {
t.Fatalf("git credential helper must run outside the shortcut pipeline")
}
}