mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4488da0b14
* 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.
237 lines
5.9 KiB
Go
237 lines
5.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestGetString(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"name": "Alice",
|
|
"user": map[string]interface{}{
|
|
"id": "u123",
|
|
"name": "Bob",
|
|
"profile": map[string]interface{}{
|
|
"email": "bob@example.com",
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keys []string
|
|
want string
|
|
}{
|
|
{"top level", []string{"name"}, "Alice"},
|
|
{"nested one level", []string{"user", "id"}, "u123"},
|
|
{"nested two levels", []string{"user", "profile", "email"}, "bob@example.com"},
|
|
{"missing key", []string{"missing"}, ""},
|
|
{"missing nested", []string{"user", "missing"}, ""},
|
|
{"wrong type", []string{"user"}, ""},
|
|
{"empty keys", []string{}, ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := GetString(m, tt.keys...)
|
|
if got != tt.want {
|
|
t.Errorf("GetString() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetStringLoose(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"quoted": "ou_abc123",
|
|
"jsonNum": json.Number("1234567890123456"), // the production path: dec.UseNumber()
|
|
"i": int(42),
|
|
"i64": int64(700123456789),
|
|
"f": float64(1e-7), // would be "1e-07" under 'g'; the 'f' verb must keep it fixed-point
|
|
"nested": map[string]interface{}{
|
|
"num": json.Number("999"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keys []string
|
|
want string
|
|
}{
|
|
{"quoted string", []string{"quoted"}, "ou_abc123"},
|
|
{"json.Number keeps full precision", []string{"jsonNum"}, "1234567890123456"},
|
|
{"int", []string{"i"}, "42"},
|
|
{"int64", []string{"i64"}, "700123456789"},
|
|
{"float64 no scientific notation", []string{"f"}, "0.0000001"},
|
|
{"nested json.Number", []string{"nested", "num"}, "999"},
|
|
{"missing key", []string{"missing"}, ""},
|
|
{"empty keys", []string{}, ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := GetStringLoose(m, tt.keys...); got != tt.want {
|
|
t.Errorf("GetStringLoose(%v) = %q, want %q", tt.keys, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetFloat(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"count": 42.0,
|
|
"data": map[string]interface{}{
|
|
"score": 99.5,
|
|
},
|
|
}
|
|
|
|
if got := GetFloat(m, "count"); got != 42.0 {
|
|
t.Errorf("GetFloat(count) = %f, want 42.0", got)
|
|
}
|
|
if got := GetFloat(m, "data", "score"); got != 99.5 {
|
|
t.Errorf("GetFloat(data.score) = %f, want 99.5", got)
|
|
}
|
|
if got := GetFloat(m, "missing"); got != 0 {
|
|
t.Errorf("GetFloat(missing) = %f, want 0", got)
|
|
}
|
|
if got := GetFloat(m); got != 0 {
|
|
t.Errorf("GetFloat() = %f, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestGetFloatOKDistinguishesMalformedValuesFromZero(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := map[string]interface{}{
|
|
"zero": float64(0),
|
|
"null": nil,
|
|
"string": "0",
|
|
}
|
|
if got, ok := GetFloatOK(m, "zero"); !ok || got != 0 {
|
|
t.Fatalf("GetFloatOK(zero) = (%v, %t), want (0, true)", got, ok)
|
|
}
|
|
for _, key := range []string{"null", "string", "missing"} {
|
|
if got, ok := GetFloatOK(m, key); ok || got != 0 {
|
|
t.Fatalf("GetFloatOK(%s) = (%v, %t), want (0, false)", key, got, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetInt(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"count": 42,
|
|
"json_count": 7.0,
|
|
"data": map[string]interface{}{
|
|
"score": int64(99),
|
|
},
|
|
}
|
|
|
|
if got := GetInt(m, "count"); got != 42 {
|
|
t.Errorf("GetInt(count) = %d, want 42", got)
|
|
}
|
|
if got := GetInt(m, "json_count"); got != 7 {
|
|
t.Errorf("GetInt(json_count) = %d, want 7", got)
|
|
}
|
|
if got := GetInt(m, "data", "score"); got != 99 {
|
|
t.Errorf("GetInt(data.score) = %d, want 99", got)
|
|
}
|
|
if got := GetInt(m, "missing"); got != 0 {
|
|
t.Errorf("GetInt(missing) = %d, want 0", got)
|
|
}
|
|
if got := GetInt(m); got != 0 {
|
|
t.Errorf("GetInt() = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestGetBool(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"active": true,
|
|
"data": map[string]interface{}{
|
|
"verified": false,
|
|
},
|
|
}
|
|
|
|
if got := GetBool(m, "active"); got != true {
|
|
t.Errorf("GetBool(active) = %v, want true", got)
|
|
}
|
|
if got := GetBool(m, "data", "verified"); got != false {
|
|
t.Errorf("GetBool(data.verified) = %v, want false", got)
|
|
}
|
|
if got := GetBool(m, "missing"); got != false {
|
|
t.Errorf("GetBool(missing) = %v, want false", got)
|
|
}
|
|
if got := GetBool(m); got != false {
|
|
t.Errorf("GetBool() = %v, want false", got)
|
|
}
|
|
}
|
|
|
|
func TestGetMap(t *testing.T) {
|
|
inner := map[string]interface{}{"key": "val"}
|
|
m := map[string]interface{}{
|
|
"data": inner,
|
|
}
|
|
|
|
got := GetMap(m, "data")
|
|
if got == nil || got["key"] != "val" {
|
|
t.Errorf("GetMap(data) = %v, want %v", got, inner)
|
|
}
|
|
if got := GetMap(m, "missing"); got != nil {
|
|
t.Errorf("GetMap(missing) = %v, want nil", got)
|
|
}
|
|
// No keys returns the original map.
|
|
if got := GetMap(m); got == nil {
|
|
t.Errorf("GetMap() = nil, want original map")
|
|
}
|
|
}
|
|
|
|
func TestGetSlice(t *testing.T) {
|
|
items := []interface{}{"a", "b"}
|
|
m := map[string]interface{}{
|
|
"items": items,
|
|
"data": map[string]interface{}{
|
|
"list": []interface{}{1.0, 2.0},
|
|
},
|
|
}
|
|
|
|
got := GetSlice(m, "items")
|
|
if len(got) != 2 {
|
|
t.Errorf("GetSlice(items) len = %d, want 2", len(got))
|
|
}
|
|
got = GetSlice(m, "data", "list")
|
|
if len(got) != 2 {
|
|
t.Errorf("GetSlice(data.list) len = %d, want 2", len(got))
|
|
}
|
|
if got := GetSlice(m, "missing"); got != nil {
|
|
t.Errorf("GetSlice(missing) = %v, want nil", got)
|
|
}
|
|
if got := GetSlice(m); got != nil {
|
|
t.Errorf("GetSlice() = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestEachMap(t *testing.T) {
|
|
items := []interface{}{
|
|
map[string]interface{}{"id": "1"},
|
|
"not a map",
|
|
map[string]interface{}{"id": "2"},
|
|
42,
|
|
}
|
|
|
|
var ids []string
|
|
EachMap(items, func(m map[string]interface{}) {
|
|
ids = append(ids, m["id"].(string))
|
|
})
|
|
|
|
if len(ids) != 2 || ids[0] != "1" || ids[1] != "2" {
|
|
t.Errorf("EachMap collected ids = %v, want [1 2]", ids)
|
|
}
|
|
}
|
|
|
|
func TestNavigateNilMap(t *testing.T) {
|
|
var m map[string]interface{}
|
|
if got := GetString(m, "key"); got != "" {
|
|
t.Errorf("GetString(nil, key) = %q, want empty", got)
|
|
}
|
|
}
|