Files
larksuite__cli/shortcuts/common/extract_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

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