Files
larksuite--cli/shortcuts/okr/okr_batch_create_test.go
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

594 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-batch-create",
AppSecret: "secret-okr-batch-create",
Brand: core.BrandFeishu,
}
}
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRBatchCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
const validBatchCreateInput = `[
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--input", validBatchCreateInput,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "abc",
"--input", validBatchCreateInput,
})
if err == nil {
t.Fatal("expected error for invalid --cycle-id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_MissingInput(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "input") {
t.Fatalf("expected --input required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --input JSON")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "[]",
})
if err == nil {
t.Fatal("expected error for empty --input array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].text") {
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
})
if err == nil {
t.Fatal("expected error for empty KR text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "101",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "201",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "202",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestBatchCreateDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
}
// Verify content is in the body
if !strings.Contains(output, "Objective 1") {
t.Fatalf("dry-run output should contain objective text, got: %s", output)
}
if !strings.Contains(output, "KR 1.1") {
t.Fatalf("dry-run output should contain KR text, got: %s", output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
ok, _ := data["ok"].(bool)
if !ok {
t.Fatal("expected ok=true in output")
}
dataField, _ := data["data"].(map[string]interface{})
created, _ := dataField["created"].([]interface{})
if len(created) != 1 {
t.Fatalf("expected 1 created objective, got %d", len(created))
}
obj, _ := created[0].(map[string]interface{})
if obj["objective_id"] != "100" {
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
}
krs, _ := obj["krs"].([]interface{})
if len(krs) != 1 || krs[0] != "200" {
t.Fatalf("expected krs=[200], got %v", krs)
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1"}]`,
})
if err == nil {
t.Fatal("expected error for API failure")
}
// Should be a typed error from the API
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != "api" {
t.Fatalf("expected api category, got %q", prob.Category)
}
}
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// First objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback: delete the created objective
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention rollback
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
t.Fatalf("expected error to mention rollback, got: %v", err)
}
// Assert typed error metadata
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
}
// Assert cause preservation
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to wrap APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("expected errors.Is to find the wrapped APIError")
}
}
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// Objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback delete also fails
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Status: 500,
Body: map[string]interface{}{
"code": 9999999,
"msg": "internal error",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention residual resources
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
t.Fatalf("expected error to mention residual resources, got: %v", err)
}
if !strings.Contains(err.Error(), "objective:100") {
t.Fatalf("expected error to list residual objective ID, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseBatchCreateInput_Valid(t *testing.T) {
t.Parallel()
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
objs, err := parseBatchCreateInput(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 objective, got %d", len(objs))
}
if objs[0].Text != "Obj 1" {
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
}
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
}
if len(objs[0].KRs) != 1 {
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
}
if objs[0].KRs[0].Text != "KR 1" {
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
}
}
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatalf("expected paragraph block type")
}
if block.Paragraph == nil {
t.Fatal("expected non-nil paragraph")
}
// Should have 3 elements: 1 text + 2 mentions
if len(block.Paragraph.Elements) != 3 {
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
}
// First element should be textRun
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
}
// Second and third should be mentions
for i := 1; i <= 2; i++ {
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
t.Fatalf("expected element %d to be mention", i)
}
}
}