diff --git a/cmd/balance_test.go b/cmd/balance_test.go index c743b5b..97295f3 100644 --- a/cmd/balance_test.go +++ b/cmd/balance_test.go @@ -142,7 +142,7 @@ func TestBalance_JSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &parsed); err != nil { t.Fatalf("output is not valid JSON: %v", err) } diff --git a/cmd/crm.go b/cmd/crm.go new file mode 100644 index 0000000..181a4da --- /dev/null +++ b/cmd/crm.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// crmCmd is the parent for all CRM (`crm-core`) operations. The static +// children (`config clear-cache`, `mcp`) are registered eagerly; resource +// trees (` `) are added dynamically by RegisterDynamicCRM +// at startup, after the OpenAPI spec has been resolved. +// +// The implementation mirrors @solapi/crm-cli (sdk/cli) — see +// docs/crm-cli-spec.md for the full design rationale. +var crmCmd = &cobra.Command{ + Use: "crm", + Short: "SOLAPI CRM의 고객 데이터와 레코드를 조회하고 관리합니다", + Long: `SOLAPI CRM에서 사용하는 고객 데이터, 엔티티, 레코드를 CLI로 조회하고 관리합니다. + +사용 가능한 작업은 로그인한 계정의 CRM 기능에 맞춰 제공됩니다. +아래 형식으로 리소스와 작업을 선택하세요: + solactl crm <리소스> <작업> [값] [옵션] + +예시: + solactl crm entities list + solactl crm records get RECxxx + solactl crm records create --data '{"entityId":"ENxxx","name":"홍길동"}' + solactl crm records list --entityId ENxxx --format csv > export.csv + +최신 CRM 명령이 보이지 않을 때: + solactl crm config clear-cache + +인증 정보는 기존 solactl 설정을 사용합니다. 다른 프로필로 실행하려면 --profile을 함께 지정하세요.`, +} + +func init() { + rootCmd.AddCommand(crmCmd) +} diff --git a/cmd/crm_config.go b/cmd/crm_config.go new file mode 100644 index 0000000..c5a0c1a --- /dev/null +++ b/cmd/crm_config.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/solapi/solactl/pkg/crm/spec" +) + +var crmConfigCmd = &cobra.Command{ + Use: "config", + Short: "CRM CLI 설정/캐시 관리", +} + +var crmConfigClearCacheCmd = &cobra.Command{ + Use: "clear-cache", + Short: "OpenAPI 스펙 캐시를 모두 삭제합니다", + Long: `~/.solactl/cache/ 아래의 모든 캐시 파일을 삭제합니다. +다음 'solactl crm <리소스> <액션>' 호출 시 OpenAPI 스펙을 새로 받아옵니다. + +캐시 TTL은 1시간입니다. 백엔드에 새 API가 추가되었는데 TTL이 만료되지 않은 경우 +이 명령으로 강제 갱신할 수 있습니다.`, + RunE: runCrmConfigClearCache, +} + +func init() { + crmConfigCmd.AddCommand(crmConfigClearCacheCmd) + crmCmd.AddCommand(crmConfigCmd) +} + +func runCrmConfigClearCache(_ *cobra.Command, _ []string) error { + if err := spec.ClearCache(); err != nil { + return fmt.Errorf("캐시 삭제 실패: %w", err) + } + dir, _ := spec.CacheDirPath() + if dir != "" { + _, _ = fmt.Fprintf(errOut(), "캐시 디렉토리를 비웠습니다: %s\n", dir) + } else { + _, _ = fmt.Fprintln(errOut(), "캐시 디렉토리를 비웠습니다.") + } + return nil +} diff --git a/cmd/crm_dynamic.go b/cmd/crm_dynamic.go new file mode 100644 index 0000000..62aa46f --- /dev/null +++ b/cmd/crm_dynamic.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/solapi/solactl/pkg/client" + "github.com/solapi/solactl/pkg/crm/output" + "github.com/solapi/solactl/pkg/crm/spec" +) + +// crmLoaderOverride lets tests inject a deterministic Loader. Production +// leaves it nil; the loader uses the upstream URL. +var crmLoaderOverride *spec.Loader + +// RegisterDynamicCRM resolves the OpenAPI spec, then mounts dynamic resource +// subcommands under `solactl crm`. Failures here must NEVER block the rest of +// the CLI: +// +// - OpenAPI fetch failure with no cache → skip + stderr warning +// - OpenAPI fetch failure with stale cache → register from stale + warning +// +// Credentials are intentionally not loaded here: this runs before cobra parses +// persistent flags, so `--profile`, `--api-key`, and `--api-secret` are only +// reliable during command execution in newClient(). +func RegisterDynamicCRM(ctx context.Context) { + loader := crmLoaderOverride + if loader == nil { + loader = &spec.Loader{ + StaleWarn: func(msg string) { _, _ = fmt.Fprintln(errOut(), msg) }, + } + } + + apiSpec, err := loader.Load(ctx, false) + if err != nil { + _, _ = fmt.Fprintf(errOut(), "⚠ CRM OpenAPI spec 로딩 실패 — 동적 명령이 등록되지 않습니다: %v\n", err) + return + } + + commands := spec.MapSpec(apiSpec) + for _, resource := range spec.Resources(commands) { + resourceCmd, _ := ensureCRMResourceCommand(resource, fmt.Sprintf("%s 리소스 관리", resource)) + markDynamicCRMResourceCommand(resourceCmd) + for _, mc := range spec.CommandsForResource(commands, resource) { + resourceCmd.AddCommand(markDynamicCRMCommand(buildDynamicSubcommand(mc))) + } + } +} + +// dynamicFlags holds per-command flag storage. cobra writes flag values into +// these slots during parse; the RunE closure reads them afterwards. +type dynamicFlags struct { + query map[string]*dynamicQueryFlag + data string + dataFile string + format string +} + +type dynamicQueryFlag struct { + name string + kind queryFlagKind + string string + bool bool + int int + number float64 +} + +type queryFlagKind int + +const ( + queryFlagString queryFlagKind = iota + queryFlagBool + queryFlagInt + queryFlagNumber +) + +func buildDynamicSubcommand(mc spec.MappedCommand) *cobra.Command { + var useBuilder strings.Builder + useBuilder.WriteString(mc.Action) + for _, p := range mc.PathParams { + useBuilder.WriteString(" <") + useBuilder.WriteString(p.Name) + useBuilder.WriteByte('>') + } + + short := strings.TrimSpace(mc.Summary) + if short == "" { + short = fmt.Sprintf("%s %s", mc.Method, mc.Path) + } + + sub := &cobra.Command{ + Use: useBuilder.String(), + Short: short, + Args: cobra.ExactArgs(len(mc.PathParams)), + } + + flags := &dynamicFlags{query: make(map[string]*dynamicQueryFlag, len(mc.QueryParams))} + for _, q := range mc.QueryParams { + desc := q.Description + if desc == "" { + desc = q.Name + } + qf := newDynamicQueryFlag(q) + flags.query[q.Name] = qf + qf.register(sub, desc) + if q.Required { + _ = sub.MarkFlagRequired(q.Name) + } + } + if mc.HasBody { + sub.Flags().StringVar(&flags.data, "data", "", "요청 본문 (JSON 문자열)") + sub.Flags().StringVar(&flags.dataFile, "data-file", "", "요청 본문 파일 경로") + } + sub.Flags().StringVar(&flags.format, "format", "", "출력 형식 (json/table/csv, 기본 table; --json이 켜져 있으면 json)") + + sub.RunE = func(cmd *cobra.Command, args []string) error { + return runDynamicCommand(cmd, mc, args, flags) + } + return sub +} + +func newDynamicQueryFlag(param spec.ParameterObject) *dynamicQueryFlag { + qf := &dynamicQueryFlag{name: param.Name, kind: queryFlagString} + if param.Schema == nil { + return qf + } + switch strings.ToLower(param.Schema.Type) { + case "boolean": + qf.kind = queryFlagBool + case "integer": + qf.kind = queryFlagInt + case "number": + qf.kind = queryFlagNumber + } + return qf +} + +func (qf *dynamicQueryFlag) register(cmd *cobra.Command, desc string) { + switch qf.kind { + case queryFlagBool: + cmd.Flags().BoolVar(&qf.bool, qf.name, false, desc) + case queryFlagInt: + cmd.Flags().IntVar(&qf.int, qf.name, 0, desc) + case queryFlagNumber: + cmd.Flags().Float64Var(&qf.number, qf.name, 0, desc) + default: + cmd.Flags().StringVar(&qf.string, qf.name, "", desc) + } +} + +func (qf *dynamicQueryFlag) value() string { + switch qf.kind { + case queryFlagBool: + return strconv.FormatBool(qf.bool) + case queryFlagInt: + return strconv.Itoa(qf.int) + case queryFlagNumber: + return strconv.FormatFloat(qf.number, 'f', -1, 64) + default: + return qf.string + } +} + +func runDynamicCommand(cmd *cobra.Command, mc spec.MappedCommand, args []string, flags *dynamicFlags) error { + format, err := output.NormalizeFormat(flags.format) + if err != nil { + return err + } + if flags.format == "" && flagJSON { + format = output.FormatJSON + } + + path := mc.Path + for i, p := range mc.PathParams { + encoded := encodePathArg(args[i]) + path = strings.ReplaceAll(path, "{"+p.Name+"}", encoded) + path = strings.ReplaceAll(path, ":"+p.Name, encoded) + } + path = strings.TrimPrefix(path, "/") + + q := url.Values{} + for _, p := range mc.QueryParams { + if v := flags.query[p.Name]; v != nil && cmd.Flags().Changed(p.Name) { + q.Set(p.Name, v.value()) + } + } + + var body any + if mc.HasBody { + body, err = readRequestBody(flags.data, flags.dataFile, mc.BodyRequired) + if err != nil { + return err + } + } + + c, err := newClient() + if err != nil { + return err + } + + raw, err := dispatch(ctx(), c, mc.Method, path, q, body) + if err != nil { + return fmt.Errorf("%s %s 호출 실패: %w", mc.Method, mc.Path, err) + } + + rendered, err := output.FormatBytes([]byte(raw), format) + if err != nil { + return err + } + if rendered != "" { + _, _ = fmt.Fprintln(out(), rendered) + } + return nil +} + +func readRequestBody(dataFlag, dataFileFlag string, required bool) (any, error) { + if dataFileFlag != "" { + raw, err := os.ReadFile(dataFileFlag) + if err != nil { + return nil, fmt.Errorf("--data-file 읽기 실패: %w", err) + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return nil, fmt.Errorf("--data-file JSON 파싱 실패: %w", err) + } + return v, nil + } + if dataFlag != "" { + var v any + if err := json.Unmarshal([]byte(dataFlag), &v); err != nil { + return nil, fmt.Errorf("--data JSON 파싱 실패: %w", err) + } + return v, nil + } + if required { + return nil, errors.New("--data 또는 --data-file 로 요청 본문을 지정해야 합니다") + } + return nil, nil +} + +// dispatch routes the method to the right client helper. mapper.MapSpec +// already uppercases mc.Method, so no normalisation is needed here. +func dispatch(ctx context.Context, c *client.Client, method, path string, q url.Values, body any) (json.RawMessage, error) { + switch method { + case http.MethodGet: + return c.Get(ctx, path, q) + case http.MethodPost: + return c.Post(ctx, withQuery(path, q), body) + case http.MethodPut: + return c.Put(ctx, withQuery(path, q), body) + case http.MethodPatch: + return c.Patch(ctx, withQuery(path, q), body) + case http.MethodDelete: + return c.Delete(ctx, withQuery(path, q)) + } + return nil, fmt.Errorf("지원하지 않는 HTTP 메서드: %s", method) +} + +func encodePathArg(s string) string { + return url.PathEscape(s) +} + +func withQuery(path string, q url.Values) string { + if len(q) == 0 { + return path + } + if strings.Contains(path, "?") { + return path + "&" + q.Encode() + } + return path + "?" + q.Encode() +} diff --git a/cmd/crm_dynamic_test.go b/cmd/crm_dynamic_test.go new file mode 100644 index 0000000..80f0f41 --- /dev/null +++ b/cmd/crm_dynamic_test.go @@ -0,0 +1,497 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/solapi/solactl/pkg/client" + "github.com/solapi/solactl/pkg/crm/spec" +) + +const fakeSpec = `{ + "openapi": "3.0.0", + "info": {"title": "crm", "version": "1"}, + "paths": { + "/crm-core/v1/entities": { + "get": {"summary": "list entities"} + }, + "/crm-core/v1/records": { + "get": { + "summary": "list records", + "parameters": [ + {"name": "entityId", "in": "query", "required": true}, + {"name": "limit", "in": "query"}, + {"name": "includeDeleted", "in": "query", "schema": {"type": "boolean"}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}}, + {"name": "score", "in": "query", "schema": {"type": "number"}}, + {"name": "keyword", "in": "query"} + ] + }, + "post": { + "summary": "create record", + "requestBody": {"required": true} + } + }, + "/crm-core/v1/records/{id}": { + "get": { + "summary": "get record", + "parameters": [ + {"name": "id", "in": "path", "required": true} + ] + }, + "delete": { + "summary": "delete record", + "parameters": [ + {"name": "id", "in": "path", "required": true} + ] + } + }, + "/crm-core/v1/records/{id}/restore": { + "post": { + "summary": "restore record", + "parameters": [ + {"name": "id", "in": "path", "required": true} + ] + } + } + } +}` + +func setupCRMTest(t *testing.T, handler http.HandlerFunc) (stdout, stderr *bytes.Buffer, _ func()) { + t.Helper() + resetFlags() + t.Setenv("HOME", t.TempDir()) + t.Setenv(spec.CacheDirEnv, t.TempDir()) + t.Setenv("SOLACTL_API_KEY", "TESTKEY") + t.Setenv("SOLACTL_API_SECRET", "TESTSECRET") + + specSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, fakeSpec) + })) + apiSrv := httptest.NewServer(handler) + + crmLoaderOverride = &spec.Loader{URL: specSrv.URL} + c := &client.Client{ + HTTPClient: apiSrv.Client(), + APIKey: "TESTKEY", + APISecret: "TESTSECRET", + MaxRetries: 0, + BaseDelay: time.Millisecond, + BaseURLOverride: apiSrv.URL, + } + clientOverride = c + + var outBuf, errBuf bytes.Buffer + outWriter = &outBuf + errWriter = &errBuf + + // Detach existing dynamic children so consecutive tests start clean. + resetCRMRegistration() + + cleanup := func() { + crmLoaderOverride = nil + clientOverride = nil + outWriter = nil + errWriter = nil + specSrv.Close() + apiSrv.Close() + resetFlags() + resetCRMRegistration() + } + t.Cleanup(cleanup) + + RegisterDynamicCRM(context.Background()) + return &outBuf, &errBuf, cleanup +} + +// resetCRMRegistration drops every dynamic resource subcommand so tests +// can re-register against a fresh spec without stale entries colliding. +func resetCRMRegistration() { + if crmCmd == nil { + return + } + for _, c := range crmCmd.Commands() { + // Keep static children (`config`, `mcp` once added). + if c.Use == "config" || strings.HasPrefix(c.Use, "config ") || c.Use == "mcp" || c.Annotations[crmStaticResourceAnnotation] == "true" { + for _, child := range c.Commands() { + if child.Annotations[crmDynamicCommandAnnotation] == "true" { + c.RemoveCommand(child) + } + } + continue + } + if c.Annotations[crmDynamicResourceAnnotation] == "true" { + crmCmd.RemoveCommand(c) + } + } +} + +func TestCRM_DynamicListRecords_TableFormat(t *testing.T) { + stdout, _, _ := setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if got := r.URL.Query().Get("entityId"); got != "ENxxx" { + t.Errorf("entityId: got %q", got) + } + for _, name := range []string{"limit", "includeDeleted", "page", "score", "keyword"} { + if _, ok := r.URL.Query()[name]; ok { + t.Errorf("query %q should be omitted when its flag was not provided: %s", name, r.URL.RawQuery) + } + } + _, _ = io.WriteString(w, `[{"id":"R1","name":"홍길동"},{"id":"R2","name":"김철수"}]`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "list", "--entityId", "ENxxx"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "R1") || !strings.Contains(out, "홍길동") { + t.Errorf("unexpected output:\n%s", out) + } +} + +func TestCRM_DynamicListRecords_QueryFlagsPreserveExplicitBoundaryValues(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + checks := map[string]string{ + "entityId": "ENxxx", + "limit": "", + "includeDeleted": "false", + "page": "0", + "score": "0", + "keyword": "", + } + for key, want := range checks { + values, ok := q[key] + if !ok { + t.Errorf("query %q missing from %s", key, r.URL.RawQuery) + continue + } + if len(values) != 1 || values[0] != want { + t.Errorf("query %q: got %#v, want %q", key, values, want) + } + } + _, _ = io.WriteString(w, `[{"id":"R1"}]`) + }) + + rootCmd.SetArgs([]string{ + "crm", "records", "list", + "--entityId", "ENxxx", + "--limit", "", + "--includeDeleted=false", + "--page", "0", + "--score", "0", + "--keyword", "", + }) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } +} + +func TestCRM_DynamicListRecords_BoolQueryFlagWithoutValueSendsTrue(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("includeDeleted"); got != "true" { + t.Errorf("includeDeleted: got %q, want true (raw query %s)", got, r.URL.RawQuery) + } + _, _ = io.WriteString(w, `[{"id":"R1"}]`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "list", "--entityId", "ENxxx", "--includeDeleted"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } +} + +func TestCRM_DynamicListRecords_FormatJSON(t *testing.T) { + stdout, _, _ := setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[{"id":"R1"}]`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "list", "--entityId", "X", "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + var parsed []map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &parsed); err != nil { + t.Fatalf("not JSON: %v\n%s", err, stdout.String()) + } +} + +func TestCRM_DynamicListRecords_FormatCSV(t *testing.T) { + stdout, _, _ := setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[{"id":"R1","name":"a,b"}]`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "list", "--entityId", "X", "--format", "csv"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(stdout.String(), `"a,b"`) { + t.Errorf("CSV not escaped:\n%s", stdout.String()) + } +} + +func TestCRM_DynamicGetRecord_PathParam(t *testing.T) { + var sawPath string + stdout, _, _ := setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + sawPath = r.URL.Path + _, _ = io.WriteString(w, `{"id":"REC123","name":"홍길동"}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "get", "REC123", "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(sawPath, "/crm-core/v1/records/REC123") { + t.Errorf("path substitution failed: %s", sawPath) + } + if !strings.Contains(stdout.String(), "REC123") { + t.Errorf("output missing payload: %s", stdout.String()) + } +} + +func TestCRM_DynamicGetRecord_PathParamEncoded(t *testing.T) { + var sawRequestURI string + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + // RequestURI preserves percent-encoding; URL.Path is already decoded. + sawRequestURI = r.RequestURI + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "get", "abc/def", "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + // `/` in arg must be percent-encoded to keep the path structure intact. + if !strings.Contains(sawRequestURI, "abc%2Fdef") { + t.Errorf("path arg not encoded: %s", sawRequestURI) + } +} + +func TestCRM_EncodePathArgMatchesURLPathEscape(t *testing.T) { + tests := []string{ + "abc/def", + "abc?def#ghi", + "space value", + "홍길동", + } + for _, tc := range tests { + t.Run(tc, func(t *testing.T) { + if got, want := encodePathArg(tc), url.PathEscape(tc); got != want { + t.Fatalf("encodePathArg(%q) = %q, want %q", tc, got, want) + } + }) + } +} + +func TestCRM_DynamicCreateRecord_DataFlag(t *testing.T) { + var receivedBody map[string]any + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedBody) + _, _ = io.WriteString(w, `{"id":"R1"}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "create", "--data", `{"name":"홍길동"}`, "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if got := receivedBody["name"]; got != "홍길동" { + t.Errorf("body forwarding failed: %v", receivedBody) + } +} + +func TestCRM_DynamicCreateRecord_DataFileFlag(t *testing.T) { + tmp := t.TempDir() + path := tmp + "/body.json" + if err := writeFile(path, []byte(`{"name":"파일"}`)); err != nil { + t.Fatal(err) + } + var receivedBody map[string]any + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedBody) + _, _ = io.WriteString(w, `{"ok":true}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "create", "--data-file", path}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if got := receivedBody["name"]; got != "파일" { + t.Errorf("body file ignored: %v", receivedBody) + } +} + +func TestCRM_DynamicCreate_DataMissingErrors(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + t.Errorf("server should not be called when body is required and missing") + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "create"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want error when --data missing for required body") + } + if !strings.Contains(err.Error(), "--data") { + t.Errorf("error should mention --data: %v", err) + } +} + +func TestCRM_DynamicCreate_DataInvalidJSON(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + t.Errorf("server should not be called for malformed JSON") + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "create", "--data", `{not json}`}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want JSON parse error") + } + if !strings.Contains(err.Error(), "JSON") { + t.Errorf("error should mention JSON: %v", err) + } +} + +func TestCRM_DynamicDelete_NoBody(t *testing.T) { + var sawMethod string + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + sawMethod = r.Method + w.WriteHeader(204) + }) + rootCmd.SetArgs([]string{"crm", "records", "delete", "REC1"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if sawMethod != http.MethodDelete { + t.Errorf("method: got %s", sawMethod) + } +} + +func TestCRM_DynamicRestorePost_NoBody(t *testing.T) { + var sawMethod, sawContentType string + var sawBody []byte + setupCRMTest(t, func(w http.ResponseWriter, r *http.Request) { + sawMethod = r.Method + sawContentType = r.Header.Get("Content-Type") + var err error + sawBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + _, _ = io.WriteString(w, `{"restored":true}`) + }) + rootCmd.SetArgs([]string{"crm", "records", "restore", "REC1", "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if sawMethod != http.MethodPost { + t.Errorf("method: got %s", sawMethod) + } + if len(sawBody) != 0 { + t.Errorf("body: got %q, want empty", string(sawBody)) + } + if sawContentType != "" { + t.Errorf("Content-Type: got %q, want empty when body is absent", sawContentType) + } +} + +func TestCRM_DynamicListRecords_RequiredQueryEnforced(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + t.Errorf("server should not be called when required flag missing") + }) + + rootCmd.SetArgs([]string{"crm", "records", "list"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want error when required --entityId missing") + } +} + +func TestCRM_DynamicListRecords_FormatInvalid(t *testing.T) { + setupCRMTest(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[]`) + }) + rootCmd.SetArgs([]string{"crm", "records", "list", "--entityId", "X", "--format", "yaml"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want error for unknown format") + } +} + +func TestCRM_ConfigClearCache(t *testing.T) { + cacheDir := t.TempDir() + t.Setenv(spec.CacheDirEnv, cacheDir) + resetFlags() + + // Seed a file. + if err := writeFile(cacheDir+"/openapi-spec-solapi.json", []byte(`{"data":{},"timestamp":0}`)); err != nil { + t.Fatal(err) + } + + var errBuf bytes.Buffer + errWriter = &errBuf + t.Cleanup(func() { errWriter = nil; resetFlags() }) + + rootCmd.SetArgs([]string{"crm", "config", "clear-cache"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(errBuf.String(), "캐시 디렉토리를 비웠습니다") { + t.Errorf("missing confirmation: %s", errBuf.String()) + } +} + +func TestCRM_RegisterDynamicWithoutCredentials(t *testing.T) { + resetFlags() + t.Setenv("HOME", t.TempDir()) + t.Setenv(spec.CacheDirEnv, t.TempDir()) + t.Setenv("SOLACTL_API_KEY", "") + t.Setenv("SOLACTL_API_SECRET", "") + + resetCRMRegistration() + specSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, fakeSpec) + })) + t.Cleanup(specSrv.Close) + crmLoaderOverride = &spec.Loader{URL: specSrv.URL} + t.Cleanup(func() { + crmLoaderOverride = nil + resetCRMRegistration() + resetFlags() + }) + + RegisterDynamicCRM(context.Background()) + + var foundList bool + for _, c := range crmCmd.Commands() { + if c.Name() != "records" { + continue + } + for _, child := range c.Commands() { + if child.Name() == "list" && child.Annotations[crmDynamicCommandAnnotation] == "true" { + foundList = true + } + } + } + if !foundList { + t.Fatal("dynamic CRM commands should register before credentials are parsed") + } +} + +func writeFile(path string, b []byte) error { + return os.WriteFile(path, b, 0o600) +} diff --git a/cmd/crm_resource.go b/cmd/crm_resource.go new file mode 100644 index 0000000..98aa30b --- /dev/null +++ b/cmd/crm_resource.go @@ -0,0 +1,51 @@ +package cmd + +import "github.com/spf13/cobra" + +const ( + crmDynamicResourceAnnotation = "solactl.crm.dynamicResource" + crmDynamicCommandAnnotation = "solactl.crm.dynamicCommand" + crmStaticResourceAnnotation = "solactl.crm.staticResource" +) + +func ensureCRMResourceCommand(resource, short string) (*cobra.Command, bool) { + for _, child := range crmCmd.Commands() { + if child.Name() == resource { + if child.Annotations == nil { + child.Annotations = map[string]string{} + } + return child, false + } + } + + cmd := &cobra.Command{ + Use: resource, + Short: short, + Annotations: map[string]string{}, + } + crmCmd.AddCommand(cmd) + return cmd, true +} + +func ensureStaticCRMResourceCommand(resource, short string) *cobra.Command { + cmd, _ := ensureCRMResourceCommand(resource, short) + cmd.Annotations[crmStaticResourceAnnotation] = "true" + return cmd +} + +func markDynamicCRMResourceCommand(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + if cmd.Annotations[crmStaticResourceAnnotation] != "true" { + cmd.Annotations[crmDynamicResourceAnnotation] = "true" + } +} + +func markDynamicCRMCommand(cmd *cobra.Command) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[crmDynamicCommandAnnotation] = "true" + return cmd +} diff --git a/cmd/crm_upload.go b/cmd/crm_upload.go new file mode 100644 index 0000000..f2f5b2d --- /dev/null +++ b/cmd/crm_upload.go @@ -0,0 +1,454 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "mime" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/solapi/solactl/pkg/client" + "github.com/solapi/solactl/pkg/crm/output" +) + +const ( + crmUploadMax10MB = 10 * 1024 * 1024 + crmUploadMax20MB = 20 * 1024 * 1024 + crmUploadMax5MB = 5 * 1024 * 1024 + crmUploadMax1MB = 1 * 1024 * 1024 +) + +var ( + crmImageUploadExt = extSet(".jpg", ".jpeg", ".png", ".gif", ".webp") + crmExcelUploadExt = extSet(".xls", ".xlsx") + crmFileUploadExt = extSet(".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".hwp", ".txt", ".csv", ".mp4", ".mov", ".mp3") + crmDocUploadExt = extSet(".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".hwp", ".txt") + crmAgentUploadExt = extSet(".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".csv", ".xlsx", ".xls", ".txt") + crmDocUploadMaxByExt = extMaxSet(crmUploadMax10MB, ".jpg", ".jpeg", ".png", ".gif", ".webp") + crmAgentUploadMaxByExt = mergeExtMaxSets( + extMaxSet(crmUploadMax5MB, ".jpg", ".jpeg", ".png", ".gif", ".webp"), + extMaxSet(crmUploadMax20MB, ".pdf"), + extMaxSet(crmUploadMax10MB, ".csv", ".xlsx", ".xls"), + extMaxSet(crmUploadMax1MB, ".txt"), + ) +) + +type crmUploadConstraint struct { + label string + maxBytes int64 + maxBytesByExt map[string]int64 + extensions map[string]struct{} +} + +type crmUploadRequest struct { + path string + query url.Values + fields []client.MultipartField +} + +func init() { + records := ensureStaticCRMResourceCommand("records", "CRM 레코드 리소스 관리") + records.AddCommand(newCRMUploadCommand("extract-excel-columns", "Excel 파일 컬럼을 추출합니다", "/crm-core/v1/records/import/excel/extract-columns", nil, crmUploadConstraint{"Excel 파일", crmUploadMax10MB, nil, crmExcelUploadExt}, addExcelCommonFlags, buildExtractExcelColumnsUpload)) + records.AddCommand(newCRMUploadCommand("preview-excel-import", "Excel 가져오기 미리보기를 생성합니다", "/crm-core/v1/records/import/excel/preview", nil, crmUploadConstraint{"Excel 파일", crmUploadMax10MB, nil, crmExcelUploadExt}, addExcelPreviewFlags, buildPreviewExcelUpload)) + records.AddCommand(newCRMUploadCommand("import-excel", "Excel 파일로 레코드를 일괄 가져옵니다", "/crm-core/v1/records/import/excel", nil, crmUploadConstraint{"Excel 파일", crmUploadMax10MB, nil, crmExcelUploadExt}, addExcelImportFlags, buildImportExcelUpload)) + records.AddCommand(newCRMUploadCommand("upload-profile-image ", "레코드 프로필 이미지를 업로드합니다", "/crm-core/v1/records/{recordId}/profile-image", []string{"recordId"}, crmUploadConstraint{"이미지 파일", crmUploadMax10MB, nil, crmImageUploadExt}, nil, buildSimpleUpload)) + records.AddCommand(newCRMUploadCommand("upload-image ", "레코드 이미지를 업로드합니다", "/crm-core/v1/records/{recordId}/images", []string{"recordId"}, crmUploadConstraint{"이미지 파일", crmUploadMax10MB, nil, crmImageUploadExt}, nil, buildSimpleUpload)) + records.AddCommand(newCRMUploadCommand("upload-attachment ", "레코드 첨부파일을 업로드합니다", "/crm-core/v1/records/{recordId}/attachments", []string{"recordId"}, crmUploadConstraint{"첨부파일", crmUploadMax10MB, nil, crmFileUploadExt}, addRecordAttachmentFlags, buildRecordAttachmentUpload)) + + agent := ensureStaticCRMResourceCommand("agent", "CRM AI 에이전트 리소스 관리") + agent.AddCommand(newCRMUploadCommand("upload-file", "AI 에이전트 파일을 업로드합니다", "/crm-core/v1/agent/files", nil, crmUploadConstraint{"에이전트 파일", crmUploadMax20MB, crmAgentUploadMaxByExt, crmAgentUploadExt}, nil, buildSimpleUpload)) + + documentTemplates := ensureStaticCRMResourceCommand("document-templates", "CRM 문서 템플릿 리소스 관리") + documentTemplates.AddCommand(newCRMUploadCommand("upload-version-attachment ", "문서 템플릿 버전에 파일을 첨부합니다", "/crm-core/v1/document-templates/{templateId}/versions/{versionId}/attachments", []string{"templateId", "versionId"}, crmUploadConstraint{"첨부파일", 0, nil, nil}, nil, buildSimpleUpload)) + + documents := ensureStaticCRMResourceCommand("documents", "CRM 문서 리소스 관리") + documents.AddCommand(newCRMUploadCommand("upload-attachment ", "문서 첨부파일을 업로드합니다", "/crm-core/v1/documents/{documentId}/attachments", []string{"documentId"}, crmUploadConstraint{"문서 첨부파일", crmUploadMax20MB, crmDocUploadMaxByExt, crmDocUploadExt}, nil, buildSimpleUpload)) + + messageTemplates := ensureStaticCRMResourceCommand("message-templates", "CRM 메시지 템플릿 리소스 관리") + messageTemplates.AddCommand(newCRMUploadCommand("upload-image ", "메시지 템플릿 이미지를 업로드합니다", "/crm-core/v1/message-templates/{messageTemplateId}/image", []string{"messageTemplateId"}, crmUploadConstraint{"이미지 파일", crmUploadMax10MB, nil, crmImageUploadExt}, nil, buildSimpleUpload)) + + forms := ensureStaticCRMResourceCommand("forms", "CRM 폼 리소스 관리") + forms.AddCommand(newCRMUploadCommand("upload-image ", "폼 이미지를 업로드합니다", "/crm-core/v1/forms/{formId}/images", []string{"formId"}, crmUploadConstraint{"이미지 파일", crmUploadMax10MB, nil, crmImageUploadExt}, addFormImageFlags, buildFormImageUpload)) + forms.AddCommand(newPublicCRMUploadCommand("upload-public-file ", "공개 폼 파일 첨부를 업로드합니다", "/crm-core/v1/sdk/forms/{publicToken}/upload", []string{"publicToken"}, crmUploadConstraint{"공개 폼 첨부파일", crmUploadMax10MB, nil, crmFileUploadExt}, nil, buildSimpleUpload)) + + contents := ensureStaticCRMResourceCommand("contents", "CRM 콘텐츠 리소스 관리") + contents.AddCommand(newCRMUploadCommand("upload-image ", "콘텐츠 이미지를 업로드합니다", "/crm-core/v1/contents/{contentId}/images", []string{"contentId"}, crmUploadConstraint{"이미지 파일", crmUploadMax10MB, nil, crmImageUploadExt}, nil, buildSimpleUpload)) +} + +func newCRMUploadCommand(use, short, path string, pathParams []string, constraint crmUploadConstraint, configure func(*cobra.Command), build func(*cobra.Command, string) (crmUploadRequest, error)) *cobra.Command { + return newCRMUploadCommandWithAuth(use, short, path, pathParams, constraint, configure, build, true) +} + +func newPublicCRMUploadCommand(use, short, path string, pathParams []string, constraint crmUploadConstraint, configure func(*cobra.Command), build func(*cobra.Command, string) (crmUploadRequest, error)) *cobra.Command { + return newCRMUploadCommandWithAuth(use, short, path, pathParams, constraint, configure, build, false) +} + +func newCRMUploadCommandWithAuth(use, short, path string, pathParams []string, constraint crmUploadConstraint, configure func(*cobra.Command), build func(*cobra.Command, string) (crmUploadRequest, error), authRequired bool) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.ExactArgs(len(pathParams)), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + defer resetFlagSet(cmd.Flags()) + + format, err := uploadOutputFormat(cmd) + if err != nil { + return err + } + filePath, err := requiredUploadFilePath(cmd) + if err != nil { + return err + } + if err := validateCRMUploadFile(filePath, constraint); err != nil { + return err + } + + resolvedPath := path + for i, name := range pathParams { + resolvedPath = strings.ReplaceAll(resolvedPath, "{"+name+"}", encodePathArg(args[i])) + } + resolvedPath = strings.TrimPrefix(resolvedPath, "/") + + req, err := build(cmd, resolvedPath) + if err != nil { + return err + } + if len(req.query) > 0 { + req.path = withQuery(req.path, req.query) + } + + c, err := newCRMUploadClient(authRequired) + if err != nil { + return err + } + raw, err := c.PostMultipart(ctx(), req.path, req.fields, client.MultipartFile{ + FieldName: "file", + Path: filePath, + FileName: filepath.Base(filePath), + ContentType: crmUploadContentType(filePath), + }) + if err != nil { + return fmt.Errorf("%s 업로드 실패: %w", constraint.label, err) + } + + rendered, err := output.FormatBytes([]byte(raw), format) + if err != nil { + return err + } + if rendered != "" { + _, _ = fmt.Fprintln(out(), rendered) + } + return nil + }, + } + cmd.Flags().String("file", "", "업로드할 파일 경로") + cmd.Flags().String("format", "", "출력 형식 (json/table/csv, 기본 table; --json이 켜져 있으면 json)") + if configure != nil { + configure(cmd) + } + return cmd +} + +func newCRMUploadClient(authRequired bool) (*client.Client, error) { + if authRequired { + return newClient() + } + if clientOverride != nil { + return clientOverride, nil + } + c := client.New("", "") + c.SkipAuthorization = true + return c, nil +} + +func addExcelCommonFlags(cmd *cobra.Command) { + cmd.Flags().String("sheet-name", "", "대상 Excel 시트 이름") + cmd.Flags().Bool("has-header", true, "첫 행을 헤더로 처리할지 여부") +} + +func addExcelPreviewFlags(cmd *cobra.Command) { + cmd.Flags().String("entity-id", "", "가져올 대상 개체 ID") + addExcelCommonFlags(cmd) +} + +func addExcelImportFlags(cmd *cobra.Command) { + cmd.Flags().String("entity-id", "", "가져올 대상 개체 ID") + cmd.Flags().String("column-mappings", "", "컬럼 매핑 JSON 문자열") + cmd.Flags().String("link-configs", "", "연결 설정 JSON 문자열") + cmd.Flags().Bool("skip-automation", false, "가져오기 후 자동화 실행을 건너뜁니다") + addExcelCommonFlags(cmd) +} + +func addRecordAttachmentFlags(cmd *cobra.Command) { + cmd.Flags().String("title", "", "첨부파일 제목 (최대 200자)") + cmd.Flags().String("description", "", "첨부파일 설명 (최대 1000자)") +} + +func addFormImageFlags(cmd *cobra.Command) { + cmd.Flags().String("purpose", "", "이미지 용도 (예: cover, question)") +} + +func buildSimpleUpload(_ *cobra.Command, path string) (crmUploadRequest, error) { + return crmUploadRequest{path: path}, nil +} + +func buildExtractExcelColumnsUpload(cmd *cobra.Command, path string) (crmUploadRequest, error) { + fields := make([]client.MultipartField, 0, 2) + addOptionalStringField(cmd, &fields, "sheet-name", "sheetName") + addOptionalBoolField(cmd, &fields, "has-header", "hasHeader") + return crmUploadRequest{path: path, fields: fields}, nil +} + +func buildPreviewExcelUpload(cmd *cobra.Command, path string) (crmUploadRequest, error) { + entityID, err := requiredStringFlag(cmd, "entity-id", "--entity-id 로 가져올 대상 개체 ID를 지정해야 합니다") + if err != nil { + return crmUploadRequest{}, err + } + fields := []client.MultipartField{{Name: "entityId", Value: entityID}} + addOptionalStringField(cmd, &fields, "sheet-name", "sheetName") + addOptionalBoolField(cmd, &fields, "has-header", "hasHeader") + return crmUploadRequest{path: path, fields: fields}, nil +} + +func buildImportExcelUpload(cmd *cobra.Command, path string) (crmUploadRequest, error) { + entityID, err := requiredStringFlag(cmd, "entity-id", "--entity-id 로 가져올 대상 개체 ID를 지정해야 합니다") + if err != nil { + return crmUploadRequest{}, err + } + fields := []client.MultipartField{{Name: "entityId", Value: entityID}} + addOptionalStringField(cmd, &fields, "sheet-name", "sheetName") + addOptionalBoolField(cmd, &fields, "has-header", "hasHeader") + if err := addOptionalJSONField(cmd, &fields, "column-mappings", "columnMappings"); err != nil { + return crmUploadRequest{}, err + } + if err := addOptionalJSONField(cmd, &fields, "link-configs", "linkConfigs"); err != nil { + return crmUploadRequest{}, err + } + query := url.Values{} + if flagChanged(cmd, "skip-automation") { + v, _ := cmd.Flags().GetBool("skip-automation") + query.Set("skipAutomation", strconv.FormatBool(v)) + } + return crmUploadRequest{path: path, fields: fields, query: query}, nil +} + +func buildRecordAttachmentUpload(cmd *cobra.Command, path string) (crmUploadRequest, error) { + fields := make([]client.MultipartField, 0, 2) + if err := addLimitedOptionalStringField(cmd, &fields, "title", "title", 200); err != nil { + return crmUploadRequest{}, err + } + if err := addLimitedOptionalStringField(cmd, &fields, "description", "description", 1000); err != nil { + return crmUploadRequest{}, err + } + return crmUploadRequest{path: path, fields: fields}, nil +} + +func buildFormImageUpload(cmd *cobra.Command, path string) (crmUploadRequest, error) { + query := url.Values{} + if flagChanged(cmd, "purpose") { + v, _ := cmd.Flags().GetString("purpose") + query.Set("purpose", strings.TrimSpace(v)) + } + return crmUploadRequest{path: path, query: query}, nil +} + +func uploadOutputFormat(cmd *cobra.Command) (output.Format, error) { + raw, _ := cmd.Flags().GetString("format") + format, err := output.NormalizeFormat(raw) + if err != nil { + return "", err + } + if raw == "" && flagJSON { + return output.FormatJSON, nil + } + return format, nil +} + +func requiredUploadFilePath(cmd *cobra.Command) (string, error) { + filePath, _ := cmd.Flags().GetString("file") + if strings.TrimSpace(filePath) == "" { + return "", fmt.Errorf("--file 로 업로드할 파일 경로를 지정해야 합니다") + } + return filePath, nil +} + +func requiredStringFlag(cmd *cobra.Command, name, message string) (string, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return "", fmt.Errorf("%s", message) + } + return v, nil +} + +func addOptionalStringField(cmd *cobra.Command, fields *[]client.MultipartField, flagName, fieldName string) { + if !flagChanged(cmd, flagName) { + return + } + v, _ := cmd.Flags().GetString(flagName) + *fields = append(*fields, client.MultipartField{Name: fieldName, Value: v}) +} + +func addOptionalBoolField(cmd *cobra.Command, fields *[]client.MultipartField, flagName, fieldName string) { + if !flagChanged(cmd, flagName) { + return + } + v, _ := cmd.Flags().GetBool(flagName) + *fields = append(*fields, client.MultipartField{Name: fieldName, Value: strconv.FormatBool(v)}) +} + +func addOptionalJSONField(cmd *cobra.Command, fields *[]client.MultipartField, flagName, fieldName string) error { + if !flagChanged(cmd, flagName) { + return nil + } + v, _ := cmd.Flags().GetString(flagName) + if strings.TrimSpace(v) == "" { + return fmt.Errorf("--%s 값은 비어 있을 수 없습니다", flagName) + } + var parsed any + if err := json.Unmarshal([]byte(v), &parsed); err != nil { + return fmt.Errorf("--%s 값은 JSON 형식이어야 합니다: %w", flagName, err) + } + *fields = append(*fields, client.MultipartField{Name: fieldName, Value: v}) + return nil +} + +func addLimitedOptionalStringField(cmd *cobra.Command, fields *[]client.MultipartField, flagName, fieldName string, maxRunes int) error { + if !flagChanged(cmd, flagName) { + return nil + } + v, _ := cmd.Flags().GetString(flagName) + if len([]rune(v)) > maxRunes { + return fmt.Errorf("--%s 값은 %d자 이하여야 합니다", flagName, maxRunes) + } + *fields = append(*fields, client.MultipartField{Name: fieldName, Value: v}) + return nil +} + +func validateCRMUploadFile(path string, constraint crmUploadConstraint) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s을(를) 열 수 없습니다: %w", constraint.label, err) + } + if info.IsDir() { + return fmt.Errorf("%s 경로가 디렉터리입니다: %s", constraint.label, path) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s은(는) 일반 파일이어야 합니다: %s", constraint.label, path) + } + if info.Size() == 0 { + return fmt.Errorf("%s이(가) 비어 있습니다", constraint.label) + } + maxBytes := maxBytesForUpload(path, constraint) + if maxBytes > 0 && info.Size() > maxBytes { + return fmt.Errorf("%s 크기는 최대 %s까지 허용됩니다 (현재 %s)", constraint.label, formatBytes(maxBytes), formatBytes(info.Size())) + } + ext := strings.ToLower(filepath.Ext(path)) + if len(constraint.extensions) > 0 { + if _, ok := constraint.extensions[ext]; !ok { + return fmt.Errorf("%s 형식은 지원하지 않습니다: %s", constraint.label, firstNonEmptyString(ext, "(확장자 없음)")) + } + } + return nil +} + +func crmUploadContentType(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + known := map[string]string{ + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".hwp": "application/x-hwp", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".pdf": "application/pdf", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + if ct, ok := known[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + if i := strings.IndexByte(ct, ';'); i >= 0 { + return ct[:i] + } + return ct + } + return "application/octet-stream" +} + +func extSet(exts ...string) map[string]struct{} { + out := make(map[string]struct{}, len(exts)) + for _, ext := range exts { + out[ext] = struct{}{} + } + return out +} + +func extMaxSet(maxBytes int64, exts ...string) map[string]int64 { + out := make(map[string]int64, len(exts)) + for _, ext := range exts { + out[ext] = maxBytes + } + return out +} + +func mergeExtMaxSets(sets ...map[string]int64) map[string]int64 { + out := map[string]int64{} + for _, set := range sets { + for ext, maxBytes := range set { + out[ext] = maxBytes + } + } + return out +} + +func maxBytesForUpload(path string, constraint crmUploadConstraint) int64 { + ext := strings.ToLower(filepath.Ext(path)) + if constraint.maxBytesByExt != nil { + if maxBytes, ok := constraint.maxBytesByExt[ext]; ok { + return maxBytes + } + } + return constraint.maxBytes +} + +func flagChanged(cmd *cobra.Command, name string) bool { + f := cmd.Flags().Lookup(name) + return f != nil && f.Changed +} + +func resetFlagSet(flags *pflag.FlagSet) { + flags.VisitAll(func(f *pflag.Flag) { + f.Changed = false + _ = f.Value.Set(f.DefValue) + }) +} + +func formatBytes(n int64) string { + const mb = 1024 * 1024 + if n%mb == 0 { + return fmt.Sprintf("%dMB", n/mb) + } + return fmt.Sprintf("%d bytes", n) +} + +func firstNonEmptyString(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/cmd/crm_upload_test.go b/cmd/crm_upload_test.go new file mode 100644 index 0000000..388fdb5 --- /dev/null +++ b/cmd/crm_upload_test.go @@ -0,0 +1,357 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solapi/solactl/pkg/client" +) + +func setupCRMUploadTest(t *testing.T, handler http.HandlerFunc) *bytes.Buffer { + t.Helper() + resetFlags() + t.Setenv("HOME", t.TempDir()) + t.Setenv("SOLACTL_API_KEY", "TESTKEY") + t.Setenv("SOLACTL_API_SECRET", "TESTSECRET") + + apiSrv := httptest.NewServer(handler) + c := &client.Client{ + HTTPClient: apiSrv.Client(), + APIKey: "TESTKEY", + APISecret: "TESTSECRET", + MaxRetries: 0, + BaseDelay: time.Millisecond, + BaseURLOverride: apiSrv.URL, + } + clientOverride = c + + var outBuf bytes.Buffer + outWriter = &outBuf + t.Cleanup(func() { + clientOverride = nil + outWriter = nil + errWriter = nil + apiSrv.Close() + resetFlags() + }) + return &outBuf +} + +func writeUploadFile(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func writeSizedUploadFile(t *testing.T, name string, size int64) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(size); err != nil { + _ = f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func TestCRMUploadRecordsImportExcel_SendsMultipartFieldsAndQuery(t *testing.T) { + filePath := writeUploadFile(t, "records.xlsx", []byte("PK\x03\x04excel")) + stdout := setupCRMUploadTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method: got %s", r.Method) + } + if r.URL.Path != "/crm-core/v1/records/import/excel" { + t.Errorf("path: got %s", r.URL.Path) + } + if got := r.URL.Query().Get("skipAutomation"); got != "true" { + t.Errorf("skipAutomation: got %q", got) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + checks := map[string]string{ + "entityId": "ENxxx", + "sheetName": "Contacts", + "hasHeader": "false", + "columnMappings": `{"0":"__name__"}`, + "linkConfigs": `[{"entityId":"ENlink"}]`, + } + for key, want := range checks { + if got := r.FormValue(key); got != want { + t.Errorf("field %s: got %q, want %q", key, got, want) + } + } + f, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer func() { _ = f.Close() }() + if header.Filename != "records.xlsx" { + t.Errorf("filename: got %q", header.Filename) + } + if got := header.Header.Get("Content-Type"); got != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" { + t.Errorf("file content-type: got %q", got) + } + _, _ = io.WriteString(w, `{"jobId":"JOB1"}`) + }) + + rootCmd.SetArgs([]string{ + "crm", "records", "import-excel", + "--file", filePath, + "--entity-id", "ENxxx", + "--sheet-name", "Contacts", + "--has-header=false", + "--column-mappings", `{"0":"__name__"}`, + "--link-configs", `[{"entityId":"ENlink"}]`, + "--skip-automation", + "--format", "json", + }) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var parsed map[string]string + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + if parsed["jobId"] != "JOB1" { + t.Errorf("jobId: got %q", parsed["jobId"]) + } +} + +func TestCRMUploadFormsUploadImage_SendsPurposeQueryAndImageMime(t *testing.T) { + filePath := writeUploadFile(t, "cover.png", []byte("\x89PNG\r\n\x1a\nimage")) + setupCRMUploadTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/crm-core/v1/forms/FORM1/images" { + t.Errorf("path: got %s", r.URL.Path) + } + if got := r.URL.Query().Get("purpose"); got != "cover" { + t.Errorf("purpose: got %q", got) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + f, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer func() { _ = f.Close() }() + if got := header.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("file content-type: got %q", got) + } + _, _ = io.WriteString(w, `{"fileId":"FILE1"}`) + }) + + rootCmd.SetArgs([]string{"crm", "forms", "upload-image", "FORM1", "--file", filePath, "--purpose", "cover", "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCRMUploadDocumentTemplateAttachment_AllowsUnfilteredFileExtension(t *testing.T) { + filePath := writeUploadFile(t, "archive.zip", []byte("PK\x03\x04zip")) + setupCRMUploadTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/crm-core/v1/document-templates/TPL1/versions/VER1/attachments" { + t.Errorf("path: got %s", r.URL.Path) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + f, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer func() { _ = f.Close() }() + if header.Filename != "archive.zip" { + t.Errorf("filename: got %q", header.Filename) + } + _, _ = io.WriteString(w, `{"fileId":"FILE1"}`) + }) + + rootCmd.SetArgs([]string{"crm", "document-templates", "upload-version-attachment", "TPL1", "VER1", "--file", filePath, "--format", "json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewCRMUploadClient_PublicDoesNotRequireCredentials(t *testing.T) { + resetFlags() + t.Setenv("HOME", t.TempDir()) + t.Setenv("SOLACTL_API_KEY", "") + t.Setenv("SOLACTL_API_SECRET", "") + clientOverride = nil + t.Cleanup(resetFlags) + + c, err := newCRMUploadClient(false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !c.SkipAuthorization { + t.Fatal("public upload client should not attach Authorization") + } +} + +func TestCRMUploadRecordsUploadAttachment_RejectsEmptyFileBeforeRequest(t *testing.T) { + filePath := writeUploadFile(t, "empty.pdf", nil) + var called bool + setupCRMUploadTest(t, func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "upload-attachment", "REC1", "--file", filePath}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want error for empty file") + } + if !strings.Contains(err.Error(), "비어") { + t.Errorf("error should explain empty file: %v", err) + } + if called { + t.Fatal("server should not be called when local file validation fails") + } +} + +func TestCRMUploadRecordsImportExcel_RejectsInvalidJSONBeforeRequest(t *testing.T) { + filePath := writeUploadFile(t, "records.xlsx", []byte("PK\x03\x04excel")) + var called bool + setupCRMUploadTest(t, func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "records", "import-excel", "--file", filePath, "--entity-id", "ENxxx", "--column-mappings", `{not-json}`}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want invalid JSON error") + } + if !strings.Contains(err.Error(), "--column-mappings") || !strings.Contains(err.Error(), "JSON") { + t.Errorf("error should identify invalid JSON flag: %v", err) + } + if called { + t.Fatal("server should not be called when JSON flag validation fails") + } +} + +func TestCRMUploadAgentUploadFile_RejectsUnsupportedExtension(t *testing.T) { + filePath := writeUploadFile(t, "script.exe", []byte("MZ")) + var called bool + setupCRMUploadTest(t, func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = io.WriteString(w, `{}`) + }) + + rootCmd.SetArgs([]string{"crm", "agent", "upload-file", "--file", filePath}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("want unsupported extension error") + } + if !strings.Contains(err.Error(), "지원하지 않습니다") { + t.Errorf("error should explain unsupported extension: %v", err) + } + if called { + t.Fatal("server should not be called when extension validation fails") + } +} + +func TestValidateCRMUploadFile_BoundariesAndFailures(t *testing.T) { + constraint := crmUploadConstraint{ + label: "테스트 파일", + maxBytes: 3, + maxBytesByExt: nil, + extensions: extSet(".txt"), + } + + atMax := writeUploadFile(t, "ok.txt", []byte("123")) + if err := validateCRMUploadFile(atMax, constraint); err != nil { + t.Fatalf("file at max size should pass: %v", err) + } + + overMax := writeUploadFile(t, "over.txt", []byte("1234")) + if err := validateCRMUploadFile(overMax, constraint); err == nil || !strings.Contains(err.Error(), "최대") { + t.Fatalf("over max error: got %v", err) + } + + dir := t.TempDir() + if err := validateCRMUploadFile(dir, constraint); err == nil || !strings.Contains(err.Error(), "디렉터리") { + t.Fatalf("directory error: got %v", err) + } + + unsupported := writeUploadFile(t, "bad.bin", []byte("123")) + if err := validateCRMUploadFile(unsupported, constraint); err == nil || !strings.Contains(err.Error(), "지원하지 않습니다") { + t.Fatalf("unsupported extension error: got %v", err) + } +} + +func TestValidateCRMUploadFile_DocumentsImageUsesImageLimit(t *testing.T) { + constraint := crmUploadConstraint{ + label: "문서 첨부파일", + maxBytes: crmUploadMax20MB, + maxBytesByExt: crmDocUploadMaxByExt, + extensions: crmDocUploadExt, + } + atMax := writeSizedUploadFile(t, "image.jpg", crmUploadMax10MB) + if err := validateCRMUploadFile(atMax, constraint); err != nil { + t.Fatalf("image at document image max should pass: %v", err) + } + + overMax := writeSizedUploadFile(t, "large.jpg", crmUploadMax10MB+1) + err := validateCRMUploadFile(overMax, constraint) + if err == nil { + t.Fatal("want document image size error") + } + if !strings.Contains(err.Error(), "10MB") { + t.Fatalf("error should explain image-specific 10MB limit: %v", err) + } +} + +func TestValidateCRMUploadFile_AgentFileUsesTypeSpecificLimits(t *testing.T) { + constraint := crmUploadConstraint{ + label: "에이전트 파일", + maxBytes: crmUploadMax20MB, + maxBytesByExt: crmAgentUploadMaxByExt, + extensions: crmAgentUploadExt, + } + pdfAtMax := writeSizedUploadFile(t, "source.pdf", crmUploadMax20MB) + if err := validateCRMUploadFile(pdfAtMax, constraint); err != nil { + t.Fatalf("PDF at agent PDF max should pass: %v", err) + } + + textOverMax := writeSizedUploadFile(t, "source.txt", crmUploadMax1MB+1) + err := validateCRMUploadFile(textOverMax, constraint) + if err == nil { + t.Fatal("want text file size error") + } + if !strings.Contains(err.Error(), "1MB") { + t.Fatalf("error should explain text-specific 1MB limit: %v", err) + } +} + +func TestValidateCRMUploadFile_TemplateVersionAttachmentHasNoLocalTypeOrSizeLimit(t *testing.T) { + constraint := crmUploadConstraint{ + label: "첨부파일", + maxBytes: 0, + maxBytesByExt: nil, + extensions: nil, + } + filePath := writeSizedUploadFile(t, "archive.zip", crmUploadMax20MB+1) + if err := validateCRMUploadFile(filePath, constraint); err != nil { + t.Fatalf("unfiltered template attachment should pass local validation: %v", err) + } +} diff --git a/cmd/messages_test.go b/cmd/messages_test.go index 52995aa..74826a1 100644 --- a/cmd/messages_test.go +++ b/cmd/messages_test.go @@ -163,7 +163,7 @@ func TestMessagesList_JSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &parsed); err != nil { t.Fatalf("output is not valid JSON: %v", err) } diff --git a/cmd/send.go b/cmd/send.go index dd063b2..4b8bb7b 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -23,10 +23,10 @@ var sendCmd = &cobra.Command{ // Shared flags available to all send subcommands. var ( - sendFlagTo string - sendFlagFrom string - sendFlagText string - sendFlagScheduled string + sendFlagTo string + sendFlagFrom string + sendFlagText string + sendFlagScheduled string sendFlagFile string // CSV file for bulk sending sendFlagSkipValidation bool sendFlagStrict bool @@ -95,10 +95,7 @@ func sendMessages(c *client.Client, msgs []types.Message) error { batchNum := 0 for start := 0; start < len(msgs); start += maxBatchSize { - end := start + maxBatchSize - if end > len(msgs) { - end = len(msgs) - } + end := min(start+maxBatchSize, len(msgs)) batch := msgs[start:end] batchNum++ @@ -271,6 +268,3 @@ func resolveFrom(c *client.Client) (string, error) { len(phones), strings.Join(lines, "\n")) } } - -// boolPtr returns a pointer to the given bool value. -func boolPtr(v bool) *bool { return &v } diff --git a/cmd/send_ata.go b/cmd/send_ata.go index 171f4cd..a187f99 100644 --- a/cmd/send_ata.go +++ b/cmd/send_ata.go @@ -74,7 +74,7 @@ func runSendATA(cmd *cobra.Command, args []string) error { Title: sendATAFlagTitle, } if sendATAFlagDisableSms { - kakaoOpts.DisableSms = boolPtr(true) + kakaoOpts.DisableSms = new(true) } var msgs []types.Message diff --git a/cmd/send_bms.go b/cmd/send_bms.go index e6b2260..5938934 100644 --- a/cmd/send_bms.go +++ b/cmd/send_bms.go @@ -100,7 +100,7 @@ func runSendBMS(cmd *cobra.Command, args []string) error { }, } if sendBMSFlagAd { - kakaoOpts.AdFlag = boolPtr(true) + kakaoOpts.AdFlag = new(true) } if sendBMSFlagFree { @@ -123,7 +123,7 @@ func runSendBMS(cmd *cobra.Command, args []string) error { } if sendBMSFlagAdult { - kakaoOpts.BMS.Adult = boolPtr(true) + kakaoOpts.BMS.Adult = new(true) } buttons, err := buildBMSButtons() diff --git a/cmd/send_rcs.go b/cmd/send_rcs.go index 16a30bb..6a4b98a 100644 --- a/cmd/send_rcs.go +++ b/cmd/send_rcs.go @@ -70,7 +70,7 @@ func runSendRCS(cmd *cobra.Command, args []string) error { rcsOpts.TemplateID = sendRCSFlagTemplateID } if sendRCSFlagCopyAllowed { - rcsOpts.CopyAllowed = boolPtr(true) + rcsOpts.CopyAllowed = new(true) } // Handle image upload for RCS diff --git a/cmd/send_test.go b/cmd/send_test.go index 9d0aecd..dd9084f 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -474,7 +474,7 @@ func TestSendSMS_JSONOutput(t *testing.T) { output := buf.String() // JSON output should be valid JSON - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &parsed); err != nil { t.Fatalf("output is not valid JSON: %v\noutput: %s", err, output) } @@ -907,7 +907,7 @@ func TestSendMessages_BatchSplit(t *testing.T) { // Build 5 messages and call sendMessages directly. var msgs []types.Message - for i := 0; i < 5; i++ { + for i := range 5 { msgs = append(msgs, types.Message{ To: fmt.Sprintf("0101111%04d", i), From: "01012345678", @@ -1417,7 +1417,7 @@ func TestSendMessages_ExactlyMaxBatch(t *testing.T) { // Exactly maxBatchSize (5) messages: should result in exactly 1 API call. var msgs []types.Message - for i := 0; i < 5; i++ { + for i := range 5 { msgs = append(msgs, types.Message{ To: fmt.Sprintf("0101111%04d", i), From: "01012345678", @@ -1489,7 +1489,7 @@ func TestSendMessages_MultipleBatches(t *testing.T) { // 7 messages with maxBatchSize=3 should yield 3 API calls: 3 + 3 + 1. var msgs []types.Message - for i := 0; i < 7; i++ { + for i := range 7 { msgs = append(msgs, types.Message{ To: fmt.Sprintf("0101111%04d", i), From: "01012345678", @@ -1979,7 +1979,7 @@ func TestSendATA_JSONOutput(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var parsed map[string]interface{} + var parsed map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &parsed); err != nil { t.Fatalf("output is not valid JSON: %v", err) } diff --git a/cmd/senderid_test.go b/cmd/senderid_test.go index 346d16f..74945e9 100644 --- a/cmd/senderid_test.go +++ b/cmd/senderid_test.go @@ -645,8 +645,8 @@ func TestSenderIDList_EmptyActive(t *testing.T) { t.Errorf("expected PHONE NUMBER header even for empty list, got:\n%s", output) } // Should not contain any phone number data - lines := strings.Split(strings.TrimSpace(output), "\n") - for _, line := range lines { + lines := strings.SplitSeq(strings.TrimSpace(output), "\n") + for line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" { continue @@ -770,8 +770,8 @@ func TestSenderIDList_AllFieldsEmpty(t *testing.T) { // Empty method and expireAt should show "-" // Count occurrences of "-" that are not part of table borders // The phone number row should have "-" for status, method, and expireAt - lines := strings.Split(output, "\n") - for _, line := range lines { + lines := strings.SplitSeq(output, "\n") + for line := range lines { if strings.Contains(line, "01000000000") { // This data row should contain "-" for empty fields dashCount := strings.Count(line, "-") diff --git a/cmd/solactl/main.go b/cmd/solactl/main.go index 7d52535..ffbc616 100644 --- a/cmd/solactl/main.go +++ b/cmd/solactl/main.go @@ -1,14 +1,26 @@ package main import ( + "context" "fmt" "os" + "strings" + "time" "github.com/solapi/solactl/cmd" "github.com/solapi/solactl/pkg/apierror" ) func main() { + // Only fetch the CRM OpenAPI spec when the user is actually invoking a + // `crm …` subcommand. Otherwise we'd add up to 30s of HTTP latency to + // `--help`, `--version`, `configure`, `send`, etc. + if invokesCRM(os.Args[1:]) { + regCtx, regCancel := context.WithTimeout(context.Background(), 35*time.Second) + cmd.RegisterDynamicCRM(regCtx) + regCancel() + } + if err := cmd.Execute(); err != nil { classified := apierror.Classify(err) _, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", classified.Message) @@ -18,3 +30,41 @@ func main() { os.Exit(1) } } + +// invokesCRM returns true when the first non-flag positional argument is "crm". +// This mirrors the root persistent flags closely enough to avoid mistaking a +// flag value such as `--profile crm` for the command while still registering +// dynamic commands for common forms like `--profile prod crm records list`. +func invokesCRM(args []string) bool { + skipNext := false + for i, a := range args { + if skipNext { + skipNext = false + continue + } + if a == "--" { + if i+1 >= len(args) { + return false + } + return args[i+1] == "crm" + } + if strings.HasPrefix(a, "--") { + name := strings.TrimPrefix(a, "--") + if idx := strings.IndexByte(name, '='); idx >= 0 { + name = name[:idx] + } + switch name { + case "api-key", "api-secret", "profile", "timeout": + if !strings.Contains(a, "=") { + skipNext = true + } + } + continue + } + if strings.HasPrefix(a, "-") { + continue + } + return a == "crm" + } + return false +} diff --git a/cmd/solactl/main_test.go b/cmd/solactl/main_test.go new file mode 100644 index 0000000..10758c2 --- /dev/null +++ b/cmd/solactl/main_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +func TestInvokesCRMDetectsCommandAfterRootFlags(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "plain crm", args: []string{"crm", "records", "list"}, want: true}, + {name: "profile before crm", args: []string{"--profile", "prod", "crm", "records", "list"}, want: true}, + {name: "profile equals form before crm", args: []string{"--profile=prod", "crm", "records", "list"}, want: true}, + {name: "api credentials before crm", args: []string{"--api-key", "key", "--api-secret", "secret", "crm", "records", "list"}, want: true}, + {name: "bool flags before crm", args: []string{"--debug", "--json", "crm", "records", "list"}, want: true}, + {name: "flag value named crm is not command", args: []string{"--profile", "crm", "balance"}, want: false}, + {name: "non crm command after flag", args: []string{"--timeout", "5s", "send", "sms"}, want: false}, + {name: "double dash crm", args: []string{"--", "crm", "records"}, want: true}, + {name: "empty args", args: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := invokesCRM(tt.args); got != tt.want { + t.Fatalf("invokesCRM(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/internal/version/semver.go b/internal/version/semver.go index 28c3978..a3d65dd 100644 --- a/internal/version/semver.go +++ b/internal/version/semver.go @@ -95,10 +95,7 @@ func comparePrereleaseIdentifiers(a, b string) int { aParts := strings.Split(a, ".") bParts := strings.Split(b, ".") - limit := len(aParts) - if len(bParts) < limit { - limit = len(bParts) - } + limit := min(len(bParts), len(aParts)) for i := 0; i < limit; i++ { aNum, aIsNum := strconv.Atoi(aParts[i]) diff --git a/pkg/apierror/apierror.go b/pkg/apierror/apierror.go index 8c941ac..1a33243 100644 --- a/pkg/apierror/apierror.go +++ b/pkg/apierror/apierror.go @@ -13,6 +13,7 @@ const ( CategoryUnknown Category = iota CategoryNetwork // connection refused, DNS, timeout CategoryAuth // 401, 403 + CategoryPlan // plan feature/quota restriction CategoryValidation // 400 CategoryNotFound // 404 CategoryRateLimit // 429 @@ -71,6 +72,16 @@ func classifyAPI(e *APIError) *ClassifiedError { ce := &ClassifiedError{Original: e} switch { + case strings.EqualFold(e.ErrorCode, "PlanQuotaExceeded"): + ce.Category = CategoryPlan + ce.Message = "CRM 플랜 한도를 초과했습니다" + ce.Hint = firstNonEmpty(e.ErrorMessage, "현재 플랜의 사용량 또는 한도를 확인하세요") + + case strings.EqualFold(e.ErrorCode, "PlanFeatureDisabled"): + ce.Category = CategoryPlan + ce.Message = "현재 CRM 플랜에서 사용할 수 없는 기능입니다" + ce.Hint = firstNonEmpty(e.ErrorMessage, "필요한 기능이 포함된 플랜인지 확인하세요") + case e.HTTPStatus == 401 || strings.EqualFold(e.ErrorCode, "Unauthorized"): ce.Category = CategoryAuth ce.Message = "인증에 실패했습니다" @@ -114,6 +125,15 @@ func classifyAPI(e *APIError) *ClassifiedError { return ce } +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + func classifyGeneric(err error) *ClassifiedError { ce := &ClassifiedError{Original: err} msg := err.Error() diff --git a/pkg/apierror/apierror_test.go b/pkg/apierror/apierror_test.go index e7f26ea..a7039cc 100644 --- a/pkg/apierror/apierror_test.go +++ b/pkg/apierror/apierror_test.go @@ -30,6 +30,20 @@ func TestClassify_APIErrors(t *testing.T) { wantMessage: "접근 권한이 없습니다", wantHint: "API 키의 권한을 확인하세요", }, + { + name: "plan quota exceeded", + err: &APIError{HTTPStatus: 403, ErrorCode: "PlanQuotaExceeded", ErrorMessage: "현재 사용량: 저장소 10/10. 권장 플랜: PROFESSIONAL"}, + wantCategory: CategoryPlan, + wantMessage: "CRM 플랜 한도를 초과했습니다", + wantHint: "현재 사용량: 저장소 10/10. 권장 플랜: PROFESSIONAL", + }, + { + name: "plan feature disabled", + err: &APIError{HTTPStatus: 403, ErrorCode: "PlanFeatureDisabled", ErrorMessage: "제한 기능: Excel 가져오기. 사용 가능한 플랜: STARTER"}, + wantCategory: CategoryPlan, + wantMessage: "현재 CRM 플랜에서 사용할 수 없는 기능입니다", + wantHint: "제한 기능: Excel 가져오기. 사용 가능한 플랜: STARTER", + }, { name: "404 not found", err: &APIError{HTTPStatus: 404, ErrorCode: "NotFound", ErrorMessage: "not found"}, @@ -460,7 +474,7 @@ func TestClassify_Concurrent(t *testing.T) { wg.Add(goroutines) results := make([]*ClassifiedError, goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { go func(idx int) { defer wg.Done() results[idx] = Classify(apiErr) diff --git a/pkg/auth/hmac_test.go b/pkg/auth/hmac_test.go index 5069312..97c5f34 100644 --- a/pkg/auth/hmac_test.go +++ b/pkg/auth/hmac_test.go @@ -238,7 +238,7 @@ func TestGenerateAuthorization_Concurrent(t *testing.T) { wg.Add(goroutines) errs := make(chan error, goroutines) - for i := 0; i < goroutines; i++ { + for range goroutines { go func() { defer wg.Done() result, err := GenerateAuthorization("NCS1234567890ABC", "01234567890123456789012345678901") diff --git a/pkg/client/client.go b/pkg/client/client.go index 4bb48b1..3960ab4 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -9,8 +9,12 @@ import ( "fmt" "io" "math/rand/v2" + "mime/multipart" "net/http" + "net/textproto" "net/url" + "os" + "path/filepath" "runtime" "strings" "time" @@ -87,13 +91,28 @@ func redactSlice(s []any) { // Client is an HTTP client for SOLAPI REST endpoints. type Client struct { - HTTPClient *http.Client - APIKey string - APISecret string - MaxRetries int - BaseDelay time.Duration - BaseURLOverride string // If set, used instead of the BaseURL constant. - UserAgent string // User-Agent header value. Set by caller. + HTTPClient *http.Client + APIKey string + APISecret string + MaxRetries int + BaseDelay time.Duration + BaseURLOverride string // If set, used instead of the BaseURL constant. + UserAgent string // User-Agent header value. Set by caller. + SkipAuthorization bool // If true, do not attach SOLAPI HMAC Authorization. +} + +// MultipartField is one regular form field in a multipart request. +type MultipartField struct { + Name string + Value string +} + +// MultipartFile is the file part in a multipart request. +type MultipartFile struct { + FieldName string + Path string + FileName string + ContentType string } // baseURL returns the effective base URL for API requests. @@ -127,30 +146,119 @@ func (c *Client) Get(ctx context.Context, path string, params url.Values) (json. // Post sends a POST request with a JSON body. func (c *Client) Post(ctx context.Context, path string, body any) (json.RawMessage, error) { u := c.baseURL() + "/" + strings.TrimLeft(path, "/") - data, err := json.Marshal(body) + data, err := marshalBody(body) if err != nil { return nil, fmt.Errorf("JSON 직렬화 실패: %w", err) } return c.executeWithRetry(ctx, http.MethodPost, u, data, isRetryableMutation) } +// PostMultipart sends a POST request with multipart/form-data. +func (c *Client) PostMultipart(ctx context.Context, path string, fields []MultipartField, file MultipartFile) (json.RawMessage, error) { + u := c.baseURL() + "/" + strings.TrimLeft(path, "/") + data, contentType, err := buildMultipartBody(fields, file) + if err != nil { + return nil, err + } + return c.executeWithRetryContentType(ctx, http.MethodPost, u, data, contentType, isRetryableMutation) +} + // Put sends a PUT request with a JSON body. func (c *Client) Put(ctx context.Context, path string, body any) (json.RawMessage, error) { u := c.baseURL() + "/" + strings.TrimLeft(path, "/") - data, err := json.Marshal(body) + data, err := marshalBody(body) if err != nil { return nil, fmt.Errorf("JSON 직렬화 실패: %w", err) } return c.executeWithRetry(ctx, http.MethodPut, u, data, isRetryableMutation) } +// Patch sends a PATCH request with a JSON body. A nil body sends no payload. +func (c *Client) Patch(ctx context.Context, path string, body any) (json.RawMessage, error) { + u := c.baseURL() + "/" + strings.TrimLeft(path, "/") + data, err := marshalBody(body) + if err != nil { + return nil, fmt.Errorf("JSON 직렬화 실패: %w", err) + } + return c.executeWithRetry(ctx, http.MethodPatch, u, data, isRetryableMutation) +} + // Delete sends a DELETE request. func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, error) { u := c.baseURL() + "/" + strings.TrimLeft(path, "/") return c.executeWithRetry(ctx, http.MethodDelete, u, nil, isRetryableMutation) } +func marshalBody(body any) ([]byte, error) { + if body == nil { + return nil, nil + } + return json.Marshal(body) +} + +func buildMultipartBody(fields []MultipartField, file MultipartFile) ([]byte, string, error) { + if file.FieldName == "" { + return nil, "", errors.New("multipart 파일 필드명이 비어 있습니다") + } + if file.Path == "" { + return nil, "", errors.New("multipart 파일 경로가 비어 있습니다") + } + + f, err := os.Open(file.Path) + if err != nil { + return nil, "", fmt.Errorf("파일 열기 실패: %w", err) + } + defer func() { _ = f.Close() }() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + for _, field := range fields { + if err := writer.WriteField(field.Name, field.Value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("multipart 필드 작성 실패: %w", err) + } + } + + filename := file.FileName + if filename == "" { + filename = filepath.Base(file.Path) + } + contentType := file.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, multipartEscape(file.FieldName), multipartEscape(filename))) + header.Set("Content-Type", contentType) + + part, err := writer.CreatePart(header) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("multipart 파일 파트 생성 실패: %w", err) + } + if _, err := io.Copy(part, f); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("multipart 파일 복사 실패: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("multipart 종료 실패: %w", err) + } + return buf.Bytes(), writer.FormDataContentType(), nil +} + +func multipartEscape(s string) string { + return strings.NewReplacer("\\", "\\\\", `"`, `\"`, "\r", "", "\n", "").Replace(s) +} + func (c *Client) executeWithRetry(ctx context.Context, method, rawURL string, body []byte, retryable func(error) bool) (json.RawMessage, error) { + contentType := "" + if body != nil { + contentType = "application/json" + } + return c.executeWithRetryContentType(ctx, method, rawURL, body, contentType, retryable) +} + +func (c *Client) executeWithRetryContentType(ctx context.Context, method, rawURL string, body []byte, contentType string, retryable func(error) bool) (json.RawMessage, error) { var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { @@ -166,7 +274,7 @@ func (c *Client) executeWithRetry(ctx context.Context, method, rawURL string, bo jitter = time.Duration(rand.Int64N(n)) } wait := delay + jitter - logger.Debug("재시�� %d/%d (대기: %v)", attempt, c.MaxRetries, wait) + logger.Debug("재시도 %d/%d (대기: %v)", attempt, c.MaxRetries, wait) timer := time.NewTimer(wait) select { @@ -177,7 +285,7 @@ func (c *Client) executeWithRetry(ctx context.Context, method, rawURL string, bo } } - result, err := c.doRequest(ctx, method, rawURL, body) + result, err := c.doRequest(ctx, method, rawURL, body, contentType) if err == nil { return result, nil } @@ -192,7 +300,7 @@ func (c *Client) executeWithRetry(ctx context.Context, method, rawURL string, bo return nil, lastErr } -func (c *Client) doRequest(ctx context.Context, method, rawURL string, body []byte) (json.RawMessage, error) { +func (c *Client) doRequest(ctx context.Context, method, rawURL string, body []byte, contentType string) (json.RawMessage, error) { var bodyReader io.Reader if body != nil { bodyReader = bytes.NewReader(body) @@ -203,15 +311,17 @@ func (c *Client) doRequest(ctx context.Context, method, rawURL string, body []by return nil, fmt.Errorf("creating request: %w", err) } - if body != nil { - req.Header.Set("Content-Type", "application/json") + if body != nil && contentType != "" { + req.Header.Set("Content-Type", contentType) } - authHeader, err := auth.GenerateAuthorization(c.APIKey, c.APISecret) - if err != nil { - return nil, fmt.Errorf("generating authorization: %w", err) + if !c.SkipAuthorization { + authHeader, err := auth.GenerateAuthorization(c.APIKey, c.APISecret) + if err != nil { + return nil, fmt.Errorf("generating authorization: %w", err) + } + req.Header.Set("Authorization", authHeader) } - req.Header.Set("Authorization", authHeader) if c.UserAgent != "" { req.Header.Set("User-Agent", c.UserAgent) @@ -263,17 +373,140 @@ func parseErrorResponse(statusCode int, body []byte) error { var parsed struct { ErrorCode string `json:"errorCode"` ErrorMessage string `json:"errorMessage"` + Message string `json:"message"` + Error string `json:"error"` } - if json.Unmarshal(body, &parsed) == nil && (parsed.ErrorCode != "" || parsed.ErrorMessage != "") { + if json.Unmarshal(body, &parsed) == nil && (parsed.ErrorCode != "" || parsed.ErrorMessage != "" || parsed.Message != "" || parsed.Error != "") { apiErr.ErrorCode = parsed.ErrorCode - apiErr.ErrorMessage = parsed.ErrorMessage + switch { + case parsed.ErrorMessage != "": + apiErr.ErrorMessage = parsed.ErrorMessage + case parsed.Message != "": + apiErr.ErrorMessage = parsed.Message + default: + apiErr.ErrorMessage = parsed.Error + } + enrichPlanError(apiErr, body) } else { apiErr.ErrorMessage = http.StatusText(statusCode) + enrichPlanError(apiErr, body) } return apiErr } +func enrichPlanError(apiErr *apierror.APIError, body []byte) { + var raw map[string]any + if json.Unmarshal(body, &raw) != nil { + return + } + payload := raw + if nested, ok := raw["message"].(map[string]any); ok { + payload = nested + } + + if code := stringField(payload, "errorCode"); code != "" && apiErr.ErrorCode == "" { + apiErr.ErrorCode = code + } + if message := stringField(payload, "message"); message != "" { + apiErr.ErrorMessage = message + } + + switch apiErr.ErrorCode { + case "PlanQuotaExceeded": + apiErr.ErrorMessage = formatPlanQuotaExceeded(apiErr.ErrorMessage, payload) + case "PlanFeatureDisabled": + apiErr.ErrorMessage = formatPlanFeatureDisabled(apiErr.ErrorMessage, payload) + } +} + +func formatPlanQuotaExceeded(base string, payload map[string]any) string { + parts := []string{} + if base != "" { + parts = append(parts, base) + } + label := firstNonEmpty(stringField(payload, "dimensionLabel"), stringField(payload, "dimension")) + usage, hasUsage := numberField(payload, "usage") + limit, hasLimit := numberField(payload, "limit") + if label != "" && hasUsage && hasLimit { + parts = append(parts, fmt.Sprintf("현재 사용량: %s %s/%s", label, formatNumber(usage), formatNumber(limit))) + } else if label != "" { + parts = append(parts, "제한 항목: "+label) + } + nextTier := stringField(payload, "nextTier") + nextTierLimit, hasNextTierLimit := numberField(payload, "nextTierLimit") + if nextTier != "" && hasNextTierLimit { + parts = append(parts, fmt.Sprintf("권장 플랜: %s (한도 %s)", nextTier, formatNumber(nextTierLimit))) + } else if nextTier != "" { + parts = append(parts, "권장 플랜: "+nextTier) + } + return strings.Join(parts, ". ") +} + +func formatPlanFeatureDisabled(base string, payload map[string]any) string { + parts := []string{} + if base != "" { + parts = append(parts, base) + } + label := firstNonEmpty(stringField(payload, "dimensionLabel"), stringField(payload, "feature"), stringField(payload, "dimension")) + if label != "" { + parts = append(parts, "제한 기능: "+label) + } + if nextTier := stringField(payload, "nextTier"); nextTier != "" { + parts = append(parts, "사용 가능한 플랜: "+nextTier) + } + return strings.Join(parts, ". ") +} + +func stringField(m map[string]any, key string) string { + v, ok := m[key] + if !ok { + return "" + } + switch val := v.(type) { + case nil: + return "" + case string: + return val + default: + return fmt.Sprint(val) + } +} + +func numberField(m map[string]any, key string) (float64, bool) { + v, ok := m[key] + if !ok { + return 0, false + } + switch val := v.(type) { + case float64: + return val, true + case int: + return float64(val), true + case json.Number: + n, err := val.Float64() + return n, err == nil + default: + return 0, false + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func formatNumber(n float64) string { + if n == float64(int64(n)) { + return fmt.Sprintf("%.0f", n) + } + return fmt.Sprintf("%g", n) +} + // isRetryableGET returns true for transient errors on GET requests. func isRetryableGET(err error) bool { if err == nil { diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 492091e..3332e3a 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "sync" "sync/atomic" @@ -87,6 +90,21 @@ func TestGet_Success(t *testing.T) { } } +func TestGet_SkipAuthorizationOmitsAuthorizationHeader(t *testing.T) { + c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization header: got %q, want empty", got) + } + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + }) + c.SkipAuthorization = true + + if _, err := directGet(context.Background(), c, ts.URL+"/public", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + func TestGet_WithParams(t *testing.T) { c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("key") != "value" { @@ -143,6 +161,126 @@ func TestPut_Success(t *testing.T) { } } +func TestPostMultipart_SendsFormFileAndFields(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(filePath, []byte("\x89PNG\r\n\x1a\npayload"), 0o600); err != nil { + t.Fatal(err) + } + + c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method: got %s, want POST", r.Method) + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data; boundary=") { + t.Errorf("Content-Type: got %q, want multipart/form-data with boundary", ct) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("purpose"); got != "cover" { + t.Errorf("purpose: got %q", got) + } + f, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer func() { _ = f.Close() }() + if header.Filename != "image.png" { + t.Errorf("filename: got %q", header.Filename) + } + if got := header.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("file content-type: got %q", got) + } + body, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "payload") { + t.Errorf("file body missing payload: %q", string(body)) + } + _, _ = io.WriteString(w, `{"uploaded":true}`) + }) + c.BaseURLOverride = ts.URL + + result, err := c.PostMultipart(context.Background(), "crm-core/v1/forms/FORM/images", []MultipartField{{Name: "purpose", Value: "cover"}}, MultipartFile{ + FieldName: "file", + Path: filePath, + FileName: "image.png", + ContentType: "image/png", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(result), "uploaded") { + t.Errorf("unexpected result: %s", result) + } +} + +func TestMutation_NilBodySendsNoPayload(t *testing.T) { + tests := []struct { + name string + method string + call func(context.Context, *Client, string) (json.RawMessage, error) + }{ + { + name: "post", + method: http.MethodPost, + call: func(ctx context.Context, c *Client, path string) (json.RawMessage, error) { + return c.Post(ctx, path, nil) + }, + }, + { + name: "put", + method: http.MethodPut, + call: func(ctx context.Context, c *Client, path string) (json.RawMessage, error) { + return c.Put(ctx, path, nil) + }, + }, + { + name: "patch", + method: http.MethodPatch, + call: func(ctx context.Context, c *Client, path string) (json.RawMessage, error) { + return c.Patch(ctx, path, nil) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sawMethod, sawContentType string + var sawBody []byte + c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + sawMethod = r.Method + sawContentType = r.Header.Get("Content-Type") + var err error + sawBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + c.BaseURLOverride = ts.URL + + result, err := tt.call(context.Background(), c, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(result), "ok") { + t.Errorf("unexpected result: %s", result) + } + if sawMethod != tt.method { + t.Errorf("method: got %s, want %s", sawMethod, tt.method) + } + if len(sawBody) != 0 { + t.Errorf("body: got %q, want empty", string(sawBody)) + } + if sawContentType != "" { + t.Errorf("Content-Type: got %q, want empty", sawContentType) + } + }) + } +} + func TestDelete_Success(t *testing.T) { c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { @@ -198,6 +336,74 @@ func TestGet_400_APIError(t *testing.T) { } } +func TestParseErrorResponse_MessageField(t *testing.T) { + err := parseErrorResponse(400, []byte(`{"message":"필터 형식이 올바르지 않습니다"}`)) + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.HTTPStatus != 400 { + t.Errorf("status: got %d, want 400", apiErr.HTTPStatus) + } + if apiErr.ErrorMessage != "필터 형식이 올바르지 않습니다" { + t.Errorf("message: got %q", apiErr.ErrorMessage) + } +} + +func TestParseErrorResponse_NestedPlanQuotaPayload(t *testing.T) { + err := parseErrorResponse(403, []byte(`{"message":{"errorCode":"PlanQuotaExceeded","message":"플랜 한도를 초과했습니다.","dimensionLabel":"저장소","usage":10,"limit":10,"nextTier":"PROFESSIONAL","nextTierLimit":100},"error":"Forbidden","statusCode":403}`)) + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.ErrorCode != "PlanQuotaExceeded" { + t.Fatalf("code: got %q", apiErr.ErrorCode) + } + for _, want := range []string{"플랜 한도를 초과했습니다", "현재 사용량: 저장소 10/10", "권장 플랜: PROFESSIONAL"} { + if !strings.Contains(apiErr.ErrorMessage, want) { + t.Errorf("message %q should contain %q", apiErr.ErrorMessage, want) + } + } +} + +func TestParseErrorResponse_FlatPlanFeaturePayload(t *testing.T) { + err := parseErrorResponse(403, []byte(`{"errorCode":"PlanFeatureDisabled","errorMessage":"현재 플랜에서 사용할 수 없는 기능입니다.","feature":"agent.enabled","dimensionLabel":"AI 에이전트","nextTier":"PROFESSIONAL"}`)) + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.ErrorCode != "PlanFeatureDisabled" { + t.Fatalf("code: got %q", apiErr.ErrorCode) + } + for _, want := range []string{"현재 플랜에서 사용할 수 없는 기능입니다", "제한 기능: AI 에이전트", "사용 가능한 플랜: PROFESSIONAL"} { + if !strings.Contains(apiErr.ErrorMessage, want) { + t.Errorf("message %q should contain %q", apiErr.ErrorMessage, want) + } + } +} + +func TestParseErrorResponse_PlanPayloadWithNullTierDoesNotRenderNil(t *testing.T) { + err := parseErrorResponse(403, []byte(`{"message":{"errorCode":"PlanQuotaExceeded","message":"플랜 한도를 초과했습니다.","dimensionLabel":"저장소","usage":10,"limit":10,"nextTier":null,"nextTierLimit":null},"error":"Forbidden","statusCode":403}`)) + var apiErr *apierror.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.ErrorCode != "PlanQuotaExceeded" { + t.Fatalf("code: got %q", apiErr.ErrorCode) + } + if strings.Contains(apiErr.ErrorMessage, "") { + t.Fatalf("message should not render JSON null values: %q", apiErr.ErrorMessage) + } + if strings.Contains(apiErr.ErrorMessage, "권장 플랜") { + t.Fatalf("message should omit missing recommended plan: %q", apiErr.ErrorMessage) + } + for _, want := range []string{"플랜 한도를 초과했습니다", "현재 사용량: 저장소 10/10"} { + if !strings.Contains(apiErr.ErrorMessage, want) { + t.Errorf("message %q should contain %q", apiErr.ErrorMessage, want) + } + } +} + func TestGet_401_APIError(t *testing.T) { c, ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) @@ -673,7 +879,7 @@ func TestGet_Concurrent(t *testing.T) { results := make([]json.RawMessage, goroutines) wg.Add(goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { go func(idx int) { defer wg.Done() results[idx], errs[idx] = directGet(context.Background(), c, ts.URL+"/test", nil) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0fe575b..c6c7e3b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -420,7 +420,7 @@ func TestConcurrent_SaveLoad(t *testing.T) { wg.Add(goroutines) panicCh := make(chan string, goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { if i%2 == 0 { go func(n int) { defer wg.Done() diff --git a/pkg/crm/output/format.go b/pkg/crm/output/format.go new file mode 100644 index 0000000..018e82c --- /dev/null +++ b/pkg/crm/output/format.go @@ -0,0 +1,364 @@ +// Package output formats CRM API responses for terminal display. +// +// Mirrors @solapi/crm-cli sdk/cli/src/output/formatter.ts so that solactl's +// CRM commands accept the same --format json|table|csv contract. +package output + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" +) + +// Format is one of the three supported output modes. An empty value is +// treated as the default (table) so that callers can pass the user-supplied +// flag without normalising first. +type Format string + +const ( + FormatJSON Format = "json" + FormatTable Format = "table" + FormatCSV Format = "csv" + + // arrayCellMaxRunes is the per-cell rune budget when rendering arrays + // of objects as a table. Mirrors formatter.ts:58. + arrayCellMaxRunes = 50 + // objectCellMaxRunes is the per-cell rune budget for single-object + // rendering. Mirrors formatter.ts:78. + objectCellMaxRunes = 80 +) + +// ErrInvalidFormat is returned when a format outside the known set is given. +var ErrInvalidFormat = errors.New("출력 형식은 json, table, csv 중 하나여야 합니다") + +// NormalizeFormat returns the canonical format value or ErrInvalidFormat. +// Empty strings default to table. +func NormalizeFormat(s string) (Format, error) { + switch Format(strings.ToLower(strings.TrimSpace(s))) { + case "": + return FormatTable, nil + case FormatJSON: + return FormatJSON, nil + case FormatTable: + return FormatTable, nil + case FormatCSV: + return FormatCSV, nil + } + return "", ErrInvalidFormat +} + +// Format renders raw JSON bytes (the API response) in the requested format. +// `data` may be any valid JSON document; non-JSON input falls back to a +// JSON-decode error so callers can surface a clear message. +func FormatBytes(raw []byte, format Format) (string, error) { + switch format { + case FormatJSON, "": + return prettyJSON(raw) + case FormatTable: + return formatTable(raw) + case FormatCSV: + return formatCSV(raw) + } + return "", ErrInvalidFormat +} + +func prettyJSON(raw []byte) (string, error) { + if len(raw) == 0 { + return "", nil + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return "", fmt.Errorf("응답 JSON 파싱 실패: %w", err) + } + return buf.String(), nil +} + +func formatTable(raw []byte) (string, error) { + if len(raw) == 0 { + return "", nil + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return "", fmt.Errorf("응답 JSON 파싱 실패: %w", err) + } + switch tv := v.(type) { + case []any: + if len(tv) == 0 { + return "(결과 없음)", nil + } + return arrayAsTable(tv), nil + case map[string]any: + if list, ok := tv["data"].([]any); ok { + meta := metaLine(tv) + body := arrayAsTable(list) + if meta == "" { + return body, nil + } + return body + "\n" + meta, nil + } + return objectAsTable(tv), nil + } + return fmt.Sprintf("%v", v), nil +} + +// arrayAsTable renders []any (each item assumed to be a map) as an ASCII +// table. Columns are taken from the first item's primitive (non-object) +// fields, mirroring the upstream CLI behaviour. +func arrayAsTable(items []any) string { + if len(items) == 0 { + return "(결과 없음)" + } + first, ok := items[0].(map[string]any) + if !ok { + // Mixed array of primitives: fall back to a single-column "value" table. + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{toCell(it, arrayCellMaxRunes)}) + } + return renderTable([]string{"value"}, rows) + } + + // Preserve insertion-friendly column ordering: sort keys alphabetically + // for determinism (Go map iteration order is randomised). + keys := make([]string, 0, len(first)) + for k, v := range first { + if isPrimitiveOrNil(v) { + keys = append(keys, k) + } + } + sort.Strings(keys) + + rows := make([][]string, 0, len(items)) + for _, it := range items { + row := make([]string, len(keys)) + obj, _ := it.(map[string]any) + for i, k := range keys { + row[i] = toCell(obj[k], arrayCellMaxRunes) + } + rows = append(rows, row) + } + return renderTable(keys, rows) +} + +// objectAsTable renders a single object as a two-column key→value table. +func objectAsTable(obj map[string]any) string { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + rows := make([][]string, 0, len(keys)) + for _, k := range keys { + rows = append(rows, []string{k, toCell(obj[k], objectCellMaxRunes)}) + } + return renderTable([]string{"key", "value"}, rows) +} + +// metaLine builds the pagination footer (totalCount/startKey/hasMore). +// Returns "" if no pagination keys were found. +func metaLine(m map[string]any) string { + parts := make([]string, 0, 3) + if v, ok := m["totalCount"]; ok { + parts = append(parts, fmt.Sprintf("총 %v건", v)) + } + if v, ok := m["startKey"]; ok && v != nil && v != "" { + parts = append(parts, fmt.Sprintf("다음 키: %v", v)) + } + if v, ok := m["hasMore"]; ok { + if b, isBool := v.(bool); isBool { + if b { + parts = append(parts, "다음 페이지 있음") + } else { + parts = append(parts, "마지막 페이지") + } + } + } + return strings.Join(parts, " | ") +} + +func formatCSV(raw []byte) (string, error) { + if len(raw) == 0 { + return "", nil + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return "", fmt.Errorf("응답 JSON 파싱 실패: %w", err) + } + items := coerceItems(v) + if len(items) == 0 { + return "", nil + } + first, ok := items[0].(map[string]any) + if !ok { + // Single-column dump for primitive arrays. + var b strings.Builder + b.WriteString("value\n") + for i, it := range items { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(escapeCSV(stringify(it))) + } + return b.String(), nil + } + keys := make([]string, 0, len(first)) + for k := range first { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(escapeCSV(k)) + } + for _, item := range items { + obj, _ := item.(map[string]any) + b.WriteByte('\n') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(escapeCSV(stringify(obj[k]))) + } + } + return b.String(), nil +} + +func coerceItems(v any) []any { + switch tv := v.(type) { + case []any: + return tv + case map[string]any: + if list, ok := tv["data"].([]any); ok { + return list + } + return []any{tv} + default: + return []any{tv} + } +} + +func escapeCSV(s string) string { + if strings.ContainsAny(s, ",\"\n\r") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} + +func stringify(v any) string { + if v == nil { + return "" + } + switch tv := v.(type) { + case string: + return tv + case bool: + if tv { + return "true" + } + return "false" + case float64: + // JSON numbers decode as float64. Preserve integer printing where + // the value is an integer. + if tv == float64(int64(tv)) { + return fmt.Sprintf("%d", int64(tv)) + } + return fmt.Sprintf("%g", tv) + case map[string]any, []any: + body, err := json.Marshal(tv) + if err != nil { + return fmt.Sprintf("%v", tv) + } + return string(body) + default: + return fmt.Sprintf("%v", tv) + } +} + +// toCell stringifies a value for table display, applying length truncation +// at the rune boundary so multi-byte UTF-8 (Korean) does not get cut. +func toCell(v any, maxRunes int) string { + if v == nil { + return "" + } + s := stringify(v) + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + if maxRunes <= 3 { + return string(runes[:maxRunes]) + } + return string(runes[:maxRunes-3]) + "..." +} + +func isPrimitiveOrNil(v any) bool { + switch v.(type) { + case nil, bool, string, float64: + return true + } + return false +} + +// renderTable renders headers + rows as a fixed-width ASCII table. The width +// of each column is sized to the widest cell (rune count) so multi-byte +// characters do not visually misalign. +func renderTable(headers []string, rows [][]string) string { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = displayWidth(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) { + if w := displayWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + } + + var b strings.Builder + writeRow := func(cells []string) { + for i, c := range cells { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(c) + pad := widths[i] - displayWidth(c) + if pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + } + b.WriteByte('\n') + } + writeRow(headers) + sep := make([]string, len(headers)) + for i, w := range widths { + sep[i] = strings.Repeat("-", w) + } + writeRow(sep) + for _, row := range rows { + fitted := make([]string, len(headers)) + for i := range headers { + if i < len(row) { + fitted[i] = row[i] + } + } + writeRow(fitted) + } + return strings.TrimSuffix(b.String(), "\n") +} + +// displayWidth returns a best-effort visual width using rune count. Wide +// CJK characters render double-wide in most terminals; the upstream CLI +// does not compensate either, so we keep parity. +func displayWidth(s string) int { + return len([]rune(s)) +} diff --git a/pkg/crm/output/format_test.go b/pkg/crm/output/format_test.go new file mode 100644 index 0000000..13e3d64 --- /dev/null +++ b/pkg/crm/output/format_test.go @@ -0,0 +1,236 @@ +package output + +import ( + "strings" + "testing" +) + +func TestNormalizeFormat(t *testing.T) { + cases := []struct { + in string + want Format + wantErr bool + }{ + {"", FormatTable, false}, + {"json", FormatJSON, false}, + {"JSON", FormatJSON, false}, + {" table ", FormatTable, false}, + {"csv", FormatCSV, false}, + {"yaml", "", true}, + } + for _, tc := range cases { + got, err := NormalizeFormat(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("NormalizeFormat(%q): want error, got %q", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("NormalizeFormat(%q): unexpected error %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("NormalizeFormat(%q): want %q got %q", tc.in, tc.want, got) + } + } +} + +func TestFormatBytes_JSON(t *testing.T) { + out, err := FormatBytes([]byte(`{"a":1,"b":[2,3]}`), FormatJSON) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "\"a\": 1") { + t.Errorf("expected indented JSON, got %q", out) + } +} + +func TestFormatBytes_JSONInvalidErrors(t *testing.T) { + _, err := FormatBytes([]byte(`not-json`), FormatJSON) + if err == nil { + t.Fatal("want error for invalid JSON") + } +} + +func TestFormatBytes_TableArray(t *testing.T) { + in := `[{"id":"R1","name":"가나","count":3},{"id":"R2","name":"다라","count":7}]` + out, err := FormatBytes([]byte(in), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "id") || !strings.Contains(out, "R1") || !strings.Contains(out, "R2") { + t.Errorf("unexpected table:\n%s", out) + } +} + +func TestFormatBytes_TableEmptyArray(t *testing.T) { + out, err := FormatBytes([]byte(`[]`), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if out != "(결과 없음)" { + t.Errorf("want (결과 없음), got %q", out) + } +} + +func TestFormatBytes_TablePagination(t *testing.T) { + in := `{"data":[{"id":"X"}],"totalCount":42,"startKey":"abc","hasMore":true}` + out, err := FormatBytes([]byte(in), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "총 42건") { + t.Errorf("missing totalCount: %s", out) + } + if !strings.Contains(out, "다음 키: abc") { + t.Errorf("missing startKey: %s", out) + } + if !strings.Contains(out, "다음 페이지 있음") { + t.Errorf("missing hasMore: %s", out) + } +} + +func TestFormatBytes_TableSingleObject(t *testing.T) { + out, err := FormatBytes([]byte(`{"id":"R1","name":"홍길동"}`), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "id") || !strings.Contains(out, "홍길동") { + t.Errorf("missing values:\n%s", out) + } +} + +func TestFormatBytes_TableTruncation(t *testing.T) { + long := strings.Repeat("가", 60) + in := `[{"v":"` + long + `"}]` + out, err := FormatBytes([]byte(in), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + // 50-rune budget; expect "..." and shorter than the input. + if !strings.Contains(out, "...") { + t.Errorf("expected truncation marker:\n%s", out) + } + if strings.Count(out, "가") >= 60 { + t.Errorf("not truncated:\n%s", out) + } +} + +func TestFormatBytes_TableSkipsNestedFields(t *testing.T) { + // Nested object/array fields are excluded from columns; primitive ones kept. + in := `[{"id":"R1","tags":["a","b"],"meta":{"x":1},"count":7}]` + out, err := FormatBytes([]byte(in), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "id") || !strings.Contains(out, "count") { + t.Errorf("primitive cols missing:\n%s", out) + } + if strings.Contains(out, "tags") || strings.Contains(out, "meta") { + t.Errorf("nested cols should be skipped:\n%s", out) + } +} + +func TestFormatBytes_CSVBasic(t *testing.T) { + in := `[{"id":"R1","name":"홍"},{"id":"R2","name":"김"}]` + out, err := FormatBytes([]byte(in), FormatCSV) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + lines := strings.Split(out, "\n") + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d:\n%s", len(lines), out) + } + if lines[0] != "id,name" { + t.Errorf("header: %q", lines[0]) + } +} + +func TestFormatBytes_CSVEscapes(t *testing.T) { + in := `[{"v":"a,b"},{"v":"q\"q"},{"v":"line\nbreak"}]` + out, err := FormatBytes([]byte(in), FormatCSV) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, `"a,b"`) { + t.Errorf("comma escape missing: %s", out) + } + if !strings.Contains(out, `"q""q"`) { + t.Errorf("quote escape missing: %s", out) + } + if !strings.Contains(out, "\"line\nbreak\"") { + t.Errorf("newline escape missing: %s", out) + } +} + +func TestFormatBytes_CSVPaginatedEnvelope(t *testing.T) { + in := `{"data":[{"id":"R1"}],"totalCount":99}` + out, err := FormatBytes([]byte(in), FormatCSV) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + // CSV must drill into data[]; totalCount must NOT leak as a column. + if !strings.Contains(out, "id") || strings.Contains(out, "totalCount") { + t.Errorf("envelope handling:\n%s", out) + } +} + +func TestFormatBytes_CSVNumberFormatting(t *testing.T) { + out, err := FormatBytes([]byte(`[{"n":1,"f":1.5}]`), FormatCSV) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + // Integer-valued JSON numbers must print without decimal. + if !strings.Contains(out, "1,1.5") && !strings.Contains(out, "f,n") { + t.Errorf("number formatting:\n%s", out) + } +} + +func TestFormatBytes_EmptyInput(t *testing.T) { + for _, f := range []Format{FormatJSON, FormatTable, FormatCSV} { + got, err := FormatBytes(nil, f) + if err != nil { + t.Errorf("[%s] err: %v", f, err) + } + if got != "" { + t.Errorf("[%s] want empty, got %q", f, got) + } + } +} + +func TestFormatBytes_NullJSON(t *testing.T) { + // API client returns json.RawMessage("null") for empty 2xx bodies. + out, err := FormatBytes([]byte(`null`), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + // Should not panic; rendering "null" as a stringified primitive is acceptable. + if out == "" { + t.Errorf("empty rendering for null is suspicious") + } +} + +func TestFormatBytes_PaginationFalseHasMore(t *testing.T) { + in := `{"data":[{"id":"X"}],"hasMore":false}` + out, err := FormatBytes([]byte(in), FormatTable) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if !strings.Contains(out, "마지막 페이지") { + t.Errorf("hasMore=false missing terminal indicator:\n%s", out) + } +} + +func TestFormatBytes_HeterogeneousArrayFirstKeyOnly(t *testing.T) { + // Documented limitation: only first item's keys form the header. Second + // item's "extra" field is silently dropped. + in := `[{"id":"R1"},{"id":"R2","extra":"lost"}]` + out, err := FormatBytes([]byte(in), FormatCSV) + if err != nil { + t.Fatalf("FormatBytes: %v", err) + } + if strings.Contains(out, "extra") || strings.Contains(out, "lost") { + t.Errorf("heterogeneous fields must not leak into CSV:\n%s", out) + } +} diff --git a/pkg/crm/spec/cache.go b/pkg/crm/spec/cache.go new file mode 100644 index 0000000..a9ef733 --- /dev/null +++ b/pkg/crm/spec/cache.go @@ -0,0 +1,181 @@ +package spec + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sync" + "time" +) + +// CacheTTL is the default TTL for cached OpenAPI specs (1 hour). Mirrors +// solapi-crm-cli sdk/cli/src/spec/cache.ts:6. +const CacheTTL = 60 * time.Minute + +// CacheDirEnv lets callers override the cache directory location, primarily +// for tests. Production code should leave this empty. +const CacheDirEnv = "SOLACTL_CACHE_DIR" + +// nowFunc is overridable so tests can drive TTL boundaries without sleeping. +var nowFunc = time.Now + +// safeKeyRe matches characters that must be replaced for safe filenames. +var safeKeyRe = regexp.MustCompile(`[^a-zA-Z0-9\-_]`) + +// cacheMu serialises read/write operations against the cache directory so +// concurrent CLI invocations on the same machine cannot tear writes. +var cacheMu sync.Mutex + +// cacheEntry is the on-disk envelope. +type cacheEntry struct { + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` // unix milliseconds +} + +// cacheDir resolves the cache directory, honouring SOLACTL_CACHE_DIR for +// tests. Falls back to ~/.solactl/cache. +func cacheDir() (string, error) { + if v := os.Getenv(CacheDirEnv); v != "" { + return v, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("홈 디렉토리를 찾을 수 없습니다: %w", err) + } + return filepath.Join(home, ".solactl", "cache"), nil +} + +func cachePath(key string) (string, error) { + dir, err := cacheDir() + if err != nil { + return "", err + } + safe := safeKeyRe.ReplaceAllString(key, "_") + return filepath.Join(dir, safe+".json"), nil +} + +func ensureCacheDir() (string, error) { + dir, err := cacheDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("캐시 디렉토리 생성 실패: %w", err) + } + return dir, nil +} + +// GetCached returns the cached value for `key`. It returns (nil, nil) if the +// entry is missing, malformed, or expired (unless ignoreTTL is true). +// +// Callers receive a *fresh* json.RawMessage so they can decode into any type. +func GetCached(key string, ignoreTTL bool) (json.RawMessage, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + return getCachedLocked(key, ignoreTTL) +} + +func getCachedLocked(key string, ignoreTTL bool) (json.RawMessage, error) { + path, err := cachePath(key) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, nil // Treat read errors as miss; the loader will refetch. + } + var entry cacheEntry + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, nil // Corrupted → miss. + } + if !ignoreTTL { + ageMs := nowFunc().UnixMilli() - entry.Timestamp + if ageMs > int64(CacheTTL/time.Millisecond) { + return nil, nil // Expired. + } + } + return entry.Data, nil +} + +// SetCache writes the value to disk under `key`. The write is atomic +// (tmp + rename) to avoid torn reads under concurrent invocations. +func SetCache(key string, data json.RawMessage) error { + cacheMu.Lock() + defer cacheMu.Unlock() + + dir, err := ensureCacheDir() + if err != nil { + return err + } + path, err := cachePath(key) + if err != nil { + return err + } + + entry := cacheEntry{Data: data, Timestamp: nowFunc().UnixMilli()} + body, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("캐시 직렬화 실패: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".cache-*.tmp") + if err != nil { + return fmt.Errorf("캐시 임시 파일 생성 실패: %w", err) + } + tmpPath := tmp.Name() + if _, writeErr := tmp.Write(body); writeErr != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("캐시 쓰기 실패: %w", writeErr) + } + if chmodErr := tmp.Chmod(0o600); chmodErr != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return chmodErr + } + if closeErr := tmp.Close(); closeErr != nil { + _ = os.Remove(tmpPath) + return closeErr + } + if renameErr := os.Rename(tmpPath, path); renameErr != nil { + _ = os.Remove(tmpPath) + return renameErr + } + return nil +} + +// ClearCache removes every file under the cache directory. Idempotent: a +// missing directory is treated as success (mirrors clear-cache no-op). +func ClearCache() error { + cacheMu.Lock() + defer cacheMu.Unlock() + + dir, err := cacheDir() + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("캐시 디렉토리 읽기 실패: %w", err) + } + for _, e := range entries { + if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { + return fmt.Errorf("캐시 파일 삭제 실패: %w", err) + } + } + return nil +} + +// CacheDirPath returns the resolved cache directory for diagnostics. +func CacheDirPath() (string, error) { + return cacheDir() +} diff --git a/pkg/crm/spec/cache_test.go b/pkg/crm/spec/cache_test.go new file mode 100644 index 0000000..425587c --- /dev/null +++ b/pkg/crm/spec/cache_test.go @@ -0,0 +1,198 @@ +package spec + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" +) + +// withTempCache redirects the cache to a t.TempDir and resets nowFunc. +// Returns a function that drives the clock to a fixed instant. +func withTempCache(t *testing.T) (dir string, setNow func(time.Time)) { + t.Helper() + dir = t.TempDir() + t.Setenv(CacheDirEnv, dir) + original := nowFunc + t.Cleanup(func() { nowFunc = original }) + setNow = func(at time.Time) { nowFunc = func() time.Time { return at } } + setNow(time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)) + return dir, setNow +} + +func TestCache_RoundTripWithinTTL(t *testing.T) { + _, setNow := withTempCache(t) + payload := json.RawMessage(`{"openapi":"3.0.0","paths":{}}`) + if err := SetCache("openapi-spec-solapi", payload); err != nil { + t.Fatalf("SetCache: %v", err) + } + setNow(time.Date(2026, 5, 1, 12, 30, 0, 0, time.UTC)) // +30 min, within TTL + got, err := GetCached("openapi-spec-solapi", false) + if err != nil { + t.Fatalf("GetCached: %v", err) + } + if string(got) != string(payload) { + t.Errorf("payload mismatch: %s", string(got)) + } +} + +func TestCache_ExpiredReturnsNil(t *testing.T) { + _, setNow := withTempCache(t) + if err := SetCache("openapi-spec-solapi", json.RawMessage(`{"x":1}`)); err != nil { + t.Fatalf("SetCache: %v", err) + } + setNow(time.Date(2026, 5, 1, 14, 0, 1, 0, time.UTC)) // +2h + got, err := GetCached("openapi-spec-solapi", false) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Errorf("want nil for expired entry, got %s", string(got)) + } +} + +func TestCache_IgnoreTTLReturnsStale(t *testing.T) { + _, setNow := withTempCache(t) + payload := json.RawMessage(`{"x":2}`) + if err := SetCache("openapi-spec-solapi", payload); err != nil { + t.Fatalf("SetCache: %v", err) + } + setNow(time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)) // way past TTL + got, err := GetCached("openapi-spec-solapi", true) + if err != nil { + t.Fatalf("err: %v", err) + } + if string(got) != string(payload) { + t.Errorf("ignoreTTL must return stale value, got %s", string(got)) + } +} + +func TestCache_MissingFileIsNotAnError(t *testing.T) { + withTempCache(t) + got, err := GetCached("nonexistent-key", false) + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if got != nil { + t.Errorf("want nil for missing key, got %s", string(got)) + } +} + +func TestCache_CorruptedFileTreatedAsMiss(t *testing.T) { + dir, _ := withTempCache(t) + // Write a file directly that fails JSON parse. + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(dir, "openapi-spec-solapi.json") + if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := GetCached("openapi-spec-solapi", false) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Errorf("corrupted entry must be miss, got %s", string(got)) + } +} + +func TestCache_KeySanitization(t *testing.T) { + dir, _ := withTempCache(t) + if err := SetCache("openapi/spec:weird key", json.RawMessage(`{"k":1}`)); err != nil { + t.Fatalf("SetCache: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("want 1 file, got %d", len(entries)) + } + if name := entries[0].Name(); name != "openapi_spec_weird_key.json" { + t.Errorf("unexpected sanitized filename: %q", name) + } +} + +func TestCache_FilePermissionsRestricted(t *testing.T) { + dir, _ := withTempCache(t) + if err := SetCache("k", json.RawMessage(`{}`)); err != nil { + t.Fatalf("SetCache: %v", err) + } + st, err := os.Stat(filepath.Join(dir, "k.json")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := st.Mode().Perm(); mode != 0o600 { + t.Errorf("want perm 0o600, got %v", mode) + } +} + +func TestCache_ClearIsIdempotent(t *testing.T) { + withTempCache(t) + // Empty (no dir created yet) must succeed. + if err := ClearCache(); err != nil { + t.Fatalf("first clear: %v", err) + } + // After write + clear, second clear is also fine. + if err := SetCache("k", json.RawMessage(`{}`)); err != nil { + t.Fatalf("SetCache: %v", err) + } + if err := ClearCache(); err != nil { + t.Fatalf("clear: %v", err) + } + if err := ClearCache(); err != nil { + t.Fatalf("second clear: %v", err) + } +} + +func TestCache_ClearRemovesAllFiles(t *testing.T) { + dir, _ := withTempCache(t) + for _, k := range []string{"a", "b", "c"} { + if err := SetCache(k, json.RawMessage(`{}`)); err != nil { + t.Fatalf("SetCache(%q): %v", k, err) + } + } + if err := ClearCache(); err != nil { + t.Fatalf("ClearCache: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(entries) != 0 { + t.Errorf("want empty dir, got %d entries", len(entries)) + } +} + +func TestCache_ConcurrentSetsDoNotCorrupt(t *testing.T) { + withTempCache(t) + var wg sync.WaitGroup + for i := range 16 { + wg.Go(func() { + payload := json.RawMessage(`{"i":` + strconv.Itoa(i) + `}`) + if err := SetCache("concurrent", payload); err != nil { + t.Errorf("SetCache: %v", err) + } + }) + } + wg.Wait() + // Final read must yield a *valid* JSON envelope (no torn write). + got, err := GetCached("concurrent", false) + if err != nil { + t.Fatalf("GetCached: %v", err) + } + if got == nil { + t.Fatal("got nil after concurrent writes") + } + var probe map[string]int + if err := json.Unmarshal(got, &probe); err != nil { + t.Fatalf("decoded payload not parseable: %v (%s)", err, string(got)) + } + if _, ok := probe["i"]; !ok { + t.Errorf("want key 'i' in payload, got %s", string(got)) + } +} diff --git a/pkg/crm/spec/loader.go b/pkg/crm/spec/loader.go new file mode 100644 index 0000000..501b5a2 --- /dev/null +++ b/pkg/crm/spec/loader.go @@ -0,0 +1,132 @@ +package spec + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// DefaultSpecURL is the upstream OpenAPI spec endpoint. Mirrors +// solapi-crm-cli sdk/cli/src/utils/env.ts:6. +const DefaultSpecURL = "https://api.solapi.com/crm-core/v1/public/openapi/json" + +// CacheKey is the on-disk cache key for the (single) environment. +const CacheKey = "openapi-spec-solapi" + +// HTTPDoer is the subset of *http.Client used by the loader. Tests pass a +// custom doer to drive failure scenarios. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Loader fetches the CRM OpenAPI spec and persists it on disk. +type Loader struct { + URL string // empty → DefaultSpecURL + Client HTTPDoer // nil → http.DefaultClient + // StaleWarn receives a single-line warning when stale fallback kicks in. + // Production wires this to stderr; tests inspect the captured value. + StaleWarn func(string) +} + +// Load returns a parsed OpenAPI spec, preferring the on-disk cache. If the +// cache is missing or expired, the loader fetches over HTTP. On HTTP failure +// it falls back to a stale cache (if any) with a warning to StaleWarn. +// +// `forceRefresh` skips the fresh cache lookup but still allows stale fallback +// — matches loader.ts:49-79 semantics. +func (l *Loader) Load(ctx context.Context, forceRefresh bool) (*OpenApiSpec, error) { + if !forceRefresh { + if raw, _ := GetCached(CacheKey, false); raw != nil { + spec, err := parseSpec(raw) + if err == nil { + return spec, nil + } + // Cached blob looked like JSON but didn't satisfy the spec shape. + // Fall through to fetch — do not surface the stale entry. + } + } + + specURL := l.URL + if specURL == "" { + specURL = DefaultSpecURL + } + doer := l.Client + if doer == nil { + doer = http.DefaultClient + } + + raw, fetchErr := doFetch(ctx, doer, specURL) + if fetchErr == nil { + spec, err := parseSpec(raw) + if err != nil { + return l.tryStale(err) + } + // Persist the *raw* bytes so subsequent loads do not re-encode. + if err := SetCache(CacheKey, raw); err != nil { + // Cache write failures must not block command execution. + if l.StaleWarn != nil { + l.StaleWarn("⚠ OpenAPI spec 캐시 저장 실패: " + err.Error()) + } + } + return spec, nil + } + return l.tryStale(fetchErr) +} + +func (l *Loader) tryStale(cause error) (*OpenApiSpec, error) { + stale, _ := GetCached(CacheKey, true) + if stale != nil { + spec, err := parseSpec(stale) + if err == nil { + if l.StaleWarn != nil { + l.StaleWarn("⚠ OpenAPI spec을 갱신할 수 없어 캐시된 버전을 사용합니다.") + } + return spec, nil + } + } + return nil, cause +} + +func doFetch(ctx context.Context, doer HTTPDoer, url string) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("OpenAPI spec 요청 생성 실패: %w", err) + } + req.Header.Set("Accept", "application/json") + + // Apply a soft per-request timeout so that a hung server cannot block the + // CLI indefinitely. Caller-supplied ctx still wins if it's tighter. + ctxFetch, cancel := context.WithTimeout(req.Context(), 30*time.Second) + defer cancel() + req = req.WithContext(ctxFetch) + + resp, err := doer.Do(req) + if err != nil { + return nil, fmt.Errorf("OpenAPI spec 로딩 실패: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("OpenAPI spec 로딩 실패 (%d): %s", resp.StatusCode, url) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("OpenAPI spec 응답 읽기 실패: %w", err) + } + return body, nil +} + +func parseSpec(raw json.RawMessage) (*OpenApiSpec, error) { + var spec OpenApiSpec + if err := json.Unmarshal(raw, &spec); err != nil { + return nil, fmt.Errorf("OpenAPI spec 파싱 실패: %w", err) + } + if spec.Paths == nil { + return nil, errors.New("유효하지 않은 OpenAPI spec: paths 필드가 없습니다") + } + return &spec, nil +} diff --git a/pkg/crm/spec/loader_test.go b/pkg/crm/spec/loader_test.go new file mode 100644 index 0000000..bf6adcd --- /dev/null +++ b/pkg/crm/spec/loader_test.go @@ -0,0 +1,170 @@ +package spec + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestLoader_FetchesAndCaches(t *testing.T) { + withTempCache(t) + body := `{"openapi":"3.0.0","info":{"title":"crm","version":"1"},"paths":{"/crm-core/v1/records":{"get":{"summary":"list"}}}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + loader := &Loader{URL: srv.URL} + spec, err := loader.Load(context.Background(), false) + if err != nil { + t.Fatalf("Load: %v", err) + } + if spec == nil || len(spec.Paths) != 1 { + t.Fatalf("unexpected spec: %+v", spec) + } + + // Cache should now be populated; second Load must not hit network. + srv.Close() + spec2, err := loader.Load(context.Background(), false) + if err != nil { + t.Fatalf("second Load: %v", err) + } + if spec2 == nil || len(spec2.Paths) != 1 { + t.Fatalf("cached spec missing: %+v", spec2) + } +} + +func TestLoader_StaleFallbackOnNetworkFailure(t *testing.T) { + withTempCache(t) + good := `{"openapi":"3.0.0","info":{"title":"crm","version":"1"},"paths":{"/crm-core/v1/x":{"get":{"summary":"x"}}}}` + if err := SetCache(CacheKey, json.RawMessage(good)); err != nil { + t.Fatalf("seed: %v", err) + } + // Drive clock past TTL so the cache is "stale". + nowFunc = func() time.Time { return time.Now().Add(2 * time.Hour) } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + var warning string + loader := &Loader{URL: srv.URL, StaleWarn: func(s string) { warning = s }} + spec, err := loader.Load(context.Background(), true) + if err != nil { + t.Fatalf("expected stale fallback, got error: %v", err) + } + if spec == nil || len(spec.Paths) != 1 { + t.Fatalf("stale spec missing: %+v", spec) + } + if !strings.Contains(warning, "캐시된 버전") { + t.Errorf("StaleWarn not invoked or wrong message: %q", warning) + } +} + +func TestLoader_NetworkFailWithNoCacheReturnsError(t *testing.T) { + withTempCache(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + t.Cleanup(srv.Close) + + loader := &Loader{URL: srv.URL} + _, err := loader.Load(context.Background(), false) + if err == nil { + t.Fatal("want error when fetch fails and no cache exists") + } + if !strings.Contains(err.Error(), "OpenAPI spec 로딩 실패") { + t.Errorf("unexpected err: %v", err) + } +} + +func TestLoader_RejectsSpecWithoutPaths(t *testing.T) { + withTempCache(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"openapi":"3.0.0","info":{"title":"x","version":"1"}}`)) + })) + t.Cleanup(srv.Close) + + loader := &Loader{URL: srv.URL} + _, err := loader.Load(context.Background(), false) + if err == nil || !strings.Contains(err.Error(), "paths") { + t.Fatalf("want paths-missing error, got %v", err) + } +} + +func TestLoader_ContextCancellationStopsFetch(t *testing.T) { + withTempCache(t) + // Server hangs forever; ctx cancellation must propagate. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + loader := &Loader{URL: srv.URL} + _, err := loader.Load(ctx, false) + if err == nil { + t.Fatal("want error when ctx cancelled") + } +} + +func TestLoader_ForceRefreshSkipsFreshCache(t *testing.T) { + withTempCache(t) + if err := SetCache(CacheKey, json.RawMessage(`{"openapi":"3.0.0","info":{"title":"x","version":"1"},"paths":{"/crm-core/v1/cached":{"get":{"summary":"c"}}}}`)); err != nil { + t.Fatalf("seed: %v", err) + } + // Clock is at the original "now"; cache is fresh. Force refresh must hit + // the server even so. + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"openapi":"3.0.0","info":{"title":"x","version":"1"},"paths":{"/crm-core/v1/fresh":{"get":{"summary":"f"}}}}`)) + })) + t.Cleanup(srv.Close) + + loader := &Loader{URL: srv.URL} + spec, err := loader.Load(context.Background(), true) + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := spec.Paths["/crm-core/v1/fresh"]; !ok { + t.Errorf("force-refresh did not hit server, got %#v", spec.Paths) + } + if atomic.LoadInt32(&hits) != 1 { + t.Errorf("expected exactly 1 server hit, got %d", hits) + } +} + +// errDoer is an HTTPDoer that always fails. Drives the path where the request +// itself fails before reaching the server. +type errDoer struct{} + +func (errDoer) Do(_ *http.Request) (*http.Response, error) { + return nil, errors.New("dial error") +} + +func TestLoader_DoerErrorReachesStaleFallback(t *testing.T) { + withTempCache(t) + good := `{"openapi":"3.0.0","info":{"title":"x","version":"1"},"paths":{"/crm-core/v1/x":{"get":{"summary":"x"}}}}` + if err := SetCache(CacheKey, json.RawMessage(good)); err != nil { + t.Fatalf("seed: %v", err) + } + nowFunc = func() time.Time { return time.Now().Add(48 * time.Hour) } + + loader := &Loader{Client: errDoer{}, StaleWarn: func(string) {}} + spec, err := loader.Load(context.Background(), true) + if err != nil { + t.Fatalf("expected stale fallback on doer err, got %v", err) + } + if spec == nil { + t.Fatal("nil spec") + } +} diff --git a/pkg/crm/spec/mapper.go b/pkg/crm/spec/mapper.go new file mode 100644 index 0000000..6f971e9 --- /dev/null +++ b/pkg/crm/spec/mapper.go @@ -0,0 +1,261 @@ +package spec + +import ( + "sort" + "strconv" + "strings" +) + +// PathPrefix is the path prefix that mapper accepts. Paths outside this +// prefix are silently skipped (mirrors mapper.ts:31-34). +const PathPrefix = "/crm-core/v1/" + +var actionByMethod = map[string]string{ + "get": "get", + "post": "create", + "patch": "update", + "put": "replace", + "delete": "delete", +} + +// MapSpec extracts MappedCommand entries from an OpenAPI spec. Iteration +// order is deterministic (paths sorted) so duplicate-action suffixing is +// reproducible across runs and tests. +func MapSpec(spec *OpenApiSpec) []MappedCommand { + if spec == nil || len(spec.Paths) == 0 { + return nil + } + + paths := make([]string, 0, len(spec.Paths)) + for p := range spec.Paths { + paths = append(paths, p) + } + sort.Strings(paths) + + var commands []MappedCommand + for _, p := range paths { + if !strings.HasPrefix(p, PathPrefix) { + continue + } + relative := strings.TrimPrefix(p, PathPrefix) + segments := strings.Split(relative, "/") + if len(segments) == 0 || segments[0] == "" { + continue + } + resource := segments[0] + + methods := sortedMethods(spec.Paths[p]) + for _, method := range methods { + op := spec.Paths[p][method] + if !isOperation(op) { + continue + } + if isExcludedOperation(method, p) { + continue + } + action := deriveAction(method, segments, op) + + pathParams := make([]ParameterObject, 0, len(op.Parameters)) + queryParams := make([]ParameterObject, 0, len(op.Parameters)) + for _, param := range op.Parameters { + switch param.In { + case "path": + pathParams = append(pathParams, param) + case "query": + queryParams = append(queryParams, param) + } + } + + commands = append(commands, MappedCommand{ + Resource: resource, + Action: action, + Method: strings.ToUpper(method), + Path: p, + Summary: op.Summary, + Tags: op.Tags, + PathParams: pathParams, + QueryParams: queryParams, + HasBody: op.RequestBody != nil, + BodyRequired: op.RequestBody != nil && op.RequestBody.Required, + }) + } + } + + dedupeActions(commands) + return commands +} + +var excludedOperations = map[string]struct{}{ + "post /crm-core/v1/agent/files": {}, + "post /crm-core/v1/contents/{contentId}/images": {}, + "post /crm-core/v1/document-templates/{templateId}/versions/{versionId}/attachments": {}, + "post /crm-core/v1/documents/{documentId}/attachments": {}, + "post /crm-core/v1/forms/{formId}/images": {}, + "post /crm-core/v1/message-templates/{messageTemplateId}/image": {}, + "post /crm-core/v1/records/import/excel": {}, + "post /crm-core/v1/records/import/excel/extract-columns": {}, + "post /crm-core/v1/records/import/excel/preview": {}, + "post /crm-core/v1/records/{recordId}/attachments": {}, + "post /crm-core/v1/records/{recordId}/images": {}, + "post /crm-core/v1/records/{recordId}/profile-image": {}, + "post /crm-core/v1/sdk/forms/{publicToken}/upload": {}, + + "delete /crm-core/v1/plans/me/scheduled-change": {}, + "post /crm-core/v1/plans/me/cancel": {}, + "post /crm-core/v1/plans/me/downgrade": {}, + "post /crm-core/v1/plans/me/retry-payment": {}, + "post /crm-core/v1/plans/me/subscribe": {}, + "post /crm-core/v1/plans/me/upgrade": {}, + "put /crm-core/v1/plans/me": {}, + "put /crm-core/v1/plans/me/overage-settings": {}, + "put /crm-core/v1/plans/me/trial/underlying": {}, +} + +func isExcludedOperation(method, path string) bool { + _, ok := excludedOperations[strings.ToLower(method)+" "+path] + return ok +} + +// isOperation distinguishes a real OpenAPI operation entry from a zero-valued +// struct produced by JSON keys that are not HTTP methods (e.g. "parameters" +// at the PathItem level, or empty `{}` placeholders). +func isOperation(o OperationObject) bool { + return o.OperationID != "" || o.Summary != "" || o.Description != "" || + len(o.Tags) > 0 || o.Parameters != nil || o.RequestBody != nil || + o.Responses != nil +} + +func sortedMethods(item PathItem) []string { + out := make([]string, 0, len(item)) + for m := range item { + out = append(out, m) + } + sort.Strings(out) + return out +} + +// dedupeActions disambiguates same (resource, action) entries by appending +// a sub-path suffix, then a counter when the suffix is empty. +func dedupeActions(commands []MappedCommand) { + seen := make(map[string]int) + for i := range commands { + cmd := &commands[i] + key := cmd.Resource + ":" + cmd.Action + count := seen[key] + if count > 0 { + suffix := extractActionSuffix(cmd.Path) + if suffix != "" { + cmd.Action = cmd.Action + "-" + suffix + } else { + cmd.Action = cmd.Action + "-" + strconv.Itoa(count+1) + } + } + seen[key] = count + 1 + } +} + +// extractActionSuffix joins the static (non-parameter) segments past the +// resource with '-'. Examples: +// +// /crm-core/v1/records/trash -> "trash" +// /crm-core/v1/records/bulk/restore -> "bulk-restore" +// /crm-core/v1/records/search/fulltext/{entityId} -> "search-fulltext" +func extractActionSuffix(path string) string { + relative := strings.TrimPrefix(path, PathPrefix) + segments := strings.Split(relative, "/") + if len(segments) <= 1 { + return "" + } + staticParts := make([]string, 0, len(segments)-1) + for _, s := range segments[1:] { + if s == "" || strings.HasPrefix(s, "{") || strings.HasPrefix(s, ":") { + continue + } + staticParts = append(staticParts, s) + } + return strings.Join(staticParts, "-") +} + +// deriveAction maps (method, segments) to a CLI action name. +func deriveAction(method string, segments []string, op OperationObject) string { + method = strings.ToLower(method) + baseAction, ok := actionByMethod[method] + if !ok { + baseAction = method + } + + subSegments := segments[1:] + staticParts := make([]string, 0, len(subSegments)) + for _, s := range subSegments { + if s == "" { + continue + } + if strings.HasPrefix(s, "{") || strings.HasPrefix(s, ":") { + continue + } + staticParts = append(staticParts, s) + } + + var lastSegment string + if len(segments) > 0 { + lastSegment = segments[len(segments)-1] + } + isLastParam := strings.HasPrefix(lastSegment, "{") || strings.HasPrefix(lastSegment, ":") + + switch method { + case "get": + if len(segments) == 1 { + return "list" + } + if isLastParam && len(staticParts) == 0 { + return "get" + } + suffix := strings.Join(staticParts, "-") + if suffix == "" { + return "get" + } + return "list-" + suffix + case "post": + if len(segments) == 1 { + return "create" + } + if len(staticParts) > 0 { + return strings.Join(staticParts, "-") + } + return "create" + } + + // PATCH/PUT/DELETE: prefer operationId of form "verb_name". + if op.OperationID != "" { + parts := strings.Split(op.OperationID, "_") + if len(parts) > 1 { + return strings.Join(parts[1:], "-") + } + } + return baseAction +} + +// Resources returns the sorted, de-duplicated list of resource names. +func Resources(commands []MappedCommand) []string { + seen := make(map[string]struct{}, len(commands)) + for _, c := range commands { + seen[c.Resource] = struct{}{} + } + out := make([]string, 0, len(seen)) + for r := range seen { + out = append(out, r) + } + sort.Strings(out) + return out +} + +// CommandsForResource filters commands by resource, preserving original order. +func CommandsForResource(commands []MappedCommand, resource string) []MappedCommand { + out := make([]MappedCommand, 0) + for _, c := range commands { + if c.Resource == resource { + out = append(out, c) + } + } + return out +} diff --git a/pkg/crm/spec/mapper_test.go b/pkg/crm/spec/mapper_test.go new file mode 100644 index 0000000..52b1e75 --- /dev/null +++ b/pkg/crm/spec/mapper_test.go @@ -0,0 +1,216 @@ +package spec + +import ( + "testing" +) + +func TestMapSpec_BasicPatterns(t *testing.T) { + tests := []struct { + name string + path string + method string + op OperationObject + wantResource string + wantAction string + wantPathParams int + }{ + {"GET list", "/crm-core/v1/records", "get", OperationObject{Summary: "list"}, "records", "list", 0}, + {"GET get by id", "/crm-core/v1/records/{id}", "get", OperationObject{Summary: "get"}, "records", "get", 0}, + {"GET sub list", "/crm-core/v1/records/{id}/logs", "get", OperationObject{Summary: "logs"}, "records", "list-logs", 0}, + {"GET trash", "/crm-core/v1/records/trash", "get", OperationObject{Summary: "trash"}, "records", "list-trash", 0}, + {"GET search/fulltext/{id}", "/crm-core/v1/records/search/fulltext/{id}", "get", OperationObject{Summary: "fts"}, "records", "list-search-fulltext", 0}, + {"POST create", "/crm-core/v1/records", "post", OperationObject{Summary: "create"}, "records", "create", 0}, + {"POST bulk-delete", "/crm-core/v1/records/bulk/delete", "post", OperationObject{Summary: "bulk"}, "records", "bulk-delete", 0}, + {"POST restore", "/crm-core/v1/records/{id}/restore", "post", OperationObject{Summary: "restore"}, "records", "restore", 0}, + {"PUT replace", "/crm-core/v1/records/{id}", "put", OperationObject{Summary: "replace"}, "records", "replace", 0}, + {"DELETE delete", "/crm-core/v1/records/{id}", "delete", OperationObject{Summary: "delete"}, "records", "delete", 0}, + {"PATCH update", "/crm-core/v1/records/{id}", "patch", OperationObject{Summary: "patch"}, "records", "update", 0}, + {"PATCH operationId verb_name", "/crm-core/v1/records/{id}", "patch", OperationObject{Summary: "patch", OperationID: "patch_renameRecord"}, "records", "renameRecord", 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{tc.path: {tc.method: tc.op}}} + cmds := MapSpec(spec) + if len(cmds) != 1 { + t.Fatalf("want 1 cmd, got %d", len(cmds)) + } + cmd := cmds[0] + if cmd.Resource != tc.wantResource { + t.Errorf("resource: want %q got %q", tc.wantResource, cmd.Resource) + } + if cmd.Action != tc.wantAction { + t.Errorf("action: want %q got %q", tc.wantAction, cmd.Action) + } + }) + } +} + +func TestMapSpec_SkipsNonPrefixPaths(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/messages/v4/messages": {"get": OperationObject{Summary: "x"}}, + "/crm-core/v1/records": {"get": OperationObject{Summary: "x"}}, + }} + cmds := MapSpec(spec) + if len(cmds) != 1 || cmds[0].Resource != "records" { + t.Fatalf("expected only the crm-core path mapped, got %#v", cmds) + } +} + +func TestMapSpec_SkipsStaticUploadAndPlanChangeOperations(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records/import/excel": {"post": OperationObject{Summary: "excel upload"}}, + "/crm-core/v1/plans/me/upgrade": {"post": OperationObject{Summary: "upgrade"}}, + "/crm-core/v1/records": {"get": OperationObject{Summary: "list"}}, + }} + cmds := MapSpec(spec) + if len(cmds) != 1 { + t.Fatalf("want only the safe JSON/list command, got %#v", cmds) + } + if cmds[0].Resource != "records" || cmds[0].Action != "list" { + t.Fatalf("unexpected remaining command: %#v", cmds[0]) + } +} + +func TestMapSpec_DuplicateActionsDisambiguated(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records": {"get": OperationObject{Summary: "list"}}, + "/crm-core/v1/records/recent": {"get": OperationObject{Summary: "recent"}}, + "/crm-core/v1/records/{id}": {"get": OperationObject{Summary: "get"}}, + "/crm-core/v1/records/{id}/logs": {"get": OperationObject{Summary: "logs"}}, + "/crm-core/v1/records/{id}/history": {"get": OperationObject{Summary: "history"}}, + }} + cmds := MapSpec(spec) + actions := make(map[string]int) + for _, c := range cmds { + actions[c.Action]++ + } + for action, count := range actions { + if count != 1 { + t.Errorf("action %q duplicated %d times", action, count) + } + } +} + +func TestMapSpec_DuplicateActionFallsBackToCounter(t *testing.T) { + // Two operations on the same path-with-only-id, same method → same + // extracted suffix is empty → counter should kick in. + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records/{id}": {"patch": OperationObject{Summary: "a"}}, + "/crm-core/v1/records/{name}": {"patch": OperationObject{Summary: "b"}}, + }} + cmds := MapSpec(spec) + if len(cmds) != 2 { + t.Fatalf("want 2 cmds, got %d", len(cmds)) + } + a, b := cmds[0].Action, cmds[1].Action + if a == b { + t.Errorf("duplicate-action suffixing failed; both %q", a) + } + // One of them must be "update", the other suffixed. + if a != "update" && b != "update" { + t.Errorf("expected one to remain 'update'; got %q and %q", a, b) + } +} + +func TestMapSpec_PathQuerySplit(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records/{id}/related/{related}": { + "get": OperationObject{ + Summary: "related", + Parameters: []ParameterObject{ + {Name: "id", In: "path", Required: true}, + {Name: "related", In: "path", Required: true}, + {Name: "limit", In: "query"}, + {Name: "X-Trace", In: "header"}, + }, + }, + }, + }} + cmds := MapSpec(spec) + if len(cmds) != 1 { + t.Fatalf("want 1 cmd, got %d", len(cmds)) + } + c := cmds[0] + if len(c.PathParams) != 2 { + t.Errorf("pathParams: want 2 got %d", len(c.PathParams)) + } + if len(c.QueryParams) != 1 || c.QueryParams[0].Name != "limit" { + t.Errorf("queryParams: want [limit] got %#v", c.QueryParams) + } + // header parameters intentionally dropped. +} + +func TestMapSpec_BodyFlags(t *testing.T) { + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records": { + "post": OperationObject{ + Summary: "c", + RequestBody: &RequestBodyObject{Required: true}, + }, + }, + }} + cmds := MapSpec(spec) + if !cmds[0].HasBody || !cmds[0].BodyRequired { + t.Errorf("body flags wrong: %+v", cmds[0]) + } +} + +func TestMapSpec_EmptyAndNilSafety(t *testing.T) { + if got := MapSpec(nil); got != nil { + t.Errorf("nil spec → want nil, got %#v", got) + } + if got := MapSpec(&OpenApiSpec{}); got != nil { + t.Errorf("empty spec → want nil, got %#v", got) + } + // Path with empty resource segment must be skipped. + spec := &OpenApiSpec{Paths: map[string]PathItem{"/crm-core/v1/": {"get": OperationObject{Summary: "x"}}}} + if got := MapSpec(spec); len(got) != 0 { + t.Errorf("empty resource → want 0 cmds, got %#v", got) + } +} + +func TestMapSpec_InvalidOperationSkipped(t *testing.T) { + // PathItem entry that didn't decode into a real OperationObject (zero value) + // must be skipped without producing a phantom command. + spec := &OpenApiSpec{Paths: map[string]PathItem{ + "/crm-core/v1/records": {"get": OperationObject{}}, + }} + cmds := MapSpec(spec) + if len(cmds) != 0 { + t.Errorf("zero-valued op should be skipped, got %#v", cmds) + } +} + +func TestResources(t *testing.T) { + cmds := []MappedCommand{ + {Resource: "records"}, + {Resource: "entities"}, + {Resource: "records"}, + {Resource: "automations"}, + } + got := Resources(cmds) + want := []string{"automations", "entities", "records"} + if len(got) != len(want) { + t.Fatalf("want %v got %v", want, got) + } + for i, r := range want { + if got[i] != r { + t.Errorf("[%d] want %q got %q", i, r, got[i]) + } + } +} + +func TestCommandsForResource(t *testing.T) { + cmds := []MappedCommand{ + {Resource: "records", Action: "list"}, + {Resource: "entities", Action: "list"}, + {Resource: "records", Action: "create"}, + } + got := CommandsForResource(cmds, "records") + if len(got) != 2 { + t.Fatalf("want 2, got %d", len(got)) + } + if got[0].Action != "list" || got[1].Action != "create" { + t.Errorf("order broken: %#v", got) + } +} diff --git a/pkg/crm/spec/types.go b/pkg/crm/spec/types.go new file mode 100644 index 0000000..bec7090 --- /dev/null +++ b/pkg/crm/spec/types.go @@ -0,0 +1,156 @@ +// Package spec is the CRM OpenAPI loader, on-disk cache, and command mapper. +// +// The implementation mirrors @solapi/crm-cli (sdk/cli/src/spec/*.ts) so that +// solactl exposes the same dynamic ` ` tree as the upstream +// node CLI. See docs/crm-cli-spec.md. +package spec + +import ( + "encoding/json" + "fmt" + "strings" +) + +// OpenApiSpec is the minimal subset of OpenAPI 3.x that the CRM CLI consumes. +// Fields not used by mapper/loader are kept as raw maps so backend additions +// do not break decoding. +type OpenApiSpec struct { + OpenAPI string `json:"openapi"` + Info SpecInfo `json:"info"` + Paths map[string]PathItem `json:"paths"` + Comp map[string]any `json:"components,omitempty"` +} + +// SpecInfo is the `info` block. +type SpecInfo struct { + Title string `json:"title"` + Version string `json:"version"` +} + +// PathItem holds operations keyed by lowercase HTTP method (get/post/...). +// Non-operation keys are ignored while path-level parameters are merged into +// each operation so standard OpenAPI PathItem objects decode successfully. +type PathItem map[string]OperationObject + +var supportedPathItemMethods = map[string]struct{}{ + "delete": {}, + "get": {}, + "patch": {}, + "post": {}, + "put": {}, +} + +// UnmarshalJSON accepts full OpenAPI PathItem objects. In particular, +// `parameters` is an array at the path level, not an operation, so decoding a +// PathItem as map[string]OperationObject directly would reject valid specs. +func (p *PathItem) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var commonParams []ParameterObject + if paramsRaw, ok := raw["parameters"]; ok { + if err := json.Unmarshal(paramsRaw, &commonParams); err != nil { + return fmt.Errorf("path-level parameters 파싱 실패: %w", err) + } + } + + item := make(PathItem) + for key, value := range raw { + method := strings.ToLower(key) + if _, ok := supportedPathItemMethods[method]; !ok { + continue + } + var op OperationObject + if err := json.Unmarshal(value, &op); err != nil { + return fmt.Errorf("%s operation 파싱 실패: %w", key, err) + } + op.Parameters = mergePathItemParameters(commonParams, op.Parameters) + item[method] = op + } + + *p = item + return nil +} + +func mergePathItemParameters(common, operation []ParameterObject) []ParameterObject { + if len(common) == 0 { + return operation + } + if len(operation) == 0 { + return append([]ParameterObject(nil), common...) + } + + overridden := make(map[string]struct{}, len(operation)) + for _, param := range operation { + overridden[param.In+"\x00"+param.Name] = struct{}{} + } + + merged := make([]ParameterObject, 0, len(common)+len(operation)) + for _, param := range common { + if _, ok := overridden[param.In+"\x00"+param.Name]; ok { + continue + } + merged = append(merged, param) + } + merged = append(merged, operation...) + return merged +} + +// OperationObject is one HTTP method on a path. +type OperationObject struct { + OperationID string `json:"operationId,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Parameters []ParameterObject `json:"parameters,omitempty"` + RequestBody *RequestBodyObject `json:"requestBody,omitempty"` + Responses map[string]any `json:"responses,omitempty"` +} + +// ParameterObject is one path/query/header parameter. +type ParameterObject struct { + Name string `json:"name"` + In string `json:"in"` // "path" | "query" | "header" + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` + Schema *SchemaObject `json:"schema,omitempty"` +} + +// RequestBodyObject covers requestBody. +type RequestBodyObject struct { + Required bool `json:"required,omitempty"` + Content map[string]MediaTypeObject `json:"content,omitempty"` +} + +// MediaTypeObject covers content[mime]. +type MediaTypeObject struct { + Schema *SchemaObject `json:"schema,omitempty"` +} + +// SchemaObject is a JSON Schema-ish blob. The CRM CLI only reads `type` and +// `enum`; other fields are accepted but unused. +type SchemaObject struct { + Type string `json:"type,omitempty"` + Properties map[string]*SchemaObject `json:"properties,omitempty"` + Items *SchemaObject `json:"items,omitempty"` + Enum []string `json:"enum,omitempty"` + Description string `json:"description,omitempty"` + Ref string `json:"$ref,omitempty"` + Required []string `json:"required,omitempty"` +} + +// MappedCommand is one CLI subcommand derived from a (path, method) pair. +type MappedCommand struct { + Resource string + Action string + Method string // uppercase + Path string // original path including prefix + Summary string + Tags []string + PathParams []ParameterObject + QueryParams []ParameterObject + HasBody bool + BodyRequired bool +} diff --git a/pkg/crm/spec/types_test.go b/pkg/crm/spec/types_test.go new file mode 100644 index 0000000..89322cc --- /dev/null +++ b/pkg/crm/spec/types_test.go @@ -0,0 +1,78 @@ +package spec + +import ( + "encoding/json" + "testing" +) + +func TestPathItemUnmarshal_MergesPathLevelParameters(t *testing.T) { + raw := []byte(`{ + "openapi": "3.0.0", + "info": {"title": "crm", "version": "1"}, + "paths": { + "/crm-core/v1/records/{id}": { + "summary": "path item metadata", + "parameters": [ + {"name": "id", "in": "path", "required": true}, + {"name": "includeDeleted", "in": "query"} + ], + "get": { + "summary": "get record", + "parameters": [ + {"name": "limit", "in": "query"} + ] + } + } + } + }`) + + var spec OpenApiSpec + if err := json.Unmarshal(raw, &spec); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + cmds := MapSpec(&spec) + if len(cmds) != 1 { + t.Fatalf("want 1 command, got %#v", cmds) + } + cmd := cmds[0] + if len(cmd.PathParams) != 1 || cmd.PathParams[0].Name != "id" { + t.Fatalf("path-level path param not mapped: %#v", cmd.PathParams) + } + if len(cmd.QueryParams) != 2 { + t.Fatalf("want 2 query params, got %#v", cmd.QueryParams) + } + gotNames := map[string]bool{} + for _, param := range cmd.QueryParams { + gotNames[param.Name] = true + } + if !gotNames["includeDeleted"] || !gotNames["limit"] { + t.Fatalf("query params not merged: %#v", cmd.QueryParams) + } +} + +func TestPathItemUnmarshal_OperationParameterOverridesPathLevel(t *testing.T) { + raw := []byte(`{ + "parameters": [ + {"name": "limit", "in": "query", "description": "path-level"} + ], + "get": { + "summary": "list", + "parameters": [ + {"name": "limit", "in": "query", "description": "operation-level"} + ] + } + }`) + + var item PathItem + if err := json.Unmarshal(raw, &item); err != nil { + t.Fatalf("unmarshal: %v", err) + } + params := item["get"].Parameters + if len(params) != 1 { + t.Fatalf("want one overridden param, got %#v", params) + } + if params[0].Description != "operation-level" { + t.Fatalf("operation param should override path-level param, got %#v", params[0]) + } +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 16eefcf..b3912da 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -151,7 +151,7 @@ func TestConcurrent_InitAndLog(t *testing.T) { var wg sync.WaitGroup wg.Add(goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { if i%2 == 0 { go func() { defer wg.Done() @@ -179,7 +179,7 @@ func TestRapidToggle(t *testing.T) { SetOutput(&buf) t.Cleanup(func() { Init(false) }) - for i := 0; i < 1000; i++ { + for i := range 1000 { if i%2 == 0 { Init(true) } else { @@ -242,7 +242,7 @@ func TestConcurrent_MixedLevels(t *testing.T) { var wg sync.WaitGroup wg.Add(goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { go func(n int) { defer wg.Done() Debug("debug from goroutine %d", n) diff --git a/pkg/output/printer_test.go b/pkg/output/printer_test.go index 0064e1b..4fded32 100644 --- a/pkg/output/printer_test.go +++ b/pkg/output/printer_test.go @@ -186,7 +186,7 @@ func TestFormatTable_Concurrent(t *testing.T) { var wg sync.WaitGroup wg.Add(goroutines) - for i := 0; i < goroutines; i++ { + for i := range goroutines { go func(n int) { defer wg.Done() var buf bytes.Buffer diff --git a/pkg/validation/common_test.go b/pkg/validation/common_test.go index 5dc01c9..0f42f72 100644 --- a/pkg/validation/common_test.go +++ b/pkg/validation/common_test.go @@ -112,9 +112,9 @@ func TestValidateFrom_Required(t *testing.T) { func TestValidateCustomFields(t *testing.T) { tests := []struct { - name string - fields map[string]string - wantN int + name string + fields map[string]string + wantN int }{ {name: "nil_fields", fields: nil, wantN: 0}, {name: "empty_fields", fields: map[string]string{}, wantN: 0}, @@ -299,10 +299,9 @@ func TestValidationErrors_HasErrors(t *testing.T) { } } - func makeNFields(n int) map[string]string { m := make(map[string]string, n) - for i := 0; i < n; i++ { + for i := range n { m["key"+strconv.Itoa(i)] = "val" } return m diff --git a/pkg/validation/validate_test.go b/pkg/validation/validate_test.go index cd9fd8f..e3d1e20 100644 --- a/pkg/validation/validate_test.go +++ b/pkg/validation/validate_test.go @@ -191,15 +191,13 @@ func TestValidateMessages_Concurrent(t *testing.T) { t.Cleanup(func() {}) var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 20 { + wg.Go(func() { msgs := []types.Message{ {To: "01012345678", From: "01011112222", Text: "hello"}, } _ = ValidateMessages(msgs, Options{AutoTypeDetect: true}) - }() + }) } wg.Wait() }