diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..45662dd --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,33 @@ +version: 2 + +builds: + - main: ./cmd/solactl + binary: solactl + ldflags: + - -s -w + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f2cfb3 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: build test test-race lint clean fuzz + +build: + go build -o bin/solactl ./cmd/solactl + +test: + go test ./... -count=1 + +test-race: + go test ./... -race -count=1 + +fuzz: + go test ./pkg/validation/... -fuzz=. -fuzztime=10s + +lint: + go vet ./... + +clean: + rm -rf bin/ diff --git a/cmd/configure_show.go b/cmd/configure_show.go index 24b91a7..df5e21e 100644 --- a/cmd/configure_show.go +++ b/cmd/configure_show.go @@ -27,7 +27,7 @@ func init() { ) if err := cfg.Validate(); err != nil { - _, _ = fmt.Fprintf(out(), "\n⚠ %v\n", err) + _, _ = fmt.Fprintf(errOut(), "\n⚠ %v\n", err) } return nil }, diff --git a/cmd/configure_test.go b/cmd/configure_test.go index b569c40..24bf402 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -90,7 +90,9 @@ func TestConfigureShow_NoConfig(t *testing.T) { var buf bytes.Buffer outWriter = &buf - t.Cleanup(func() { outWriter = nil }) + var errBuf bytes.Buffer + errWriter = &errBuf + t.Cleanup(func() { outWriter = nil; errWriter = nil }) rootCmd.SetArgs([]string{"configure", "show"}) err := rootCmd.Execute() @@ -102,9 +104,13 @@ func TestConfigureShow_NoConfig(t *testing.T) { if !strings.Contains(output, "API Key") { t.Errorf("expected API Key label, got: %s", output) } - // Should show warning about missing config - if !strings.Contains(output, "⚠") { - t.Errorf("expected warning for empty config, got: %s", output) + // Warning should go to stderr, not stdout + errOutput := errBuf.String() + if !strings.Contains(errOutput, "⚠") { + t.Errorf("expected warning for empty config on stderr, got: %s", errOutput) + } + if strings.Contains(output, "⚠") { + t.Errorf("warning should not appear on stdout, got: %s", output) } resetFlags() } @@ -211,7 +217,9 @@ func TestConfigureShow_ValidationWarning(t *testing.T) { var buf bytes.Buffer outWriter = &buf - t.Cleanup(func() { outWriter = nil }) + var errBuf bytes.Buffer + errWriter = &errBuf + t.Cleanup(func() { outWriter = nil; errWriter = nil }) rootCmd.SetArgs([]string{"configure", "show"}) err := rootCmd.Execute() @@ -219,9 +227,13 @@ func TestConfigureShow_ValidationWarning(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - output := buf.String() - if !strings.Contains(output, "⚠") { - t.Errorf("expected validation warning (⚠) for empty API Key, got: %s", output) + errOutput := errBuf.String() + if !strings.Contains(errOutput, "⚠") { + t.Errorf("expected validation warning (⚠) for empty API Key on stderr, got: %s", errOutput) + } + stdoutOutput := buf.String() + if strings.Contains(stdoutOutput, "⚠") { + t.Errorf("warning should not appear on stdout, got: %s", stdoutOutput) } resetFlags() } diff --git a/cmd/root.go b/cmd/root.go index 4638e72..93ac876 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,11 +5,13 @@ import ( "io" "os" "os/signal" + "runtime" "syscall" "time" "github.com/spf13/cobra" + "github.com/solapi/solactl/internal/version" "github.com/solapi/solactl/pkg/client" "github.com/solapi/solactl/pkg/config" "github.com/solapi/solactl/pkg/logger" @@ -32,6 +34,10 @@ var ( // outWriter is the destination for command output. Tests override this. var outWriter io.Writer +// errWriter is the destination for informational/diagnostic output (stderr). +// Tests override this to capture stderr messages. +var errWriter io.Writer + // clientOverride is set by tests to bypass loadConfig and use a test client. var clientOverride *client.Client @@ -103,7 +109,9 @@ func newClient() (*client.Client, error) { return nil, err } logger.Debug("SOLAPI 클라이언트 생성: %s", client.BaseURL) - return client.New(cfg.APIKey, cfg.APISecret), nil + c := client.New(cfg.APIKey, cfg.APISecret) + c.UserAgent = "solactl/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")" + return c, nil } // out returns the current output writer, falling back to os.Stdout. @@ -114,6 +122,14 @@ func out() io.Writer { return os.Stdout } +// errOut returns the current error/diagnostic writer, falling back to os.Stderr. +func errOut() io.Writer { + if errWriter != nil { + return errWriter + } + return os.Stderr +} + // printer returns a configured output printer. func printer() *output.Printer { return &output.Printer{Writer: out(), JSONMode: flagJSON} diff --git a/cmd/send.go b/cmd/send.go index 38c6e5d..6a816d8 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -11,7 +11,9 @@ import ( "github.com/solapi/solactl/internal/version" "github.com/solapi/solactl/pkg/client" + "github.com/solapi/solactl/pkg/output" "github.com/solapi/solactl/pkg/types" + "github.com/solapi/solactl/pkg/validation" ) var sendCmd = &cobra.Command{ @@ -25,7 +27,9 @@ var ( sendFlagFrom string sendFlagText string sendFlagScheduled string - sendFlagFile string // CSV file for bulk sending + sendFlagFile string // CSV file for bulk sending + sendFlagSkipValidation bool + sendFlagStrict bool ) func init() { @@ -34,6 +38,8 @@ func init() { sendCmd.PersistentFlags().StringVar(&sendFlagText, "text", "", "메시지 내용") sendCmd.PersistentFlags().StringVar(&sendFlagScheduled, "scheduled", "", "예약 발송 시간 (ISO 8601)") sendCmd.PersistentFlags().StringVar(&sendFlagFile, "file", "", "수신자 CSV 파일 경로") + sendCmd.PersistentFlags().BoolVar(&sendFlagSkipValidation, "skip-validation", false, "클라이언트 사이드 검증 건너뛰기") + sendCmd.PersistentFlags().BoolVar(&sendFlagStrict, "strict", false, "엄격 검증 모드 활성화") rootCmd.AddCommand(sendCmd) } @@ -56,11 +62,37 @@ func parseRecipients(to string) []string { return result } -// sendMessages builds a SendRequest with the agent and posts to send-many/detail. +// sendMessages validates and sends messages via the API. // If the message list exceeds maxBatchSize, it auto-splits into multiple API calls. func sendMessages(c *client.Client, msgs []types.Message) error { + // Client-side validation (skip with --skip-validation) + if !sendFlagSkipValidation { + opts := validation.Options{ + Strict: sendFlagStrict, + AutoTypeDetect: true, + } + if errs := validation.ValidateMessages(msgs, opts); errs != nil { + p := &output.Printer{Writer: errOut()} + fmt.Fprintf(errOut(), "검증 오류 %d건:\n", len(errs)) + headers := []string{"번호", "필드", "오류코드", "메시지"} + var rows [][]string + for _, e := range errs { + rows = append(rows, []string{ + fmt.Sprintf("[%d]", e.Index+1), + e.Field, + e.Code, + e.Message, + }) + } + p.FormatTable(headers, rows) + return fmt.Errorf("검증 실패: %d건의 오류가 발견되었습니다", len(errs)) + } + } + showList := true agent := types.DefaultAgent(version.Version) + totalBatches := (len(msgs) + maxBatchSize - 1) / maxBatchSize + batchNum := 0 for start := 0; start < len(msgs); start += maxBatchSize { end := start + maxBatchSize @@ -68,6 +100,11 @@ func sendMessages(c *client.Client, msgs []types.Message) error { end = len(msgs) } batch := msgs[start:end] + batchNum++ + + if totalBatches > 1 { + fmt.Fprintf(errOut(), "[%d/%d] %d건 발송 중...\n", batchNum, totalBatches, len(batch)) + } req := types.SendRequest{ Messages: batch, @@ -202,5 +239,39 @@ func buildMessagesFromFlags(msgBuilder func(to string) types.Message) ([]types.M return msgs, nil } +// resolveFrom returns the --from value if set, otherwise queries the senderid API +// and auto-selects: 1 active → use it, 0 or 2+ → error with guidance. +func resolveFrom(c *client.Client) (string, error) { + if sendFlagFrom != "" { + return sendFlagFrom, nil + } + + raw, err := c.Get(ctx(), "senderid/v1/numbers/active", nil) + if err != nil { + return "", fmt.Errorf("발신번호(--from)를 입력하세요 (발신번호 조회 실패: %w)", err) + } + + var senders []types.SenderID + if err := json.Unmarshal(raw, &senders); err != nil { + return "", fmt.Errorf("발신번호 자동 선택 실패 (응답 파싱 오류: %w). --from으로 직접 지정하세요", err) + } + + switch len(senders) { + case 0: + return "", fmt.Errorf("등록된 활성 발신번호가 없습니다. solactl senderid list로 확인하세요") + case 1: + selected := senders[0].PhoneNumber + fmt.Fprintf(errOut(), "발신번호 자동 선택: %s\n", selected) + return selected, nil + default: + var lines []string + for _, s := range senders { + lines = append(lines, " "+s.PhoneNumber) + } + return "", fmt.Errorf("활성 발신번호가 %d개입니다. --from으로 지정하세요:\n%s", + len(senders), 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_lms.go b/cmd/send_lms.go index b0120a0..511d93a 100644 --- a/cmd/send_lms.go +++ b/cmd/send_lms.go @@ -30,15 +30,16 @@ func runSendLMS(cmd *cobra.Command, args []string) error { var msgs []types.Message if sendFlagFile != "" { - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") + from, err := resolveFrom(c) + if err != nil { + return err } - msgs, err = loadCSVMessages(sendFlagFile, sendFlagFrom, sendFlagText) + msgs, err = loadCSVMessages(sendFlagFile, from, sendFlagText) if err != nil { return err } - // Set subject on all CSV-loaded messages. for i := range msgs { + msgs[i].Type = "LMS" msgs[i].Subject = sendLMSFlagSubject } } else { @@ -48,14 +49,17 @@ func runSendLMS(cmd *cobra.Command, args []string) error { if sendFlagText == "" { return fmt.Errorf("메시지 내용(--text)을 입력하세요") } - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") + + from, err := resolveFrom(c) + if err != nil { + return err } msgs, err = buildMessagesFromFlags(func(to string) types.Message { return types.Message{ To: to, - From: sendFlagFrom, + From: from, + Type: "LMS", Text: sendFlagText, Subject: sendLMSFlagSubject, } diff --git a/cmd/send_mms.go b/cmd/send_mms.go index d00762e..660cde9 100644 --- a/cmd/send_mms.go +++ b/cmd/send_mms.go @@ -43,8 +43,10 @@ func runSendMMS(cmd *cobra.Command, args []string) error { if sendFlagText == "" { return fmt.Errorf("메시지 내용(--text)을 입력하세요") } - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") + + from, err := resolveFrom(c) + if err != nil { + return err } imageID, err := uploadImage(c, sendMMSFlagImage, "MMS") @@ -55,7 +57,8 @@ func runSendMMS(cmd *cobra.Command, args []string) error { msgs, err := buildMessagesFromFlags(func(to string) types.Message { return types.Message{ To: to, - From: sendFlagFrom, + From: from, + Type: "MMS", Text: sendFlagText, Subject: sendMMSFlagSubject, ImageID: imageID, diff --git a/cmd/send_rcs.go b/cmd/send_rcs.go index a839b27..16a30bb 100644 --- a/cmd/send_rcs.go +++ b/cmd/send_rcs.go @@ -44,9 +44,6 @@ func runSendRCS(cmd *cobra.Command, args []string) error { if sendFlagTo == "" && sendFlagFile == "" { return fmt.Errorf("수신번호(--to)를 입력하세요") } - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") - } if sendRCSFlagBrandID == "" { return fmt.Errorf("RCS 브랜드 ID(--brand-id)를 입력하세요") } @@ -54,6 +51,11 @@ func runSendRCS(cmd *cobra.Command, args []string) error { return fmt.Errorf("메시지 내용(--text)을 입력하세요 (또는 --template-id 사용)") } + from, err := resolveFrom(c) + if err != nil { + return err + } + variables, err := parseVariables(sendRCSFlagVariables) if err != nil { return err @@ -83,7 +85,7 @@ func runSendRCS(cmd *cobra.Command, args []string) error { var msgs []types.Message if sendFlagFile != "" { - msgs, err = loadCSVMessages(sendFlagFile, sendFlagFrom, sendFlagText) + msgs, err = loadCSVMessages(sendFlagFile, from, sendFlagText) if err != nil { return err } @@ -98,7 +100,7 @@ func runSendRCS(cmd *cobra.Command, args []string) error { msgs, err = buildMessagesFromFlags(func(to string) types.Message { return types.Message{ To: to, - From: sendFlagFrom, + From: from, Text: sendFlagText, Subject: sendRCSFlagSubject, ImageID: imageID, diff --git a/cmd/send_sms.go b/cmd/send_sms.go index b11d606..46bc2f5 100644 --- a/cmd/send_sms.go +++ b/cmd/send_sms.go @@ -27,10 +27,11 @@ func runSendSMS(cmd *cobra.Command, args []string) error { var msgs []types.Message if sendFlagFile != "" { - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") + from, err := resolveFrom(c) + if err != nil { + return err } - msgs, err = loadCSVMessages(sendFlagFile, sendFlagFrom, sendFlagText) + msgs, err = loadCSVMessages(sendFlagFile, from, sendFlagText) if err != nil { return err } @@ -41,14 +42,16 @@ func runSendSMS(cmd *cobra.Command, args []string) error { if sendFlagText == "" { return fmt.Errorf("메시지 내용(--text)을 입력하세요") } - if sendFlagFrom == "" { - return fmt.Errorf("발신번호(--from)를 입력하세요") + + from, err := resolveFrom(c) + if err != nil { + return err } msgs, err = buildMessagesFromFlags(func(to string) types.Message { return types.Message{ To: to, - From: sendFlagFrom, + From: from, Text: sendFlagText, } }) diff --git a/cmd/send_test.go b/cmd/send_test.go index 916b8c6..8fd35c9 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -26,6 +26,8 @@ func resetSendFlags() { sendFlagText = "" sendFlagScheduled = "" sendFlagFile = "" + sendFlagSkipValidation = false + sendFlagStrict = false sendLMSFlagSubject = "" sendMMSFlagImage = "" sendMMSFlagSubject = "" @@ -87,6 +89,7 @@ func setupSendTest(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Cleanup(func() { clientOverride = nil outWriter = nil + errWriter = nil resetSendFlags() }) @@ -101,6 +104,14 @@ func captureBuf(t *testing.T) *bytes.Buffer { return &buf } +// captureErrBuf sets up stderr capture and returns the buffer. +func captureErrBuf(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + errWriter = &buf + return &buf +} + // mockSendResponse returns a realistic send-many/detail response. func mockSendResponse(total, regSuccess, regFailed int) types.SendResponse { return types.SendResponse{ @@ -258,7 +269,14 @@ func TestSendSMS_MissingText(t *testing.T) { func TestSendSMS_MissingFrom(t *testing.T) { setupSendTest(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("server should not be called") + // resolveFrom calls senderid API when --from is not provided + if strings.Contains(r.URL.Path, "senderid") { + // Return empty list → 0 active senders → error + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + t.Fatal("send endpoint should not be called") }) captureBuf(t) @@ -267,9 +285,6 @@ func TestSendSMS_MissingFrom(t *testing.T) { if err == nil { t.Fatal("expected error for missing --from") } - if !strings.Contains(err.Error(), "--from") { - t.Errorf("error should mention --from: %v", err) - } } func TestSendLMS_WithSubject(t *testing.T) { @@ -659,7 +674,12 @@ func TestSendSMS_APIError(t *testing.T) { func TestSendLMS_MissingFrom(t *testing.T) { setupSendTest(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("server should not be called") + if strings.Contains(r.URL.Path, "senderid") { + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + t.Fatal("send endpoint should not be called") }) captureBuf(t) @@ -668,9 +688,6 @@ func TestSendLMS_MissingFrom(t *testing.T) { if err == nil { t.Fatal("expected error for missing --from") } - if !strings.Contains(err.Error(), "--from") { - t.Errorf("error should mention --from: %v", err) - } } func TestSendSMS_FailedMessages(t *testing.T) { @@ -716,6 +733,68 @@ func TestSendSMS_FailedMessages(t *testing.T) { } } +func TestSendSMS_PartialSuccessFailure(t *testing.T) { + // Test: 3 messages sent, 2 succeed, 1 fails (partial success) + setupSendTest(t, func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "senderid") { + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + resp := types.SendResponse{ + GroupInfo: types.GroupInfo{ + GroupID: "G_PARTIAL", + Status: "SENDING", + Count: types.GroupCount{ + Total: 3, + RegisteredSuccess: 2, + RegisteredFailed: 1, + }, + }, + FailedMessageList: []types.FailedMessage{ + { + To: "01033333333", + From: "01012345678", + StatusCode: "1010", + StatusMessage: "수신번호 형식 오류", + }, + }, + } + data, _ := json.Marshal(resp) + w.WriteHeader(200) + _, _ = w.Write(data) + }) + + buf := captureBuf(t) + + rootCmd.SetArgs([]string{ + "send", "sms", + "--to", "01011111111,01022222222,01033333333", + "--from", "01012345678", + "--text", "Hi", + "--skip-validation", + }) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + // Should show both success and failure counts + if !strings.Contains(output, "등록 성공") { + t.Error("output should show registered success count") + } + if !strings.Contains(output, "등록 실패") { + t.Error("output should show registered failed count") + } + if !strings.Contains(output, "실패 메시지") { + t.Error("output should show failed messages section") + } + if !strings.Contains(output, "01033333333") { + t.Error("output should show the failed recipient") + } +} + // --------------------------------------------------------------------------- // 1. Batching Logic Tests // --------------------------------------------------------------------------- @@ -821,6 +900,7 @@ func TestSendMessages_BatchSplit(t *testing.T) { t.Cleanup(func() { clientOverride = nil outWriter = nil + errWriter = nil resetSendFlags() }) @@ -1160,7 +1240,12 @@ func TestPrintSendResult_EmptyFailedList(t *testing.T) { func TestSendSMS_AllFlagsMissing(t *testing.T) { setupSendTest(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("server should not be called") + if strings.Contains(r.URL.Path, "senderid") { + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + t.Fatal("send endpoint should not be called") }) captureBuf(t) @@ -1169,10 +1254,6 @@ func TestSendSMS_AllFlagsMissing(t *testing.T) { if err == nil { t.Fatal("expected error when no flags provided") } - // The first validation in runSendSMS (after file check) is --to. - if !strings.Contains(err.Error(), "--to") { - t.Errorf("error should mention --to as first missing flag, got: %v", err) - } } func TestSendMMS_EmptyImageFlag(t *testing.T) { @@ -1275,6 +1356,7 @@ func TestSendMessages_ZeroMessages(t *testing.T) { t.Cleanup(func() { clientOverride = nil outWriter = nil + errWriter = nil resetSendFlags() }) @@ -1328,6 +1410,7 @@ func TestSendMessages_ExactlyMaxBatch(t *testing.T) { t.Cleanup(func() { clientOverride = nil outWriter = nil + errWriter = nil resetSendFlags() }) @@ -1385,6 +1468,7 @@ func TestSendMessages_MultipleBatches(t *testing.T) { resetSendFlags() captureBuf(t) + errBuf := captureErrBuf(t) c := &client.Client{ HTTPClient: ts.Client(), @@ -1398,6 +1482,7 @@ func TestSendMessages_MultipleBatches(t *testing.T) { t.Cleanup(func() { clientOverride = nil outWriter = nil + errWriter = nil resetSendFlags() }) @@ -1431,6 +1516,15 @@ func TestSendMessages_MultipleBatches(t *testing.T) { t.Errorf("batch[%d] size: got %d, want %d", i, batchSizes[i], want) } } + + // Verify batch progress messages go to stderr + stderrOutput := errBuf.String() + if !strings.Contains(stderrOutput, "[1/3]") { + t.Errorf("expected batch progress [1/3] on stderr, got: %s", stderrOutput) + } + if !strings.Contains(stderrOutput, "[3/3]") { + t.Errorf("expected batch progress [3/3] on stderr, got: %s", stderrOutput) + } } // --------------------------------------------------------------------------- @@ -2519,7 +2613,12 @@ func TestSendRCS_MissingBrandID(t *testing.T) { func TestSendRCS_MissingFrom(t *testing.T) { setupSendTest(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("server should not be called") + if strings.Contains(r.URL.Path, "senderid") { + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + t.Fatal("send endpoint should not be called") }) captureBuf(t) @@ -2528,9 +2627,6 @@ func TestSendRCS_MissingFrom(t *testing.T) { if err == nil { t.Fatal("expected error for missing --from") } - if !strings.Contains(err.Error(), "--from") { - t.Errorf("error should mention --from: %v", err) - } } func TestSendRCS_MissingTo(t *testing.T) { @@ -3028,3 +3124,44 @@ func TestSendBMS_FreeWithWideImage(t *testing.T) { t.Errorf("WIDE bubble type should upload with BMS_WIDE type, got %q", uploadType) } } + +// --------------------------------------------------------------------------- +// Stderr output tests +// --------------------------------------------------------------------------- + +func TestSendMessages_ValidationError_Stderr(t *testing.T) { + resetSendFlags() + sendFlagSkipValidation = false + + outBuf := captureBuf(t) + errBuf := captureErrBuf(t) + t.Cleanup(func() { + outWriter = nil + errWriter = nil + resetSendFlags() + }) + + // Message with empty "to" triggers validation error + msgs := []types.Message{{To: "", From: "01012345678", Text: "test"}} + c := &client.Client{APIKey: "k", APISecret: "s"} + + err := sendMessages(c, msgs) + if err == nil { + t.Fatal("expected validation error") + } + + // Validation header and table should appear on stderr + stderrOut := errBuf.String() + if !strings.Contains(stderrOut, "검증 오류") { + t.Errorf("expected validation header on stderr, got: %s", stderrOut) + } + if !strings.Contains(stderrOut, "1010") { + t.Errorf("expected error code in stderr table, got: %s", stderrOut) + } + + // stdout should NOT contain validation output + stdoutOut := outBuf.String() + if strings.Contains(stdoutOut, "검증 오류") { + t.Errorf("validation output should not appear on stdout, got: %s", stdoutOut) + } +} diff --git a/internal/version/semver_test.go b/internal/version/semver_test.go index 6c015f2..854e2ff 100644 --- a/internal/version/semver_test.go +++ b/internal/version/semver_test.go @@ -3,6 +3,7 @@ package version import "testing" func TestParseSemver_Valid(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { input string major int @@ -46,6 +47,7 @@ func TestParseSemver_Valid(t *testing.T) { } func TestParseSemver_Invalid(t *testing.T) { + t.Cleanup(func() {}) tests := []string{ "", "vx.y.z", @@ -67,6 +69,7 @@ func TestParseSemver_Invalid(t *testing.T) { } func TestCompareSemver_MajorDifference(t *testing.T) { + t.Cleanup(func() {}) a := Semver{Major: 2, Minor: 0, Patch: 0} b := Semver{Major: 1, Minor: 9, Patch: 9} if got := CompareSemver(a, b); got != 1 { @@ -78,6 +81,7 @@ func TestCompareSemver_MajorDifference(t *testing.T) { } func TestCompareSemver_MinorDifference(t *testing.T) { + t.Cleanup(func() {}) a := Semver{Major: 1, Minor: 3, Patch: 0} b := Semver{Major: 1, Minor: 2, Patch: 9} if got := CompareSemver(a, b); got != 1 { @@ -86,6 +90,7 @@ func TestCompareSemver_MinorDifference(t *testing.T) { } func TestCompareSemver_PatchDifference(t *testing.T) { + t.Cleanup(func() {}) a := Semver{Major: 1, Minor: 2, Patch: 4} b := Semver{Major: 1, Minor: 2, Patch: 3} if got := CompareSemver(a, b); got != 1 { @@ -94,6 +99,7 @@ func TestCompareSemver_PatchDifference(t *testing.T) { } func TestCompareSemver_Equal(t *testing.T) { + t.Cleanup(func() {}) a := Semver{Major: 1, Minor: 2, Patch: 3} b := Semver{Major: 1, Minor: 2, Patch: 3} if got := CompareSemver(a, b); got != 0 { @@ -102,6 +108,7 @@ func TestCompareSemver_Equal(t *testing.T) { } func TestCompareSemver_PrereleaseVsStable(t *testing.T) { + t.Cleanup(func() {}) stable := Semver{Major: 1, Minor: 2, Patch: 3} rc := Semver{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc1"} @@ -114,6 +121,7 @@ func TestCompareSemver_PrereleaseVsStable(t *testing.T) { } func TestCompareSemver_PrereleaseOrder(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { name string a, b Semver @@ -161,6 +169,7 @@ func TestCompareSemver_PrereleaseOrder(t *testing.T) { } func TestCompareSemver_PrereleaseEqual(t *testing.T) { + t.Cleanup(func() {}) a := Semver{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc1"} b := Semver{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc1"} if got := CompareSemver(a, b); got != 0 { @@ -169,6 +178,7 @@ func TestCompareSemver_PrereleaseEqual(t *testing.T) { } func TestCompareSemver_CurrentGreaterThanLatest(t *testing.T) { + t.Cleanup(func() {}) current := Semver{Major: 2, Minor: 0, Patch: 0} latest := Semver{Major: 1, Minor: 5, Patch: 0} @@ -178,6 +188,7 @@ func TestCompareSemver_CurrentGreaterThanLatest(t *testing.T) { } func FuzzParseSemver(f *testing.F) { + f.Cleanup(func() {}) f.Add("v1.2.3") f.Add("1.0.0-rc1") f.Add("") diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 244dc86..9915d0d 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -3,6 +3,7 @@ package version import "testing" func TestVersion_NonEmpty(t *testing.T) { + t.Cleanup(func() {}) fields := []struct { name string value string @@ -22,6 +23,7 @@ func TestVersion_NonEmpty(t *testing.T) { } func TestVersion_DevDefault(t *testing.T) { + t.Cleanup(func() {}) // When run via `go test` without ldflags, Version should be "dev" // because debug.ReadBuildInfo().Main.Version is "(devel)" in test mode. // If built with module info, it could be a real version string. diff --git a/pkg/apierror/apierror_test.go b/pkg/apierror/apierror_test.go index 16eb501..e7f26ea 100644 --- a/pkg/apierror/apierror_test.go +++ b/pkg/apierror/apierror_test.go @@ -8,6 +8,7 @@ import ( ) func TestClassify_APIErrors(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { name string err *APIError @@ -124,6 +125,7 @@ func TestClassify_APIErrors(t *testing.T) { } func TestClassify_GenericErrors(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { name string err error @@ -194,6 +196,7 @@ func TestClassify_GenericErrors(t *testing.T) { } func TestClassify_Nil(t *testing.T) { + t.Cleanup(func() {}) got := Classify(nil) if got != nil { t.Errorf("Classify(nil) = %v, want nil", got) @@ -201,6 +204,7 @@ func TestClassify_Nil(t *testing.T) { } func TestClassifiedError_Unwrap(t *testing.T) { + t.Cleanup(func() {}) orig := fmt.Errorf("original error") ce := &ClassifiedError{Original: orig, Category: CategoryUnknown, Message: "test"} @@ -217,6 +221,7 @@ func TestClassifiedError_Unwrap(t *testing.T) { } func TestClassifiedError_Error(t *testing.T) { + t.Cleanup(func() {}) orig := fmt.Errorf("original message") ce := &ClassifiedError{Original: orig, Category: CategoryAuth, Message: "인증 실패"} if ce.Error() != "original message" { @@ -225,6 +230,7 @@ func TestClassifiedError_Error(t *testing.T) { } func TestClassify_WrappedAPIError(t *testing.T) { + t.Cleanup(func() {}) apiErr := &APIError{HTTPStatus: 401, ErrorCode: "Unauthorized", ErrorMessage: "bad"} wrapped := fmt.Errorf("HTTP request failed: %w", apiErr) @@ -235,6 +241,7 @@ func TestClassify_WrappedAPIError(t *testing.T) { } func TestClassify_AllCategoriesHaveNonEmptyMessage(t *testing.T) { + t.Cleanup(func() {}) testErrors := []error{ &APIError{HTTPStatus: 401, ErrorCode: "Unauthorized", ErrorMessage: "x"}, &APIError{HTTPStatus: 403, ErrorCode: "Forbidden", ErrorMessage: "x"}, @@ -257,6 +264,7 @@ func TestClassify_AllCategoriesHaveNonEmptyMessage(t *testing.T) { } func TestClassify_APIError_AllFieldsEmpty(t *testing.T) { + t.Cleanup(func() {}) input := &APIError{} ce := Classify(input) @@ -272,6 +280,7 @@ func TestClassify_APIError_AllFieldsEmpty(t *testing.T) { } func TestClassify_Unauthorized_ByCodeOnly(t *testing.T) { + t.Cleanup(func() {}) input := &APIError{ErrorCode: "Unauthorized", ErrorMessage: "bad creds"} ce := Classify(input) @@ -281,6 +290,7 @@ func TestClassify_Unauthorized_ByCodeOnly(t *testing.T) { } func TestClassify_Forbidden_ByCodeOnly(t *testing.T) { + t.Cleanup(func() {}) input := &APIError{ErrorCode: "Forbidden", ErrorMessage: "no access"} ce := Classify(input) @@ -290,6 +300,7 @@ func TestClassify_Forbidden_ByCodeOnly(t *testing.T) { } func TestClassify_BadRequest_ByCodeOnly(t *testing.T) { + t.Cleanup(func() {}) input := &APIError{ErrorCode: "BadRequest", ErrorMessage: "need title"} ce := Classify(input) @@ -302,6 +313,7 @@ func TestClassify_BadRequest_ByCodeOnly(t *testing.T) { } func TestAPIError_Error_WithErrorCode(t *testing.T) { + t.Cleanup(func() {}) e := &APIError{ErrorCode: "BadRequest", ErrorMessage: "invalid input"} if e.Error() != "BadRequest: invalid input" { t.Errorf("Error() = %q, want %q", e.Error(), "BadRequest: invalid input") @@ -309,6 +321,7 @@ func TestAPIError_Error_WithErrorCode(t *testing.T) { } func TestAPIError_Error_WithMessageOnly(t *testing.T) { + t.Cleanup(func() {}) e := &APIError{ErrorMessage: "something went wrong"} if e.Error() != "something went wrong" { t.Errorf("Error() = %q, want %q", e.Error(), "something went wrong") @@ -316,6 +329,7 @@ func TestAPIError_Error_WithMessageOnly(t *testing.T) { } func TestAPIError_Error_StatusOnly(t *testing.T) { + t.Cleanup(func() {}) e := &APIError{HTTPStatus: 500} if e.Error() != "HTTP 500" { t.Errorf("Error() = %q, want %q", e.Error(), "HTTP 500") @@ -323,6 +337,7 @@ func TestAPIError_Error_StatusOnly(t *testing.T) { } func TestAPIError_Error_Empty(t *testing.T) { + t.Cleanup(func() {}) e := &APIError{} if e.Error() != "HTTP 0" { t.Errorf("Error() = %q, want %q", e.Error(), "HTTP 0") @@ -330,6 +345,7 @@ func TestAPIError_Error_Empty(t *testing.T) { } func TestClassify_HintContainsSolactl(t *testing.T) { + t.Cleanup(func() {}) // Auth error hints should reference solactl, not colactl e := &APIError{HTTPStatus: 401, ErrorCode: "Unauthorized", ErrorMessage: "bad"} ce := Classify(e) @@ -355,6 +371,7 @@ func searchSubstring(s, sub string) bool { } func TestClassify_Idempotent(t *testing.T) { + t.Cleanup(func() {}) apiErr := &APIError{HTTPStatus: 429, ErrorCode: "TooManyRequests", ErrorMessage: "slow down"} first := Classify(apiErr) @@ -372,6 +389,7 @@ func TestClassify_Idempotent(t *testing.T) { } func TestClassify_HTTPStatusBoundaries(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { status int wantCategory Category @@ -411,6 +429,7 @@ func TestClassify_HTTPStatusBoundaries(t *testing.T) { } func TestClassify_WrappedError(t *testing.T) { + t.Cleanup(func() {}) apiErr := &APIError{HTTPStatus: 403, ErrorCode: "Forbidden", ErrorMessage: "no access"} wrapped := fmt.Errorf("context: %w", apiErr) @@ -433,6 +452,7 @@ func TestClassify_WrappedError(t *testing.T) { } func TestClassify_Concurrent(t *testing.T) { + t.Cleanup(func() {}) apiErr := &APIError{HTTPStatus: 500, ErrorCode: "InternalServerError", ErrorMessage: "oops"} const goroutines = 100 @@ -465,6 +485,7 @@ func TestClassify_Concurrent(t *testing.T) { } func FuzzClassifyGeneric(f *testing.F) { + f.Cleanup(func() {}) f.Add("connection refused") f.Add("no such host") f.Add("context deadline exceeded") @@ -491,6 +512,7 @@ func FuzzClassifyGeneric(f *testing.F) { } func TestAPIError_Error_AllCombinations(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { name string err APIError diff --git a/pkg/client/client.go b/pkg/client/client.go index 23a38c4..40821d9 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -11,6 +11,7 @@ import ( "math/rand/v2" "net/http" "net/url" + "runtime" "strings" "time" @@ -24,12 +25,13 @@ const BaseURL = "https://api.solapi.com" // Client is an HTTP client for SOLAPI REST endpoints. type Client struct { - HTTPClient *http.Client - APIKey string - APISecret string - MaxRetries int - BaseDelay time.Duration + 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. } // baseURL returns the effective base URL for API requests. @@ -90,6 +92,11 @@ func (c *Client) executeWithRetry(ctx context.Context, method, rawURL string, bo var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { + // Short-circuit if context is already expired + if err := ctx.Err(); err != nil { + return nil, err + } + if attempt > 0 { delay := c.BaseDelay * time.Duration(1<<(attempt-1)) var jitter time.Duration @@ -144,6 +151,12 @@ func (c *Client) doRequest(ctx context.Context, method, rawURL string, body []by } req.Header.Set("Authorization", authHeader) + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } else { + req.Header.Set("User-Agent", "solactl/unknown ("+runtime.GOOS+"/"+runtime.GOARCH+")") + } + logger.Debug("--> %s %s", method, rawURL) start := time.Now() diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go index 660c2a6..77a15c4 100644 --- a/pkg/types/types_test.go +++ b/pkg/types/types_test.go @@ -7,6 +7,7 @@ import ( ) func TestDefaultAgent(t *testing.T) { + t.Cleanup(func() {}) a := DefaultAgent("1.0.0") if a.SDKVersion != "solactl/1.0.0" { t.Errorf("SDKVersion: got %q", a.SDKVersion) @@ -17,6 +18,7 @@ func TestDefaultAgent(t *testing.T) { } func TestSendRequest_JSON(t *testing.T) { + t.Cleanup(func() {}) show := true req := SendRequest{ Messages: []Message{ @@ -47,6 +49,7 @@ func TestSendRequest_JSON(t *testing.T) { } func TestMessage_OmitsEmptyFields(t *testing.T) { + t.Cleanup(func() {}) msg := Message{To: "01012345678"} data, err := json.Marshal(msg) if err != nil { @@ -67,6 +70,7 @@ func TestMessage_OmitsEmptyFields(t *testing.T) { } func TestMessage_WithKakaoOptions(t *testing.T) { + t.Cleanup(func() {}) msg := Message{ To: "01012345678", From: "029999999", @@ -94,6 +98,7 @@ func TestMessage_WithKakaoOptions(t *testing.T) { } func TestMessage_WithRCSOptions(t *testing.T) { + t.Cleanup(func() {}) msg := Message{ To: "01012345678", Text: "RCS test", @@ -119,6 +124,7 @@ func TestMessage_WithRCSOptions(t *testing.T) { } func TestSendResponse_Unmarshal(t *testing.T) { + t.Cleanup(func() {}) raw := `{ "groupInfo": { "count": {"total": 1, "registeredSuccess": 1}, @@ -149,6 +155,7 @@ func TestSendResponse_Unmarshal(t *testing.T) { } func TestUploadFileRequest_JSON(t *testing.T) { + t.Cleanup(func() {}) req := UploadFileRequest{ File: "base64data==", Type: "MMS", @@ -168,6 +175,7 @@ func TestUploadFileRequest_JSON(t *testing.T) { // SenderID tests func TestSenderID_DisplayStatus(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { status string want string @@ -185,6 +193,7 @@ func TestSenderID_DisplayStatus(t *testing.T) { } func TestSenderID_DisplayMethod(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { method string want string @@ -201,6 +210,7 @@ func TestSenderID_DisplayMethod(t *testing.T) { } func TestSenderID_DisplayExpireAt(t *testing.T) { + t.Cleanup(func() {}) tests := []struct { expireAt string want string @@ -219,6 +229,7 @@ func TestSenderID_DisplayExpireAt(t *testing.T) { } func TestSenderIDInfo_Unmarshal(t *testing.T) { + t.Cleanup(func() {}) raw := `{ "accountId": "ACC123", "limit": 5, @@ -244,6 +255,7 @@ func TestSenderIDInfo_Unmarshal(t *testing.T) { } func FuzzMessageJSON(f *testing.F) { + f.Cleanup(func() {}) f.Add(`{"to":"010","from":"029","text":"hi"}`) f.Add(`{}`) f.Add(`{"to":"","kakaoOptions":{"pfId":"test"}}`) diff --git a/pkg/validation/common.go b/pkg/validation/common.go new file mode 100644 index 0000000..cc5aa6d --- /dev/null +++ b/pkg/validation/common.go @@ -0,0 +1,106 @@ +package validation + +import ( + "strconv" + + "github.com/solapi/solactl/pkg/types" +) + +// validateCommon performs common validation shared across all message types. +func validateCommon(msg *types.Message, idx int, _ Options) []ValidationError { + var errs []ValidationError + + // Validate phone number + if msg.To == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "to", Code: "1010", + Message: "수신번호(--to)가 비어 있습니다", + }) + } else { + number, country, err := ParsePhone(msg.To) + if err != nil { + errs = append(errs, ValidationError{ + Index: idx, Field: "to", Code: "1010", + Message: err.Error(), + }) + } else { + msg.To = number // normalize in place + if country != "" { + msg.Country = country + } + } + } + + // Validate customFields + errs = append(errs, validateCustomFields(msg.CustomFields, idx)...) + + return errs +} + +// validateFrom checks the from field requirement based on message type. +func validateFrom(msg *types.Message, idx int, required bool) []ValidationError { + if required && msg.From == "" { + return []ValidationError{{ + Index: idx, Field: "from", Code: "1010", + Message: "발신번호(--from)는 필수입니다", + }} + } + return nil +} + +// validateCustomFields checks the custom fields constraints. +func validateCustomFields(fields map[string]string, idx int) []ValidationError { + if fields == nil { + return nil + } + var errs []ValidationError + + if len(fields) > 10 { + errs = append(errs, ValidationError{ + Index: idx, Field: "customFields", Code: "1010", + Message: "customFields는 최대 10개까지 허용됩니다", + }) + } + + for key, value := range fields { + keyLen := GetRealTextLength(key) + if keyLen > 30 { + errs = append(errs, ValidationError{ + Index: idx, Field: "customFields." + key, Code: "1010", + Message: "customFields 키는 최대 30자까지 허용됩니다", + }) + } + valueLen := GetRealTextLength(value) + if valueLen > 1000 { + errs = append(errs, ValidationError{ + Index: idx, Field: "customFields." + key, Code: "1010", + Message: "customFields 값은 최대 1,000자까지 허용됩니다", + }) + } + } + + return errs +} + +// checkDuplicateRecipients finds duplicate recipient phone numbers. +func checkDuplicateRecipients(msgs []types.Message) []ValidationError { + seen := make(map[string]int, len(msgs)) + var errs []ValidationError + + for i, msg := range msgs { + if msg.To == "" { + continue + } + if firstIdx, ok := seen[msg.To]; ok { + errs = append(errs, ValidationError{ + Index: i, Field: "to", Code: "1026", + Message: "중복 수신번호입니다 (메시지 #" + strconv.Itoa(firstIdx+1) + "과 동일)", + }) + } else { + seen[msg.To] = i + } + } + + return errs +} + diff --git a/pkg/validation/common_test.go b/pkg/validation/common_test.go new file mode 100644 index 0000000..5dc01c9 --- /dev/null +++ b/pkg/validation/common_test.go @@ -0,0 +1,309 @@ +package validation + +import ( + "strconv" + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +func TestValidateCommon_ValidMessage(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", From: "01011112222", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) > 0 { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateCommon_EmptyTo(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } + if errs[0].Code != "1010" || errs[0].Field != "to" { + t.Errorf("unexpected error: %+v", errs[0]) + } +} + +func TestValidateCommon_InvalidPhone(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "abc", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d: %v", len(errs), errs) + } + if errs[0].Field != "to" { + t.Errorf("expected field 'to', got %q", errs[0].Field) + } +} + +func TestValidateCommon_PhoneNormalized(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "010-1234-5678", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) > 0 { + t.Errorf("expected no errors, got %v", errs) + } + if msg.To != "01012345678" { + t.Errorf("To not normalized: got %q, want %q", msg.To, "01012345678") + } +} + +func TestValidateCommon_CountryPopulated(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "+82-10-1234-5678", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if msg.Country != "82" { + t.Errorf("msg.Country = %q, want %q", msg.Country, "82") + } + if msg.To != "01012345678" { + t.Errorf("msg.To = %q, want 01012345678", msg.To) + } +} + +func TestValidateCommon_NonKoreanCountryPassthrough(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "+1-555-123-4567", Text: "hello"} + errs := validateCommon(&msg, 0, Options{}) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + // Non-Korean numbers: country stays empty (pass-through) + if msg.Country != "" { + t.Errorf("msg.Country = %q, want empty for non-Korean", msg.Country) + } + if msg.To != "15551234567" { + t.Errorf("msg.To = %q, want 15551234567", msg.To) + } +} + +func TestValidateFrom_Required(t *testing.T) { + tests := []struct { + name string + from string + required bool + wantErr bool + }{ + {name: "required_present", from: "01012345678", required: true, wantErr: false}, + {name: "required_absent", from: "", required: true, wantErr: true}, + {name: "optional_absent", from: "", required: false, wantErr: false}, + {name: "optional_present", from: "01012345678", required: false, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{From: tt.from} + errs := validateFrom(&msg, 0, tt.required) + if (len(errs) > 0) != tt.wantErr { + t.Errorf("validateFrom(from=%q, required=%v) errors=%v, wantErr=%v", + tt.from, tt.required, errs, tt.wantErr) + } + }) + } +} + +func TestValidateCustomFields(t *testing.T) { + tests := []struct { + name string + fields map[string]string + wantN int + }{ + {name: "nil_fields", fields: nil, wantN: 0}, + {name: "empty_fields", fields: map[string]string{}, wantN: 0}, + {name: "valid_single", fields: map[string]string{"key": "value"}, wantN: 0}, + {name: "valid_10_fields", fields: makeNFields(10), wantN: 0}, + {name: "exceeds_10_fields", fields: makeNFields(11), wantN: 1}, + {name: "key_30chars_ok", fields: map[string]string{strings.Repeat("가", 30): "v"}, wantN: 0}, + {name: "key_31chars_fail", fields: map[string]string{strings.Repeat("가", 31): "v"}, wantN: 1}, + {name: "value_1000chars_ok", fields: map[string]string{"k": strings.Repeat("가", 1000)}, wantN: 0}, + {name: "value_1001chars_fail", fields: map[string]string{"k": strings.Repeat("가", 1001)}, wantN: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateCustomFields(tt.fields, 0) + if len(errs) != tt.wantN { + t.Errorf("validateCustomFields() got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + }) + } +} + +func TestCheckDuplicateRecipients(t *testing.T) { + tests := []struct { + name string + msgs []types.Message + wantN int + }{ + { + name: "no_duplicates", + msgs: []types.Message{{To: "01011111111"}, {To: "01022222222"}}, + wantN: 0, + }, + { + name: "one_duplicate", + msgs: []types.Message{{To: "01011111111"}, {To: "01011111111"}}, + wantN: 1, + }, + { + name: "multiple_duplicates", + msgs: []types.Message{ + {To: "01011111111"}, + {To: "01022222222"}, + {To: "01011111111"}, + {To: "01022222222"}, + }, + wantN: 2, + }, + { + name: "empty_to_skipped", + msgs: []types.Message{{To: ""}, {To: ""}}, + wantN: 0, + }, + { + name: "single_message", + msgs: []types.Message{{To: "01011111111"}}, + wantN: 0, + }, + { + name: "empty_list", + msgs: []types.Message{}, + wantN: 0, + }, + { + name: "nil_list", + msgs: nil, + wantN: 0, + }, + { + name: "triple_same_number", + msgs: []types.Message{ + {To: "01011111111"}, + {To: "01011111111"}, + {To: "01011111111"}, + }, + wantN: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := checkDuplicateRecipients(tt.msgs) + if len(errs) != tt.wantN { + t.Errorf("checkDuplicateRecipients() got %d errors, want %d: %v", + len(errs), tt.wantN, errs) + } + // Verify error code + for _, e := range errs { + if e.Code != "1026" { + t.Errorf("expected code 1026, got %q", e.Code) + } + } + }) + } +} + +func TestCheckDuplicateRecipients_MessageContent(t *testing.T) { + t.Run("1_based_index_in_message", func(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01011111111"}, + {To: "01022222222"}, + {To: "01011111111"}, // duplicate of index 0 + } + errs := checkDuplicateRecipients(msgs) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } + if errs[0].Index != 2 { + t.Errorf("expected Index=2, got %d", errs[0].Index) + } + if !strings.Contains(errs[0].Message, "메시지 #1과 동일") { + t.Errorf("expected 1-based reference '메시지 #1과 동일', got %q", errs[0].Message) + } + }) + + t.Run("multiple_duplicates_1_based", func(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01011111111"}, // index 0 + {To: "01011111111"}, // index 1, dup of 0 + {To: "01011111111"}, // index 2, dup of 0 + } + errs := checkDuplicateRecipients(msgs) + if len(errs) != 2 { + t.Fatalf("expected 2 errors, got %d", len(errs)) + } + for _, e := range errs { + if !strings.Contains(e.Message, "메시지 #1과 동일") { + t.Errorf("expected 1-based reference to first occurrence (#1), got %q", e.Message) + } + } + }) +} + +func TestValidationErrors_Error(t *testing.T) { + t.Cleanup(func() {}) + + t.Run("empty", func(t *testing.T) { + var ve ValidationErrors + if ve.Error() != "" { + t.Errorf("empty ValidationErrors.Error() = %q, want empty", ve.Error()) + } + }) + + t.Run("single", func(t *testing.T) { + ve := ValidationErrors{{Index: 0, Field: "to", Code: "1010", Message: "invalid"}} + got := ve.Error() + if !strings.Contains(got, "검증 오류 1건") { + t.Errorf("Error() = %q, want containing '검증 오류 1건'", got) + } + if !strings.Contains(got, "[1] to (1010): invalid") { + t.Errorf("Error() = %q, want containing error detail", got) + } + }) + + t.Run("multiple", func(t *testing.T) { + ve := ValidationErrors{ + {Index: 0, Field: "to", Code: "1010", Message: "a"}, + {Index: 1, Field: "from", Code: "1010", Message: "b"}, + } + got := ve.Error() + if !strings.Contains(got, "검증 오류 2건") { + t.Errorf("Error() = %q, want containing '검증 오류 2건'", got) + } + }) +} + +func TestValidationErrors_HasErrors(t *testing.T) { + t.Cleanup(func() {}) + + var empty ValidationErrors + if empty.HasErrors() { + t.Error("empty ValidationErrors.HasErrors() = true, want false") + } + + nonempty := ValidationErrors{{Index: 0, Field: "to", Code: "1010", Message: "x"}} + if !nonempty.HasErrors() { + t.Error("non-empty ValidationErrors.HasErrors() = false, want true") + } +} + + +func makeNFields(n int) map[string]string { + m := make(map[string]string, n) + for i := 0; i < n; i++ { + m["key"+strconv.Itoa(i)] = "val" + } + return m +} diff --git a/pkg/validation/detect.go b/pkg/validation/detect.go new file mode 100644 index 0000000..9ba0805 --- /dev/null +++ b/pkg/validation/detect.go @@ -0,0 +1,104 @@ +package validation + +import ( + "strings" + + "github.com/solapi/solactl/pkg/types" +) + +// DetectType determines the message type from message content +// following the PRD auto-type detection algorithm. +func DetectType(msg *types.Message) string { + // 1. Kakao options present? + if msg.KakaoOptions != nil { + return detectKakaoType(msg) + } + + // 2. RCS options present? + if msg.RCSOptions != nil { + return detectRCSType(msg) + } + + // 3. Standard SMS/LMS/MMS detection + return detectStandardType(msg) +} + +func detectKakaoType(msg *types.Message) string { + ko := msg.KakaoOptions + + // Check for BMS: templateId starts with KA01BP or bms field present + isBMS := false + if ko.TemplateID != "" && strings.HasPrefix(ko.TemplateID, "KA01BP") { + isBMS = true + } + if ko.BMS != nil { + isBMS = true + } + + if isBMS { + if ko.TemplateID != "" { + // Template-based BMS: type from chatBubbleType + return detectBMSTemplateType(ko) + } + // Free-form BMS + return detectBMSFreeType(ko) + } + + // ATA: templateCode or templateId present (non-KA01BP) + if ko.TemplateID != "" { + return "ATA" + } + + // Fallback: if kakaoOptions present but no template, still ATA-like + return "ATA" +} + +func detectBMSTemplateType(ko *types.KakaoOptions) string { + if ko.BMS != nil && ko.BMS.ChatBubbleType != "" { + return "BMS_" + ko.BMS.ChatBubbleType + } + // Default to BMS_TEXT if no chatBubbleType + return "BMS_TEXT" +} + +func detectBMSFreeType(ko *types.KakaoOptions) string { + if ko.BMS != nil && ko.BMS.ChatBubbleType != "" { + return "BMS_" + ko.BMS.ChatBubbleType + } + return "BMS_TEXT" +} + +func detectRCSType(msg *types.Message) string { + ro := msg.RCSOptions + + // templateId present → RCS_TPL + if ro.TemplateID != "" { + return "RCS_TPL" + } + + // imageId, mmsType, or additionalBody present → RCS_MMS + if msg.ImageID != "" || ro.MmsType != "" { + return "RCS_MMS" + } + + // text > 100 chars or subject present → RCS_LMS + if GetRealTextLength(msg.Text) > 100 || msg.Subject != "" { + return "RCS_LMS" + } + + return "RCS_SMS" +} + +func detectStandardType(msg *types.Message) string { + // imageId present → MMS + if msg.ImageID != "" { + return "MMS" + } + + // text > 90 bytes or subject present → LMS + if GetTextLength(msg.Text) > 90 || msg.Subject != "" { + return "LMS" + } + + return "SMS" +} diff --git a/pkg/validation/detect_test.go b/pkg/validation/detect_test.go new file mode 100644 index 0000000..0fc9d0e --- /dev/null +++ b/pkg/validation/detect_test.go @@ -0,0 +1,337 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +func TestDetectType(t *testing.T) { + tests := []struct { + name string + msg types.Message + want string + }{ + // Standard types + { + name: "sms_short_text", + msg: types.Message{Text: "hello"}, + want: "SMS", + }, + { + name: "sms_exactly_90bytes", + msg: types.Message{Text: strings.Repeat("a", 90)}, + want: "SMS", + }, + { + name: "lms_91bytes", + msg: types.Message{Text: strings.Repeat("a", 91)}, + want: "LMS", + }, + { + name: "lms_korean_46chars_92bytes", + msg: types.Message{Text: strings.Repeat("가", 46)}, + want: "LMS", + }, + { + name: "lms_with_subject", + msg: types.Message{Text: "short", Subject: "제목"}, + want: "LMS", + }, + { + name: "mms_with_imageId", + msg: types.Message{Text: "hello", ImageID: "img-123"}, + want: "MMS", + }, + { + name: "mms_imageId_overrides_length", + msg: types.Message{Text: "short", ImageID: "img-123"}, + want: "MMS", + }, + { + name: "sms_empty_text", + msg: types.Message{Text: ""}, + want: "SMS", + }, + + // Kakao ATA + { + name: "ata_with_templateId", + msg: types.Message{ + Text: "알림톡 내용", + KakaoOptions: &types.KakaoOptions{TemplateID: "TPL_001"}, + }, + want: "ATA", + }, + { + name: "ata_with_pfId_and_templateId", + msg: types.Message{ + Text: "알림톡", + KakaoOptions: &types.KakaoOptions{PfID: "KA01PF", TemplateID: "TPL_002"}, + }, + want: "ATA", + }, + { + name: "ata_kakaoOptions_no_template", + msg: types.Message{ + Text: "알림톡", + KakaoOptions: &types.KakaoOptions{PfID: "KA01PF"}, + }, + want: "ATA", + }, + + // BMS Template types + { + name: "bms_text_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_TEXT_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "TEXT", Targeting: "I"}, + }, + }, + want: "BMS_TEXT", + }, + { + name: "bms_image_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_IMG_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "IMAGE", Targeting: "M"}, + }, + }, + want: "BMS_IMAGE", + }, + { + name: "bms_wide_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_WIDE_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "WIDE", Targeting: "N"}, + }, + }, + want: "BMS_WIDE", + }, + { + name: "bms_wide_item_list_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_WIL_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "WIDE_ITEM_LIST"}, + }, + }, + want: "BMS_WIDE_ITEM_LIST", + }, + { + name: "bms_commerce_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_COM_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "COMMERCE"}, + }, + }, + want: "BMS_COMMERCE", + }, + { + name: "bms_carousel_feed_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_CF_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "CAROUSEL_FEED"}, + }, + }, + want: "BMS_CAROUSEL_FEED", + }, + { + name: "bms_carousel_commerce_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_CC_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "CAROUSEL_COMMERCE"}, + }, + }, + want: "BMS_CAROUSEL_COMMERCE", + }, + { + name: "bms_premium_video_template", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_PV_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "PREMIUM_VIDEO"}, + }, + }, + want: "BMS_PREMIUM_VIDEO", + }, + + // BMS Free (no templateId) + { + name: "bms_free_text", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "TEXT", Targeting: "I"}, + }, + }, + want: "BMS_TEXT", + }, + { + name: "bms_free_image", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "IMAGE", Targeting: "M"}, + }, + }, + want: "BMS_IMAGE", + }, + { + name: "bms_free_wide", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "WIDE", Targeting: "N"}, + }, + }, + want: "BMS_WIDE", + }, + { + name: "bms_free_no_bubbletype_defaults_text", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF", + BMS: &types.KakaoBMSOptions{Targeting: "I"}, + }, + }, + want: "BMS_TEXT", + }, + { + name: "bms_KA01BP_prefix_without_bms_field", + msg: types.Message{ + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_TEXT_001", + }, + }, + want: "BMS_TEXT", + }, + + // RCS types + { + name: "rcs_sms_short", + msg: types.Message{ + Text: "짧은 메시지", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + want: "RCS_SMS", + }, + { + name: "rcs_sms_exactly_100chars", + msg: types.Message{ + Text: strings.Repeat("가", 100), + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + want: "RCS_SMS", + }, + { + name: "rcs_lms_101chars", + msg: types.Message{ + Text: strings.Repeat("가", 101), + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + want: "RCS_LMS", + }, + { + name: "rcs_lms_with_subject", + msg: types.Message{ + Text: "짧은", + Subject: "제목", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + want: "RCS_LMS", + }, + { + name: "rcs_mms_with_imageId", + msg: types.Message{ + Text: "짧은", + ImageID: "img-1", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + want: "RCS_MMS", + }, + { + name: "rcs_mms_with_mmsType", + msg: types.Message{ + Text: "짧은", + RCSOptions: &types.RCSOptions{BrandID: "brand-1", MmsType: "M3"}, + }, + want: "RCS_MMS", + }, + { + name: "rcs_tpl_with_templateId", + msg: types.Message{ + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + want: "RCS_TPL", + }, + { + name: "rcs_tpl_overrides_mmsType", + msg: types.Message{ + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1", MmsType: "M3"}, + }, + want: "RCS_TPL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + got := DetectType(&tt.msg) + if got != tt.want { + t.Errorf("DetectType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDetectType_PriorityOrder(t *testing.T) { + // Kakao options take priority over RCS and standard + t.Run("kakao_over_rcs", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{ + Text: "text", + KakaoOptions: &types.KakaoOptions{TemplateID: "TPL_001"}, + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + } + got := DetectType(&msg) + if got != "ATA" { + t.Errorf("DetectType() = %q, want ATA (kakao priority)", got) + } + }) + + // Kakao options take priority over imageId (MMS) + t.Run("kakao_over_mms", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{ + Text: "text", + ImageID: "img-1", + KakaoOptions: &types.KakaoOptions{TemplateID: "TPL_001"}, + } + got := DetectType(&msg) + if got != "ATA" { + t.Errorf("DetectType() = %q, want ATA (kakao priority over imageId)", got) + } + }) + + // RCS takes priority over standard MMS + t.Run("rcs_over_mms", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{ + Text: "text", + ImageID: "img-1", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + } + got := DetectType(&msg) + if got != "RCS_MMS" { + t.Errorf("DetectType() = %q, want RCS_MMS (rcs priority)", got) + } + }) +} diff --git a/pkg/validation/errors.go b/pkg/validation/errors.go new file mode 100644 index 0000000..1701e19 --- /dev/null +++ b/pkg/validation/errors.go @@ -0,0 +1,42 @@ +package validation + +import ( + "fmt" + "strings" +) + +// ValidationError represents a single validation error for a message. +type ValidationError struct { + Index int // message index in the batch (0-based) + Field string // field path (e.g., "to", "kakaoOptions.pfId") + Code string // SOLAPI error code (e.g., "1010", "1031") + Message string // Korean error message +} + +// ValidationErrors is a collection of validation errors. +type ValidationErrors []ValidationError + +// Error implements the error interface. +func (ve ValidationErrors) Error() string { + if len(ve) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "검증 오류 %d건", len(ve)) + for _, e := range ve { + fmt.Fprintf(&b, "\n[%d] %s (%s): %s", e.Index+1, e.Field, e.Code, e.Message) + } + return b.String() +} + +// HasErrors returns true if there are any validation errors. +func (ve ValidationErrors) HasErrors() bool { + return len(ve) > 0 +} + +// Options controls validation behavior. +type Options struct { + Strict bool // strict mode: enforce template matching, subject requirements + AllowDuplicates bool // allow duplicate recipients + AutoTypeDetect bool // auto-detect message type when Type is empty +} diff --git a/pkg/validation/phone.go b/pkg/validation/phone.go new file mode 100644 index 0000000..35f75e2 --- /dev/null +++ b/pkg/validation/phone.go @@ -0,0 +1,122 @@ +package validation + +import ( + "regexp" + "strings" +) + +var phoneDigitsOnly = regexp.MustCompile(`^[0-9]{5,25}$`) + +// NormalizePhone strips all characters except digits and leading '+' from +// a phone number string. A '+' is considered "leading" if no digits have +// been emitted yet, even if non-digit characters precede it in the input +// (e.g., "(+82)" preserves the '+'). Formatting characters (-, space, +// parentheses), non-ASCII characters, and any other non-digit characters +// are removed. +func NormalizePhone(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '+' && b.Len() == 0: + // keep leading '+' only when nothing has been written yet + b.WriteRune(r) + default: + // drop all other characters (formatting, letters, symbols, non-ASCII) + } + } + return b.String() +} + +// ParsePhone normalizes a phone number and extracts an optional country code. +// Returns (normalized digits, country code string, error). +// Currently only supports Korean country code (+82). Other country code +// prefixes are passed through as-is (server handles validation). +func ParsePhone(s string) (number string, country string, err error) { + cleaned := NormalizePhone(s) + if cleaned == "" { + return "", "", &PhoneError{Input: s, Reason: "수신번호가 비어 있습니다"} + } + + // Parse country code from leading '+' + if strings.HasPrefix(cleaned, "+") { + rest := cleaned[1:] + if len(rest) == 0 { + return "", "", &PhoneError{Input: s, Reason: "국가 코드를 파싱할 수 없습니다"} + } + country, number = extractCountryCode(rest) + if country == "" { + // Non-Korean country code: pass through entire digits without + // client-side normalization. Server handles country code validation. + number = rest + country = "" + } + } else { + number = cleaned + country = "" + } + + // Korea-specific normalization: prepend 0 for numbers starting with 1. + // Only applies when country code is explicitly "82" (from + prefix). + if country == "82" && len(number) > 0 && number[0] == '1' { + if !isKoreanSpecialNumber(number) { + number = "0" + number + } + } + + // Validate final digit-only format + if !phoneDigitsOnly.MatchString(number) { + if len(number) < 5 { + return "", "", &PhoneError{Input: s, Reason: "수신번호가 너무 짧습니다 (최소 5자리)"} + } + if len(number) > 25 { + return "", "", &PhoneError{Input: s, Reason: "수신번호가 너무 깁니다 (최대 25자리)"} + } + return "", "", &PhoneError{Input: s, Reason: "수신번호는 숫자만 포함해야 합니다"} + } + + return number, country, nil +} + +// extractCountryCode extracts the country code from a digit string after '+'. +// Supports Korean country code "82" explicitly. For other prefixes, +// returns empty to indicate no client-side country code extraction +// (server handles validation). +func extractCountryCode(s string) (country, rest string) { + if len(s) < 2 { + return "", "" + } + + // Check for Korea (+82) explicitly + if strings.HasPrefix(s, "82") { + return "82", s[2:] + } + + // For other country codes, pass the entire number through as-is. + // The server handles country code validation for non-Korean numbers. + // We return empty country to indicate no client-side normalization needed. + return "", "" +} + +// isKoreanSpecialNumber checks if a number starting with '1' is a +// special 8-digit Korean number (15xx, 16xx, 18xx) that should NOT +// have 0 prepended. +func isKoreanSpecialNumber(number string) bool { + if len(number) != 8 { + return false + } + prefix := number[:2] + return prefix == "15" || prefix == "16" || prefix == "18" +} + +// PhoneError represents a phone number validation error. +type PhoneError struct { + Input string + Reason string +} + +func (e *PhoneError) Error() string { + return e.Reason + ": " + e.Input +} diff --git a/pkg/validation/phone_test.go b/pkg/validation/phone_test.go new file mode 100644 index 0000000..ea2cbad --- /dev/null +++ b/pkg/validation/phone_test.go @@ -0,0 +1,253 @@ +package validation + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestNormalizePhone(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "digits_only", input: "01012345678", want: "01012345678"}, + {name: "with_dashes", input: "010-1234-5678", want: "01012345678"}, + {name: "with_spaces", input: "010 1234 5678", want: "01012345678"}, + {name: "with_parens", input: "(02) 999-9999", want: "029999999"}, + {name: "with_plus", input: "+82-10-1234-5678", want: "+821012345678"}, + {name: "mixed_formatting", input: "(+82) 10-1234-5678", want: "+821012345678"}, + {name: "leading_space_plus", input: " +821012345678", want: "+821012345678"}, + {name: "letters_stripped", input: "010abc5678", want: "0105678"}, + {name: "symbols_stripped", input: "010#1234*5678", want: "01012345678"}, + {name: "plus_only_at_start", input: "82+10", want: "8210"}, + {name: "non_ascii_stripped", input: "010가1234나5678", want: "01012345678"}, + {name: "empty", input: "", want: ""}, + {name: "only_formatting", input: "- () ", want: ""}, + {name: "only_non_ascii", input: "가나다", want: ""}, + {name: "already_clean", input: "12345", want: "12345"}, + {name: "long_number", input: "1234567890123456789012345", want: "1234567890123456789012345"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + got := NormalizePhone(tt.input) + if got != tt.want { + t.Errorf("NormalizePhone(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParsePhone_Valid(t *testing.T) { + tests := []struct { + name string + input string + wantNumber string + wantCountry string + }{ + {name: "korean_mobile", input: "01012345678", wantNumber: "01012345678", wantCountry: ""}, + {name: "korean_formatted", input: "010-1234-5678", wantNumber: "01012345678", wantCountry: ""}, + {name: "korean_landline", input: "02-999-9999", wantNumber: "029999999", wantCountry: ""}, + {name: "with_country_82", input: "+82-10-1234-5678", wantNumber: "01012345678", wantCountry: "82"}, + {name: "five_digits_minimum", input: "12345", wantNumber: "12345", wantCountry: ""}, + {name: "twenty_five_digits_max", input: strings.Repeat("1", 25), wantNumber: strings.Repeat("1", 25), wantCountry: ""}, + {name: "korean_special_15xx_no_prepend", input: "+82-1588-1234", wantNumber: "15881234", wantCountry: "82"}, + {name: "korean_special_16xx_no_prepend", input: "+82-1688-5678", wantNumber: "16885678", wantCountry: "82"}, + {name: "korean_special_18xx_no_prepend", input: "+82-1899-0000", wantNumber: "18990000", wantCountry: "82"}, + {name: "korean_mobile_with_country_prepend_0", input: "+82-10-9999-8888", wantNumber: "01099998888", wantCountry: "82"}, + {name: "us_number_passthrough", input: "+1-555-123-4567", wantNumber: "15551234567", wantCountry: ""}, + {name: "uk_number_passthrough", input: "+44-20-1234-5678", wantNumber: "442012345678", wantCountry: ""}, + // Parenthesized +82: leading '+' is preserved (b.Len()==0 check), + // so it's correctly parsed as international +82 with 0-prepend. + {name: "parens_82_international", input: "(+82) 10-1234-5678", wantNumber: "01012345678", wantCountry: "82"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + number, country, err := ParsePhone(tt.input) + if err != nil { + t.Fatalf("ParsePhone(%q) unexpected error: %v", tt.input, err) + } + if number != tt.wantNumber { + t.Errorf("ParsePhone(%q) number = %q, want %q", tt.input, number, tt.wantNumber) + } + if country != tt.wantCountry { + t.Errorf("ParsePhone(%q) country = %q, want %q", tt.input, country, tt.wantCountry) + } + }) + } +} + +func TestParsePhone_Invalid(t *testing.T) { + tests := []struct { + name string + input string + wantMsg string + }{ + {name: "empty", input: "", wantMsg: "비어 있습니다"}, + {name: "only_formatting", input: "- () ", wantMsg: "비어 있습니다"}, + {name: "only_non_ascii", input: "가나다", wantMsg: "비어 있습니다"}, + {name: "four_digits_too_short", input: "1234", wantMsg: "너무 짧습니다"}, + {name: "twenty_six_digits_too_long", input: strings.Repeat("1", 26), wantMsg: "너무 깁니다"}, + {name: "plus_only", input: "+", wantMsg: "파싱할 수 없습니다"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + _, _, err := ParsePhone(tt.input) + if err == nil { + t.Fatalf("ParsePhone(%q) expected error, got nil", tt.input) + } + if !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("ParsePhone(%q) error = %q, want containing %q", tt.input, err.Error(), tt.wantMsg) + } + }) + } +} + +func TestParsePhone_Boundary(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {name: "4_digits_fail", input: "1234", wantErr: true}, + {name: "5_digits_pass", input: "12345", wantErr: false}, + {name: "25_digits_pass", input: strings.Repeat("1", 25), wantErr: false}, + {name: "26_digits_fail", input: strings.Repeat("1", 26), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + _, _, err := ParsePhone(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePhone(%q) err = %v, wantErr = %v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestParsePhone_KoreaCountryCode82_PrependZero(t *testing.T) { + tests := []struct { + name string + input string + wantNumber string + }{ + // Numbers starting with 1 should get 0 prepended (except special 8-digit 15xx/16xx/18xx) + {name: "10_mobile_prepend", input: "+82-10-1234-5678", wantNumber: "01012345678"}, + {name: "11_old_mobile_prepend", input: "+82-11-1234-5678", wantNumber: "01112345678"}, + {name: "12_prepend", input: "+82-12-345-6789", wantNumber: "0123456789"}, + // Special 8-digit numbers: no prepend + {name: "1588_no_prepend", input: "+82-1588-1234", wantNumber: "15881234"}, + {name: "1600_no_prepend", input: "+82-1600-1234", wantNumber: "16001234"}, + {name: "1899_no_prepend", input: "+82-1899-0000", wantNumber: "18990000"}, + // 15xx but 9 digits: should prepend (not 8-digit special) + {name: "1588_9digits_prepend", input: "+82-1588-12345", wantNumber: "0158812345"}, + // Number not starting with 1: no prepend + {name: "2_landline_no_prepend", input: "+82-2-1234-5678", wantNumber: "212345678"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + number, _, err := ParsePhone(tt.input) + if err != nil { + t.Fatalf("ParsePhone(%q) unexpected error: %v", tt.input, err) + } + if number != tt.wantNumber { + t.Errorf("ParsePhone(%q) = %q, want %q", tt.input, number, tt.wantNumber) + } + }) + } +} + +func TestParsePhone_NoCountryCode_NoPrepend(t *testing.T) { + // Without explicit +82 prefix, no 0-prepend happens even for numbers starting with 1. + // The 0-prepend only applies when country code is explicitly "82". + t.Cleanup(func() {}) + + number, country, err := ParsePhone("1012345678") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if country != "" { + t.Errorf("country = %q, want empty", country) + } + if number != "1012345678" { + t.Errorf("number = %q, want 1012345678", number) + } +} + +func TestPhoneError(t *testing.T) { + t.Cleanup(func() {}) + e := &PhoneError{Input: "abc", Reason: "잘못된 형식"} + got := e.Error() + if got != "잘못된 형식: abc" { + t.Errorf("PhoneError.Error() = %q, want %q", got, "잘못된 형식: abc") + } +} + +func FuzzNormalizePhone(f *testing.F) { + f.Add("") + f.Add("01012345678") + f.Add("+82-10-1234-5678") + f.Add("(02) 999-9999") + f.Add("가나다") + f.Add(strings.Repeat("1", 100)) + + f.Fuzz(func(t *testing.T, s string) { + if !utf8.ValidString(s) { + return + } + result := NormalizePhone(s) + // Result should not contain formatting chars + for _, r := range result { + if r == '-' || r == ' ' || r == '(' || r == ')' { + t.Errorf("NormalizePhone(%q) contains formatting char %q", s, string(r)) + } + if r > 0x7F { + t.Errorf("NormalizePhone(%q) contains non-ASCII char %q", s, string(r)) + } + } + }) +} + +func FuzzParsePhone(f *testing.F) { + f.Add("01012345678") + f.Add("+82-10-1234-5678") + f.Add("") + f.Add("1234") + f.Add(strings.Repeat("9", 30)) + f.Add("abc") + + f.Fuzz(func(t *testing.T, s string) { + if !utf8.ValidString(s) { + return + } + // ParsePhone must never panic + number, country, err := ParsePhone(s) + if err != nil { + return + } + // Valid results should have digits-only number + for _, r := range number { + if r < '0' || r > '9' { + t.Errorf("ParsePhone(%q) number %q contains non-digit", s, number) + } + } + // Country should be digits only or empty + for _, r := range country { + if r < '0' || r > '9' { + t.Errorf("ParsePhone(%q) country %q contains non-digit", s, country) + } + } + _ = number + _ = country + }) +} diff --git a/pkg/validation/textlen.go b/pkg/validation/textlen.go new file mode 100644 index 0000000..98be952 --- /dev/null +++ b/pkg/validation/textlen.go @@ -0,0 +1,39 @@ +// Package validation provides client-side message validation for solactl CLI. +package validation + +// GetTextLength calculates text length using EUC-KR-style byte counting. +// ASCII (0x00-0x7F) = 1 byte, everything else = 2 bytes. +// Used for SMS (≤90), LMS/MMS text (≤2000), Subject (≤40). +func GetTextLength(s string) int { + n := 0 + for _, r := range s { + if r <= 0x7F { + n++ + } else { + n += 2 + } + } + return n +} + +// GetRealTextLength returns the Unicode character count (number of runes). +// Used for ATA (≤1000 chars), RCS_SMS (≤100), RCS_LMS (≤1300), RCS_MMS (≤1300), RCS_TPL (≤2600). +func GetRealTextLength(s string) int { + return len([]rune(s)) +} + +// GetJSStringLength returns the equivalent of JavaScript's string.length, +// which counts UTF-16 code units. Characters in the BMP (U+0000–U+FFFF) +// count as 1; supplementary characters (U+10000+) count as 2 (surrogate pair). +// Used for all BMS content fields. +func GetJSStringLength(s string) int { + n := 0 + for _, r := range s { + if r > 0xFFFF { + n += 2 + } else { + n++ + } + } + return n +} diff --git a/pkg/validation/textlen_test.go b/pkg/validation/textlen_test.go new file mode 100644 index 0000000..908a7b3 --- /dev/null +++ b/pkg/validation/textlen_test.go @@ -0,0 +1,207 @@ +package validation + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestGetTextLength(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {name: "empty", input: "", want: 0}, + {name: "ascii_single", input: "a", want: 1}, + {name: "ascii_90chars", input: strings.Repeat("a", 90), want: 90}, + {name: "ascii_91chars", input: strings.Repeat("a", 91), want: 91}, + {name: "korean_single", input: "가", want: 2}, + {name: "korean_45chars_boundary_90bytes", input: strings.Repeat("가", 45), want: 90}, + {name: "korean_46chars_boundary_92bytes", input: strings.Repeat("가", 46), want: 92}, + {name: "mixed_ascii_korean", input: "hello가나", want: 5 + 4}, + {name: "mixed_boundary_88ascii_1korean", input: strings.Repeat("a", 88) + "가", want: 90}, + {name: "mixed_boundary_89ascii_1korean", input: strings.Repeat("a", 89) + "가", want: 91}, + {name: "japanese_katakana", input: "アイウ", want: 6}, + {name: "chinese_char", input: "中文", want: 4}, + {name: "emoji_basic", input: "😀", want: 2}, + {name: "digits", input: "01012345678", want: 11}, + {name: "space", input: " ", want: 1}, + {name: "newline", input: "\n", want: 1}, + {name: "tab", input: "\t", want: 1}, + {name: "null_byte", input: "\x00", want: 1}, + {name: "del_0x7F", input: "\x7F", want: 1}, + {name: "0x80_boundary", input: "\u0080", want: 2}, + {name: "max_bmp", input: "\uFFFF", want: 2}, + {name: "supplementary_char", input: "𠀀", want: 2}, + {name: "lms_2000bytes", input: strings.Repeat("가", 1000), want: 2000}, + {name: "lms_2001bytes", input: strings.Repeat("가", 1000) + "a", want: 2001}, + {name: "subject_40bytes", input: strings.Repeat("가", 20), want: 40}, + {name: "subject_41bytes", input: strings.Repeat("가", 20) + "a", want: 41}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + got := GetTextLength(tt.input) + if got != tt.want { + t.Errorf("GetTextLength(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestGetRealTextLength(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {name: "empty", input: "", want: 0}, + {name: "ascii_single", input: "a", want: 1}, + {name: "korean_single", input: "가", want: 1}, + {name: "korean_1000chars", input: strings.Repeat("가", 1000), want: 1000}, + {name: "korean_1001chars", input: strings.Repeat("가", 1001), want: 1001}, + {name: "mixed", input: "hello가나다", want: 8}, + {name: "emoji_as_one_rune", input: "😀", want: 1}, + {name: "supplementary_char", input: "𠀀", want: 1}, + {name: "rcs_sms_100chars", input: strings.Repeat("가", 100), want: 100}, + {name: "rcs_sms_101chars", input: strings.Repeat("가", 101), want: 101}, + {name: "rcs_lms_1300chars", input: strings.Repeat("가", 1300), want: 1300}, + {name: "rcs_tpl_2600chars", input: strings.Repeat("가", 2600), want: 2600}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + got := GetRealTextLength(tt.input) + if got != tt.want { + t.Errorf("GetRealTextLength(%q) = %d, want %d", truncateForLog(tt.input), got, tt.want) + } + }) + } +} + +func TestGetJSStringLength(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {name: "empty", input: "", want: 0}, + {name: "ascii_single", input: "a", want: 1}, + {name: "korean_single", input: "가", want: 1}, + {name: "bmp_char", input: "\uFFFF", want: 1}, + {name: "supplementary_emoji_surrogate_pair", input: "😀", want: 2}, + {name: "supplementary_cjk", input: "𠀀", want: 2}, + {name: "mixed_bmp_and_supplementary", input: "abc😀def", want: 8}, + {name: "multiple_supplementary", input: "😀😁😂", want: 6}, + {name: "korean_1300chars", input: strings.Repeat("가", 1300), want: 1300}, + {name: "bms_text_boundary", input: strings.Repeat("가", 1300), want: 1300}, + {name: "bms_wide_76chars", input: strings.Repeat("가", 76), want: 76}, + {name: "bms_wide_77chars", input: strings.Repeat("가", 77), want: 77}, + {name: "only_supplementary_1char", input: "𝄞", want: 2}, + {name: "combining_chars", input: "e\u0301", want: 2}, // e + combining accent = 2 BMP runes = 2 code units + {name: "precomposed_char", input: "\u00E9", want: 1}, // precomposed é = 1 code unit + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + got := GetJSStringLength(tt.input) + if got != tt.want { + t.Errorf("GetJSStringLength(%q) = %d, want %d", truncateForLog(tt.input), got, tt.want) + } + }) + } +} + +func TestGetTextLength_VsUTF8ByteLength(t *testing.T) { + // Verify that GetTextLength differs from UTF-8 byte length for non-ASCII + t.Cleanup(func() {}) + + korean := "가" // UTF-8: 3 bytes, EUC-KR style: 2 bytes + utf8Len := len([]byte(korean)) + textLen := GetTextLength(korean) + + if utf8Len != 3 { + t.Errorf("UTF-8 byte length of '가' = %d, want 3", utf8Len) + } + if textLen != 2 { + t.Errorf("GetTextLength('가') = %d, want 2", textLen) + } + if utf8Len == textLen { + t.Error("GetTextLength should differ from UTF-8 byte length for Korean chars") + } +} + +func TestGetJSStringLength_VsRuneCount(t *testing.T) { + // Verify that GetJSStringLength differs from rune count for supplementary chars + t.Cleanup(func() {}) + + emoji := "😀" // 1 rune, but 2 UTF-16 code units + runeCount := utf8.RuneCountInString(emoji) + jsLen := GetJSStringLength(emoji) + + if runeCount != 1 { + t.Errorf("rune count of '😀' = %d, want 1", runeCount) + } + if jsLen != 2 { + t.Errorf("GetJSStringLength('😀') = %d, want 2", jsLen) + } +} + +func FuzzGetTextLength(f *testing.F) { + f.Add("") + f.Add("hello") + f.Add("가나다") + f.Add("hello가나다") + f.Add("😀") + f.Add("\x00\x7F\x80") + + f.Fuzz(func(t *testing.T, s string) { + if !utf8.ValidString(s) { + return + } + n := GetTextLength(s) + if n < 0 { + t.Errorf("GetTextLength returned negative: %d", n) + } + // Text length should be >= rune count (since non-ASCII runes count as 2) + runeCount := utf8.RuneCountInString(s) + if n < runeCount { + t.Errorf("GetTextLength(%d) < rune count(%d)", n, runeCount) + } + }) +} + +func FuzzGetJSStringLength(f *testing.F) { + f.Add("") + f.Add("hello") + f.Add("가나다") + f.Add("😀😁😂") + f.Add("𠀀") + + f.Fuzz(func(t *testing.T, s string) { + if !utf8.ValidString(s) { + return + } + n := GetJSStringLength(s) + if n < 0 { + t.Errorf("GetJSStringLength returned negative: %d", n) + } + // JS string length should be >= rune count + runeCount := utf8.RuneCountInString(s) + if n < runeCount { + t.Errorf("GetJSStringLength(%d) < rune count(%d)", n, runeCount) + } + }) +} + +// truncateForLog truncates a string for test log output. +func truncateForLog(s string) string { + if len(s) > 40 { + return s[:40] + "..." + } + return s +} diff --git a/pkg/validation/validate.go b/pkg/validation/validate.go new file mode 100644 index 0000000..3342019 --- /dev/null +++ b/pkg/validation/validate.go @@ -0,0 +1,59 @@ +package validation + +import ( + "fmt" + "strings" + + "github.com/solapi/solactl/pkg/types" +) + +// ValidateMessages validates all messages and returns collected errors. +// Returns nil if all messages are valid. +// This function may mutate msg.To (phone normalization) and msg.Type (auto-detection). +func ValidateMessages(msgs []types.Message, opts Options) ValidationErrors { + var errs ValidationErrors + + for i := range msgs { + // Common validation (phone normalization, customFields) + errs = append(errs, validateCommon(&msgs[i], i, opts)...) + + // Auto-detect type if not set + if msgs[i].Type == "" && opts.AutoTypeDetect { + msgs[i].Type = DetectType(&msgs[i]) + } + + // Type-specific validation + msgType := msgs[i].Type + switch { + case msgType == "SMS": + errs = append(errs, validateSMS(&msgs[i], i, opts)...) + case msgType == "LMS": + errs = append(errs, validateLMS(&msgs[i], i, opts)...) + case msgType == "MMS": + errs = append(errs, validateMMS(&msgs[i], i, opts)...) + case msgType == "ATA": + errs = append(errs, validateATA(&msgs[i], i, opts)...) + case strings.HasPrefix(msgType, "BMS"): + errs = append(errs, validateBMS(&msgs[i], i, msgType, opts)...) + case strings.HasPrefix(msgType, "RCS"): + errs = append(errs, validateRCS(&msgs[i], i, msgType, opts)...) + case msgType == "": + // No type set and auto-detect disabled — skip type-specific validation + default: + errs = append(errs, ValidationError{ + Index: i, Field: "type", Code: "1010", + Message: fmt.Sprintf("알 수 없는 메시지 타입입니다: %q", msgType), + }) + } + } + + // Duplicate recipient check + if !opts.AllowDuplicates { + errs = append(errs, checkDuplicateRecipients(msgs)...) + } + + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/pkg/validation/validate_bms.go b/pkg/validation/validate_bms.go new file mode 100644 index 0000000..ab01bb2 --- /dev/null +++ b/pkg/validation/validate_bms.go @@ -0,0 +1,279 @@ +package validation + +import ( + "fmt" + "strings" + + "github.com/solapi/solactl/pkg/types" +) + +// validateBMS validates a BMS (Brand Message Service) message. +// detectedType should be one of: BMS_TEXT, BMS_IMAGE, BMS_WIDE, +// BMS_WIDE_ITEM_LIST, BMS_COMMERCE, BMS_CAROUSEL_FEED, +// BMS_CAROUSEL_COMMERCE, BMS_PREMIUM_VIDEO. +func validateBMS(msg *types.Message, idx int, detectedType string, _ Options) []ValidationError { + var errs []ValidationError + + // kakaoOptions required + if msg.KakaoOptions == nil { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions", Code: "1010", + Message: "BMS는 kakaoOptions가 필수입니다", + }) + return errs + } + + ko := msg.KakaoOptions + + // pfId or senderKey required + if ko.PfID == "" && ko.SenderKey == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.pfId", Code: "1010", + Message: "BMS는 pfId 또는 senderKey가 필수입니다 (--pf-id)", + }) + } + + // targeting required + if ko.BMS == nil || ko.BMS.Targeting == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.bms.targeting", Code: "1083", + Message: "BMS는 targeting이 필수입니다 (--targeting: I, M, N)", + }) + } else { + t := ko.BMS.Targeting + if t != "I" && t != "M" && t != "N" { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.bms.targeting", Code: "1083", + Message: fmt.Sprintf("targeting 값이 올바르지 않습니다: %q (허용: I, M, N)", t), + }) + } + } + + // Template-based BMS: text/imageId are provided by the template, so + // content validation is skipped — only common fields are validated. + isTemplate := ko.TemplateID != "" + if isTemplate { + return errs + } + + // Free-form BMS: type-specific content validation + switch detectedType { + case "BMS_TEXT": + errs = append(errs, validateBMSText(msg, idx)...) + case "BMS_IMAGE": + errs = append(errs, validateBMSImage(msg, idx)...) + case "BMS_WIDE": + errs = append(errs, validateBMSWide(msg, idx)...) + case "BMS_WIDE_ITEM_LIST": + errs = append(errs, validateBMSWideItemList(msg, idx)...) + case "BMS_COMMERCE": + errs = append(errs, validateBMSCommerce(msg, idx)...) + case "BMS_CAROUSEL_FEED": + errs = append(errs, validateBMSCarousel(msg, idx, "CAROUSEL_FEED")...) + case "BMS_CAROUSEL_COMMERCE": + errs = append(errs, validateBMSCarousel(msg, idx, "CAROUSEL_COMMERCE")...) + case "BMS_PREMIUM_VIDEO": + errs = append(errs, validateBMSPremiumVideo(msg, idx)...) + } + + return errs +} + +func validateBMSText(msg *types.Message, idx int) []ValidationError { + var errs []ValidationError + + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "BMS_TEXT는 본문(content)이 필수입니다", + }) + } else { + textLen := GetJSStringLength(msg.Text) + if textLen > 1300 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("BMS_TEXT 본문은 1,300자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + if countNewlines(msg.Text) > 99 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: "BMS_TEXT 본문의 줄바꿈은 99개 이하여야 합니다", + }) + } + } + + errs = append(errs, validateBMSButtons(msg, idx, 5, 14)...) + return errs +} + +func validateBMSImage(msg *types.Message, idx int) []ValidationError { + var errs []ValidationError + + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "BMS_IMAGE는 본문(content)이 필수입니다", + }) + } else { + textLen := GetJSStringLength(msg.Text) + if textLen > 1300 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("BMS_IMAGE 본문은 1,300자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + if msg.ImageID == "" && (msg.KakaoOptions == nil || msg.KakaoOptions.ImageID == "") { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "BMS_IMAGE는 이미지가 필수입니다", + }) + } + + errs = append(errs, validateBMSButtons(msg, idx, 5, 14)...) + return errs +} + +func validateBMSWide(msg *types.Message, idx int) []ValidationError { + var errs []ValidationError + + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "BMS_WIDE는 본문(content)이 필수입니다", + }) + } else { + textLen := GetJSStringLength(msg.Text) + if textLen > 76 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("BMS_WIDE 본문은 76자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + if countNewlines(msg.Text) > 1 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: "BMS_WIDE 본문의 줄바꿈은 1개 이하여야 합니다", + }) + } + } + + if msg.ImageID == "" && (msg.KakaoOptions == nil || msg.KakaoOptions.ImageID == "") { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "BMS_WIDE는 이미지가 필수입니다", + }) + } + + errs = append(errs, validateBMSButtons(msg, idx, 2, 8)...) + return errs +} + +func validateBMSWideItemList(_ *types.Message, _ int) []ValidationError { + // WIDE_ITEM_LIST requires complex structured fields (header, mainWideItem, subWideItemList) + // which are not directly exposed as CLI flags. Minimal validation here. + return nil +} + +func validateBMSCommerce(msg *types.Message, idx int) []ValidationError { + var errs []ValidationError + + if msg.ImageID == "" && (msg.KakaoOptions == nil || msg.KakaoOptions.ImageID == "") { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "BMS_COMMERCE는 이미지가 필수입니다", + }) + } + + // buttons 1-2, only WL/AL + if msg.KakaoOptions != nil { + nButtons := len(msg.KakaoOptions.Buttons) + if nButtons < 1 { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.buttons", Code: "1010", + Message: "BMS_COMMERCE는 버튼이 최소 1개 필수입니다", + }) + } + if nButtons > 2 { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.buttons", Code: "1044", + Message: fmt.Sprintf("BMS_COMMERCE 버튼은 최대 2개입니다 (현재: %d개)", nButtons), + }) + } + for i, btn := range msg.KakaoOptions.Buttons { + if btn.ButtonType != "WL" && btn.ButtonType != "AL" { + errs = append(errs, ValidationError{ + Index: idx, Field: fmt.Sprintf("kakaoOptions.buttons[%d].buttonType", i), Code: "1010", + Message: fmt.Sprintf("BMS_COMMERCE 버튼은 WL 또는 AL만 허용됩니다 (현재: %q)", btn.ButtonType), + }) + } + } + } + + return errs +} + +func validateBMSCarousel(_ *types.Message, _ int, _ string) []ValidationError { + // CAROUSEL types require complex structured carousel fields + // which are not directly exposed as CLI flags. Minimal validation. + return nil +} + +func validateBMSPremiumVideo(msg *types.Message, idx int) []ValidationError { + var errs []ValidationError + + if msg.Text != "" { + textLen := GetJSStringLength(msg.Text) + if textLen > 76 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("BMS_PREMIUM_VIDEO 본문은 76자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // buttons max 1 + if msg.KakaoOptions != nil && len(msg.KakaoOptions.Buttons) > 1 { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.buttons", Code: "1044", + Message: fmt.Sprintf("BMS_PREMIUM_VIDEO 버튼은 최대 1개입니다 (현재: %d개)", len(msg.KakaoOptions.Buttons)), + }) + } + + return errs +} + +// validateBMSButtons validates button count and name length for BMS types. +func validateBMSButtons(msg *types.Message, idx int, maxButtons int, maxNameLen int) []ValidationError { + if msg.KakaoOptions == nil { + return nil + } + var errs []ValidationError + + nButtons := len(msg.KakaoOptions.Buttons) + if nButtons > maxButtons { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.buttons", Code: "1044", + Message: fmt.Sprintf("버튼은 최대 %d개입니다 (현재: %d개)", maxButtons, nButtons), + }) + } + + for i, btn := range msg.KakaoOptions.Buttons { + nameLen := GetJSStringLength(btn.ButtonName) + if nameLen > maxNameLen { + errs = append(errs, ValidationError{ + Index: idx, Field: fmt.Sprintf("kakaoOptions.buttons[%d].buttonName", i), Code: "1010", + Message: fmt.Sprintf("버튼명은 최대 %d자입니다 (현재: %d자)", maxNameLen, nameLen), + }) + } + } + + return errs +} + +// countNewlines counts the number of newline characters in a string. +func countNewlines(s string) int { + return strings.Count(s, "\n") +} diff --git a/pkg/validation/validate_bms_test.go b/pkg/validation/validate_bms_test.go new file mode 100644 index 0000000..060fb15 --- /dev/null +++ b/pkg/validation/validate_bms_test.go @@ -0,0 +1,391 @@ +package validation + +import ( + "fmt" + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +// validBMSKakaoOptions returns KakaoOptions for template-based BMS (skips content validation). +func validBMSKakaoOptions(bubbleType string) *types.KakaoOptions { + return &types.KakaoOptions{ + PfID: "KA01PF_001", + TemplateID: "KA01BP_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: bubbleType, Targeting: "I"}, + } +} + +// freeBMSKakaoOptions returns KakaoOptions for free-form BMS (content validation applied). +func freeBMSKakaoOptions(bubbleType string) *types.KakaoOptions { + return &types.KakaoOptions{ + PfID: "KA01PF_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: bubbleType, Targeting: "I"}, + } +} + +func TestValidateBMS_CommonFields(t *testing.T) { + tests := []struct { + name string + msg types.Message + wantField string + }{ + { + name: "kakaoOptions_nil", + msg: types.Message{To: "01012345678"}, + wantField: "kakaoOptions", + }, + { + name: "pfId_missing", + msg: types.Message{ + To: "01012345678", + KakaoOptions: &types.KakaoOptions{ + TemplateID: "KA01BP_001", + BMS: &types.KakaoBMSOptions{Targeting: "I"}, + }, + }, + wantField: "kakaoOptions.pfId", + }, + { + name: "targeting_missing", + msg: types.Message{ + To: "01012345678", + KakaoOptions: &types.KakaoOptions{PfID: "KA01PF_001", TemplateID: "KA01BP_001"}, + }, + wantField: "kakaoOptions.bms.targeting", + }, + { + name: "targeting_invalid_value", + msg: types.Message{ + To: "01012345678", + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF_001", TemplateID: "KA01BP_001", + BMS: &types.KakaoBMSOptions{Targeting: "X"}, + }, + }, + wantField: "kakaoOptions.bms.targeting", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateBMS(&tt.msg, 0, "BMS_TEXT", Options{}) + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on field %q, got: %v", tt.wantField, errs) + } + }) + } +} + +func TestValidateBMS_AllBubbleTypes(t *testing.T) { + // Test that all 8 bubble types are dispatched without panic + bubbleTypes := []string{ + "BMS_TEXT", "BMS_IMAGE", "BMS_WIDE", + "BMS_WIDE_ITEM_LIST", "BMS_COMMERCE", + "BMS_CAROUSEL_FEED", "BMS_CAROUSEL_COMMERCE", + "BMS_PREMIUM_VIDEO", + } + + for _, bt := range bubbleTypes { + t.Run(bt, func(t *testing.T) { + t.Cleanup(func() {}) + shortType := strings.TrimPrefix(bt, "BMS_") + ko := validBMSKakaoOptions(shortType) + msg := types.Message{ + To: "01012345678", + Text: "content", + ImageID: "img-1", + KakaoOptions: ko, + } + // Should not panic + errs := validateBMS(&msg, 0, bt, Options{}) + _ = errs + }) + } +} + +func TestValidateBMS_TEXT(t *testing.T) { + tests := []struct { + name string + text string + wantN int + }{ + {name: "valid", text: "hello", wantN: 0}, + {name: "1300chars_ok", text: strings.Repeat("가", 1300), wantN: 0}, + {name: "1301chars_fail", text: strings.Repeat("가", 1301), wantN: 1}, + {name: "empty_text_fail", text: "", wantN: 1}, + {name: "99newlines_ok", text: strings.Repeat("\n", 99) + "a", wantN: 0}, + {name: "100newlines_fail", text: strings.Repeat("\n", 100) + "a", wantN: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: tt.text, KakaoOptions: freeBMSKakaoOptions("TEXT")} + errs := validateBMS(&msg, 0, "BMS_TEXT", Options{}) + // Filter to text-related errors only + textErrs := 0 + for _, e := range errs { + if e.Field == "text" { + textErrs++ + } + } + if textErrs != tt.wantN { + t.Errorf("text errors = %d, want %d (all errs: %v)", textErrs, tt.wantN, errs) + } + }) + } +} + +func TestValidateBMS_IMAGE(t *testing.T) { + t.Run("imageId_required", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: "text", KakaoOptions: freeBMSKakaoOptions("IMAGE")} + errs := validateBMS(&msg, 0, "BMS_IMAGE", Options{}) + found := false + for _, e := range errs { + if e.Field == "imageId" { + found = true + } + } + if !found { + t.Error("expected imageId error for BMS_IMAGE without image") + } + }) + + t.Run("imageId_present_ok", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: "text", ImageID: "img-1", KakaoOptions: freeBMSKakaoOptions("IMAGE")} + errs := validateBMS(&msg, 0, "BMS_IMAGE", Options{}) + for _, e := range errs { + if e.Field == "imageId" { + t.Errorf("unexpected imageId error: %v", e) + } + } + }) +} + +func TestValidateBMS_WIDE(t *testing.T) { + tests := []struct { + name string + text string + wantField string + }{ + {name: "76chars_ok", text: strings.Repeat("가", 76)}, + {name: "77chars_fail", text: strings.Repeat("가", 77), wantField: "text"}, + {name: "1newline_ok", text: "line1\nline2"}, + {name: "2newlines_fail", text: "line1\nline2\nline3", wantField: "text"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: tt.text, ImageID: "img-1", KakaoOptions: freeBMSKakaoOptions("WIDE")} + errs := validateBMS(&msg, 0, "BMS_WIDE", Options{}) + if tt.wantField != "" { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on %q, got: %v", tt.wantField, errs) + } + } + }) + } + + t.Run("buttons_max_2", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("WIDE") + ko.Buttons = make([]types.KakaoButton, 3) + msg := types.Message{To: "01012345678", Text: "text", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_WIDE", Options{}) + found := false + for _, e := range errs { + if e.Field == "kakaoOptions.buttons" { + found = true + } + } + if !found { + t.Error("expected button count error for 3 buttons in BMS_WIDE") + } + }) + + t.Run("button_name_max_8chars", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("WIDE") + ko.Buttons = []types.KakaoButton{{ButtonName: strings.Repeat("가", 9)}} + msg := types.Message{To: "01012345678", Text: "text", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_WIDE", Options{}) + found := false + for _, e := range errs { + if strings.Contains(e.Field, "buttonName") { + found = true + } + } + if !found { + t.Error("expected button name length error") + } + }) +} + +func TestValidateBMS_COMMERCE(t *testing.T) { + t.Run("imageId_required", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("COMMERCE") + ko.Buttons = []types.KakaoButton{{ButtonType: "WL", ButtonName: "클릭"}} + msg := types.Message{To: "01012345678", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_COMMERCE", Options{}) + found := false + for _, e := range errs { + if e.Field == "imageId" { + found = true + } + } + if !found { + t.Error("expected imageId error") + } + }) + + t.Run("buttons_min_1", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("COMMERCE") + msg := types.Message{To: "01012345678", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_COMMERCE", Options{}) + found := false + for _, e := range errs { + if e.Field == "kakaoOptions.buttons" { + found = true + } + } + if !found { + t.Error("expected min button error") + } + }) + + t.Run("buttons_max_2", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("COMMERCE") + ko.Buttons = make([]types.KakaoButton, 3) + msg := types.Message{To: "01012345678", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_COMMERCE", Options{}) + found := false + for _, e := range errs { + if e.Field == "kakaoOptions.buttons" && strings.Contains(e.Message, "최대 2개") { + found = true + } + } + if !found { + t.Error("expected max button error") + } + }) + + t.Run("only_WL_AL_allowed", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("COMMERCE") + ko.Buttons = []types.KakaoButton{{ButtonType: "BK", ButtonName: "키워드"}} + msg := types.Message{To: "01012345678", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_COMMERCE", Options{}) + found := false + for _, e := range errs { + if strings.Contains(e.Field, "buttonType") { + found = true + } + } + if !found { + t.Error("expected buttonType error for non-WL/AL") + } + }) + + t.Run("empty_buttonType_rejected", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("COMMERCE") + ko.Buttons = []types.KakaoButton{{ButtonType: "", ButtonName: "클릭"}} + msg := types.Message{To: "01012345678", ImageID: "img-1", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_COMMERCE", Options{}) + found := false + for _, e := range errs { + if strings.Contains(e.Field, "buttonType") { + found = true + } + } + if !found { + t.Error("expected buttonType error for empty buttonType") + } + }) +} + +func TestValidateBMS_PREMIUM_VIDEO(t *testing.T) { + t.Run("text_76chars_ok", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: strings.Repeat("가", 76), KakaoOptions: freeBMSKakaoOptions("PREMIUM_VIDEO")} + errs := validateBMS(&msg, 0, "BMS_PREMIUM_VIDEO", Options{}) + for _, e := range errs { + if e.Field == "text" { + t.Errorf("unexpected text error: %v", e) + } + } + }) + + t.Run("text_77chars_fail", func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: strings.Repeat("가", 77), KakaoOptions: freeBMSKakaoOptions("PREMIUM_VIDEO")} + errs := validateBMS(&msg, 0, "BMS_PREMIUM_VIDEO", Options{}) + found := false + for _, e := range errs { + if e.Field == "text" { + found = true + } + } + if !found { + t.Error("expected text length error") + } + }) + + t.Run("buttons_max_1", func(t *testing.T) { + t.Cleanup(func() {}) + ko := freeBMSKakaoOptions("PREMIUM_VIDEO") + ko.Buttons = make([]types.KakaoButton, 2) + msg := types.Message{To: "01012345678", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_PREMIUM_VIDEO", Options{}) + found := false + for _, e := range errs { + if e.Field == "kakaoOptions.buttons" { + found = true + } + } + if !found { + t.Error("expected button count error") + } + }) +} + +func TestValidateBMS_TargetingValues(t *testing.T) { + validValues := []string{"I", "M", "N"} + for _, v := range validValues { + t.Run(fmt.Sprintf("targeting_%s_ok", v), func(t *testing.T) { + t.Cleanup(func() {}) + ko := &types.KakaoOptions{ + PfID: "KA01PF", TemplateID: "KA01BP_001", + BMS: &types.KakaoBMSOptions{ChatBubbleType: "TEXT", Targeting: v}, + } + msg := types.Message{To: "01012345678", Text: "content", KakaoOptions: ko} + errs := validateBMS(&msg, 0, "BMS_TEXT", Options{}) + for _, e := range errs { + if e.Field == "kakaoOptions.bms.targeting" { + t.Errorf("unexpected targeting error for %q: %v", v, e) + } + } + }) + } +} diff --git a/pkg/validation/validate_kakao.go b/pkg/validation/validate_kakao.go new file mode 100644 index 0000000..7acef08 --- /dev/null +++ b/pkg/validation/validate_kakao.go @@ -0,0 +1,68 @@ +package validation + +import ( + "fmt" + + "github.com/solapi/solactl/pkg/types" +) + +// validateATA validates a Kakao AlimTalk (ATA) message. +func validateATA(msg *types.Message, idx int, _ Options) []ValidationError { + var errs []ValidationError + + // from is optional for Kakao types + // errs = append(errs, validateFrom(msg, idx, false)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "ATA 본문(--text)은 필수입니다", + }) + } else { + textLen := GetRealTextLength(msg.Text) + if textLen > 1000 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("ATA 본문은 1,000자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // kakaoOptions required + if msg.KakaoOptions == nil { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions", Code: "1010", + Message: "ATA는 kakaoOptions가 필수입니다", + }) + return errs + } + + ko := msg.KakaoOptions + + // pfId or senderKey required + if ko.PfID == "" && ko.SenderKey == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.pfId", Code: "1010", + Message: "ATA는 pfId 또는 senderKey가 필수입니다 (--pf-id)", + }) + } + + // templateId required + if ko.TemplateID == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.templateId", Code: "1010", + Message: "ATA는 templateId가 필수입니다 (--template-id)", + }) + } + + // buttons max 5 + if len(ko.Buttons) > 5 { + errs = append(errs, ValidationError{ + Index: idx, Field: "kakaoOptions.buttons", Code: "1044", + Message: fmt.Sprintf("ATA 버튼은 최대 5개입니다 (현재: %d개)", len(ko.Buttons)), + }) + } + + return errs +} diff --git a/pkg/validation/validate_kakao_test.go b/pkg/validation/validate_kakao_test.go new file mode 100644 index 0000000..baee57e --- /dev/null +++ b/pkg/validation/validate_kakao_test.go @@ -0,0 +1,197 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +func TestValidateATA(t *testing.T) { + validKO := func() *types.KakaoOptions { + return &types.KakaoOptions{PfID: "KA01PF_001", TemplateID: "TPL_001"} + } + + tests := []struct { + name string + msg types.Message + wantN int + wantField string + }{ + { + name: "valid_ata", + msg: types.Message{ + To: "01012345678", Text: "알림톡 내용", + KakaoOptions: validKO(), + }, + wantN: 0, + }, + { + name: "valid_ata_1000chars", + msg: types.Message{ + To: "01012345678", Text: strings.Repeat("가", 1000), + KakaoOptions: validKO(), + }, + wantN: 0, + }, + { + name: "text_exceeds_1000chars", + msg: types.Message{ + To: "01012345678", Text: strings.Repeat("가", 1001), + KakaoOptions: validKO(), + }, + wantN: 1, + wantField: "text", + }, + { + name: "text_empty", + msg: types.Message{ + To: "01012345678", + KakaoOptions: validKO(), + }, + wantN: 1, + wantField: "text", + }, + { + name: "kakaoOptions_nil", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + }, + wantN: 1, + wantField: "kakaoOptions", + }, + { + name: "pfId_missing", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{TemplateID: "TPL_001"}, + }, + wantN: 1, + wantField: "kakaoOptions.pfId", + }, + { + name: "senderKey_as_alternative", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{SenderKey: "SK_001", TemplateID: "TPL_001"}, + }, + wantN: 0, + }, + { + name: "templateId_missing", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{PfID: "KA01PF_001"}, + }, + wantN: 1, + wantField: "kakaoOptions.templateId", + }, + { + name: "buttons_5_ok", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF_001", TemplateID: "TPL_001", + Buttons: make([]types.KakaoButton, 5), + }, + }, + wantN: 0, + }, + { + name: "buttons_6_exceeds", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{ + PfID: "KA01PF_001", TemplateID: "TPL_001", + Buttons: make([]types.KakaoButton, 6), + }, + }, + wantN: 1, + wantField: "kakaoOptions.buttons", + }, + { + name: "from_optional_no_error", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: validKO(), + // From intentionally empty + }, + wantN: 0, + }, + { + name: "from_present_ok", + msg: types.Message{ + To: "01012345678", From: "01011112222", Text: "알림톡", + KakaoOptions: validKO(), + }, + wantN: 0, + }, + { + name: "multiple_errors_pfId_and_templateId", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{}, + }, + wantN: 2, // pfId + templateId + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateATA(&tt.msg, 0, Options{}) + if len(errs) != tt.wantN { + t.Errorf("validateATA() got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + break + } + } + if !found { + t.Errorf("expected error on field %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateATA_TextBoundary(t *testing.T) { + ko := &types.KakaoOptions{PfID: "KA01PF_001", TemplateID: "TPL_001"} + + tests := []struct { + name string + text string + wantErr bool + }{ + {name: "999chars_ok", text: strings.Repeat("가", 999), wantErr: false}, + {name: "1000chars_ok", text: strings.Repeat("가", 1000), wantErr: false}, + {name: "1001chars_fail", text: strings.Repeat("가", 1001), wantErr: true}, + {name: "1000_ascii_ok", text: strings.Repeat("a", 1000), wantErr: false}, + {name: "1001_ascii_fail", text: strings.Repeat("a", 1001), wantErr: true}, + {name: "mixed_999korean_1ascii_ok", text: strings.Repeat("가", 999) + "a", wantErr: false}, + {name: "emoji_counts_as_1", text: strings.Repeat("😀", 1000), wantErr: false}, + {name: "emoji_1001_fail", text: strings.Repeat("😀", 1001), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + msg := types.Message{To: "01012345678", Text: tt.text, KakaoOptions: ko} + errs := validateATA(&msg, 0, Options{}) + hasTextErr := false + for _, e := range errs { + if e.Field == "text" { + hasTextErr = true + } + } + if hasTextErr != tt.wantErr { + t.Errorf("text length %d chars: hasTextErr=%v, want %v", + GetRealTextLength(tt.text), hasTextErr, tt.wantErr) + } + }) + } +} diff --git a/pkg/validation/validate_rcs.go b/pkg/validation/validate_rcs.go new file mode 100644 index 0000000..4cc1db8 --- /dev/null +++ b/pkg/validation/validate_rcs.go @@ -0,0 +1,202 @@ +package validation + +import ( + "fmt" + + "github.com/solapi/solactl/pkg/types" +) + +// validateRCS validates an RCS message by sub-type. +func validateRCS(msg *types.Message, idx int, detectedType string, opts Options) []ValidationError { + switch detectedType { + case "RCS_SMS": + return validateRCSSMS(msg, idx, opts) + case "RCS_LMS": + return validateRCSLMS(msg, idx, opts) + case "RCS_MMS": + return validateRCSMMS(msg, idx, opts) + case "RCS_TPL": + return validateRCSTPL(msg, idx, opts) + default: + return []ValidationError{{ + Index: idx, Field: "type", Code: "1010", + Message: fmt.Sprintf("알 수 없는 RCS 서브타입입니다: %q", detectedType), + }} + } +} + +func validateRCSSMS(msg *types.Message, idx int, _ Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + errs = append(errs, validateRCSBrandID(msg, idx)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "RCS_SMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetRealTextLength(msg.Text) + if textLen > 100 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("RCS_SMS 본문은 100자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // imageId forbidden + if msg.ImageID != "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "imageId는 RCS_SMS에서 사용할 수 없습니다 (RCS_MMS를 사용하세요)", + }) + } + + return errs +} + +func validateRCSLMS(msg *types.Message, idx int, opts Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + errs = append(errs, validateRCSBrandID(msg, idx)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "RCS_LMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetRealTextLength(msg.Text) + if textLen > 1300 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("RCS_LMS 본문은 1,300자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // subject max 30 chars + if msg.Subject != "" { + subjectLen := GetRealTextLength(msg.Subject) + if subjectLen > 30 { + if opts.Strict { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1014", + Message: fmt.Sprintf("RCS_LMS 제목은 30자 이하여야 합니다 (현재: %d자)", subjectLen), + }) + } + // non-strict: auto-truncation on server + } + } + + // imageId forbidden + if msg.ImageID != "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "imageId는 RCS_LMS에서 사용할 수 없습니다 (RCS_MMS를 사용하세요)", + }) + } + + return errs +} + +func validateRCSMMS(msg *types.Message, idx int, opts Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + errs = append(errs, validateRCSBrandID(msg, idx)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "RCS_MMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetRealTextLength(msg.Text) + if textLen > 1300 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("RCS_MMS 본문은 1,300자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // imageId required unless mmsType is set (template may provide the image) + hasMmsType := msg.RCSOptions != nil && msg.RCSOptions.MmsType != "" + if msg.ImageID == "" && !hasMmsType { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "RCS_MMS는 이미지(--image) 또는 mmsType이 필요합니다", + }) + } + + // subject max 30 chars + if msg.Subject != "" { + subjectLen := GetRealTextLength(msg.Subject) + if subjectLen > 30 { + if opts.Strict { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1014", + Message: fmt.Sprintf("RCS_MMS 제목은 30자 이하여야 합니다 (현재: %d자)", subjectLen), + }) + } + } + } + + return errs +} + +func validateRCSTPL(msg *types.Message, idx int, _ Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + errs = append(errs, validateRCSBrandID(msg, idx)...) + + // templateId required (via RCSOptions) + if msg.RCSOptions == nil || msg.RCSOptions.TemplateID == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "rcsOptions.templateId", Code: "1010", + Message: "RCS_TPL은 templateId가 필수입니다 (--template-id)", + }) + } + + // text max 2600 chars + if msg.Text != "" { + textLen := GetRealTextLength(msg.Text) + if textLen > 2600 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("RCS_TPL 본문은 2,600자 이하여야 합니다 (현재: %d자)", textLen), + }) + } + } + + // subject max 60 chars + if msg.Subject != "" { + subjectLen := GetRealTextLength(msg.Subject) + if subjectLen > 60 { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1014", + Message: fmt.Sprintf("RCS_TPL 제목은 60자 이하여야 합니다 (현재: %d자)", subjectLen), + }) + } + } + + return errs +} + +func validateRCSBrandID(msg *types.Message, idx int) []ValidationError { + if msg.RCSOptions == nil || msg.RCSOptions.BrandID == "" { + return []ValidationError{{ + Index: idx, Field: "rcsOptions.brandId", Code: "1010", + Message: "RCS는 brandId가 필수입니다 (--brand-id)", + }} + } + return nil +} diff --git a/pkg/validation/validate_rcs_test.go b/pkg/validation/validate_rcs_test.go new file mode 100644 index 0000000..4e5c0fa --- /dev/null +++ b/pkg/validation/validate_rcs_test.go @@ -0,0 +1,328 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +func validRCSMsg(text string) types.Message { + return types.Message{ + To: "01012345678", From: "01011112222", Text: text, + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + } +} + +func TestValidateRCS_SMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + wantN int + wantField string + }{ + { + name: "valid", + msg: validRCSMsg("짧은 메시지"), + wantN: 0, + }, + { + name: "100chars_ok", + msg: validRCSMsg(strings.Repeat("가", 100)), + wantN: 0, + }, + { + name: "101chars_fail", + msg: validRCSMsg(strings.Repeat("가", 101)), + wantN: 1, + wantField: "text", + }, + { + name: "text_empty", + msg: types.Message{ + To: "01012345678", From: "01011112222", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + wantN: 1, + wantField: "text", + }, + { + name: "from_missing", + msg: types.Message{ + To: "01012345678", Text: "text", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + wantN: 1, + wantField: "from", + }, + { + name: "brandId_missing", + msg: types.Message{ + To: "01012345678", From: "01011112222", Text: "text", + RCSOptions: &types.RCSOptions{}, + }, + wantN: 1, + wantField: "rcsOptions.brandId", + }, + { + name: "imageId_forbidden", + msg: types.Message{ + To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + wantN: 1, + wantField: "imageId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateRCS(&tt.msg, 0, "RCS_SMS", Options{}) + if len(errs) != tt.wantN { + t.Errorf("got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateRCS_LMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + opts Options + wantN int + wantField string + }{ + { + name: "valid", + msg: validRCSMsg(strings.Repeat("가", 200)), + wantN: 0, + }, + { + name: "1300chars_ok", + msg: validRCSMsg(strings.Repeat("가", 1300)), + wantN: 0, + }, + { + name: "1301chars_fail", + msg: validRCSMsg(strings.Repeat("가", 1301)), + wantN: 1, + wantField: "text", + }, + { + name: "subject_30chars_ok", + msg: func() types.Message { + m := validRCSMsg("text") + m.Subject = strings.Repeat("가", 30) + return m + }(), + wantN: 0, + }, + { + name: "subject_31chars_strict_fail", + msg: func() types.Message { + m := validRCSMsg("text") + m.Subject = strings.Repeat("가", 31) + return m + }(), + opts: Options{Strict: true}, + wantN: 1, + wantField: "subject", + }, + { + name: "subject_31chars_nonstrict_ok", + msg: func() types.Message { + m := validRCSMsg("text") + m.Subject = strings.Repeat("가", 31) + return m + }(), + opts: Options{Strict: false}, + wantN: 0, + }, + { + name: "imageId_forbidden", + msg: func() types.Message { + m := validRCSMsg("text") + m.ImageID = "img-1" + return m + }(), + wantN: 1, + wantField: "imageId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateRCS(&tt.msg, 0, "RCS_LMS", tt.opts) + if len(errs) != tt.wantN { + t.Errorf("got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateRCS_MMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + wantN int + wantField string + }{ + { + name: "valid", + msg: func() types.Message { + m := validRCSMsg("text") + m.ImageID = "img-1" + return m + }(), + wantN: 0, + }, + { + name: "imageId_missing", + msg: validRCSMsg("text"), + wantN: 1, + wantField: "imageId", + }, + { + name: "text_1301chars_fail", + msg: func() types.Message { + m := validRCSMsg(strings.Repeat("가", 1301)) + m.ImageID = "img-1" + return m + }(), + wantN: 1, + wantField: "text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateRCS(&tt.msg, 0, "RCS_MMS", Options{}) + if len(errs) != tt.wantN { + t.Errorf("got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateRCS_TPL(t *testing.T) { + tests := []struct { + name string + msg types.Message + wantN int + wantField string + }{ + { + name: "valid", + msg: types.Message{ + To: "01012345678", From: "01011112222", + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + wantN: 0, + }, + { + name: "templateId_missing", + msg: types.Message{ + To: "01012345678", From: "01011112222", + RCSOptions: &types.RCSOptions{BrandID: "brand-1"}, + }, + wantN: 1, + wantField: "rcsOptions.templateId", + }, + { + name: "text_2600chars_ok", + msg: types.Message{ + To: "01012345678", From: "01011112222", + Text: strings.Repeat("가", 2600), + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + wantN: 0, + }, + { + name: "text_2601chars_fail", + msg: types.Message{ + To: "01012345678", From: "01011112222", + Text: strings.Repeat("가", 2601), + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + wantN: 1, + wantField: "text", + }, + { + name: "subject_60chars_ok", + msg: types.Message{ + To: "01012345678", From: "01011112222", + Subject: strings.Repeat("가", 60), + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + wantN: 0, + }, + { + name: "subject_61chars_fail", + msg: types.Message{ + To: "01012345678", From: "01011112222", + Subject: strings.Repeat("가", 61), + RCSOptions: &types.RCSOptions{BrandID: "brand-1", TemplateID: "tpl-1"}, + }, + wantN: 1, + wantField: "subject", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateRCS(&tt.msg, 0, "RCS_TPL", Options{}) + if len(errs) != tt.wantN { + t.Errorf("got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + } + } + if !found { + t.Errorf("expected error on %q, got: %v", tt.wantField, errs) + } + } + }) + } +} diff --git a/pkg/validation/validate_sms.go b/pkg/validation/validate_sms.go new file mode 100644 index 0000000..3e39f18 --- /dev/null +++ b/pkg/validation/validate_sms.go @@ -0,0 +1,147 @@ +package validation + +import ( + "fmt" + + "github.com/solapi/solactl/pkg/types" +) + +// validateSMS validates an SMS message. +func validateSMS(msg *types.Message, idx int, opts Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "SMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetTextLength(msg.Text) + if textLen > 90 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("SMS 본문은 90바이트 이하여야 합니다 (현재: %d바이트)", textLen), + }) + } + } + + // subject forbidden + if msg.Subject != "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1010", + Message: "subject 필드는 SMS 타입에서 사용할 수 없습니다 (LMS로 전환하세요)", + }) + } + + // imageId forbidden + if msg.ImageID != "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "imageId 필드는 SMS 타입에서 사용할 수 없습니다 (MMS로 전환하세요)", + }) + } + + return errs +} + +// validateLMS validates an LMS message. +func validateLMS(msg *types.Message, idx int, opts Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "LMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetTextLength(msg.Text) + if textLen > 2000 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("LMS 본문은 2,000바이트 이하여야 합니다 (현재: %d바이트)", textLen), + }) + } + } + + // subject validation + if msg.Subject != "" { + subjectLen := GetTextLength(msg.Subject) + if opts.Strict && subjectLen > 40 { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1014", + Message: fmt.Sprintf("제목은 40바이트 이하여야 합니다 (현재: %d바이트)", subjectLen), + }) + } + // non-strict: auto-truncation is done server-side, no error + } else if opts.Strict { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1010", + Message: "strict 모드에서 LMS 제목(--subject)은 필수입니다", + }) + } + + // imageId forbidden + if msg.ImageID != "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "imageId 필드는 LMS 타입에서 사용할 수 없습니다 (MMS로 전환하세요)", + }) + } + + return errs +} + +// validateMMS validates an MMS message. +func validateMMS(msg *types.Message, idx int, opts Options) []ValidationError { + var errs []ValidationError + + errs = append(errs, validateFrom(msg, idx, true)...) + + // text required + if msg.Text == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1010", + Message: "MMS 본문(--text)은 필수입니다", + }) + } else { + textLen := GetTextLength(msg.Text) + if textLen > 2000 { + errs = append(errs, ValidationError{ + Index: idx, Field: "text", Code: "1031", + Message: fmt.Sprintf("MMS 본문은 2,000바이트 이하여야 합니다 (현재: %d바이트)", textLen), + }) + } + } + + // imageId required + if msg.ImageID == "" { + errs = append(errs, ValidationError{ + Index: idx, Field: "imageId", Code: "1010", + Message: "MMS는 이미지(--image)가 필수입니다", + }) + } + + // subject validation (same as LMS) + if msg.Subject != "" { + subjectLen := GetTextLength(msg.Subject) + if opts.Strict && subjectLen > 40 { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1014", + Message: fmt.Sprintf("제목은 40바이트 이하여야 합니다 (현재: %d바이트)", subjectLen), + }) + } + } else if opts.Strict { + errs = append(errs, ValidationError{ + Index: idx, Field: "subject", Code: "1010", + Message: "strict 모드에서 MMS 제목(--subject)은 필수입니다", + }) + } + + return errs +} diff --git a/pkg/validation/validate_sms_test.go b/pkg/validation/validate_sms_test.go new file mode 100644 index 0000000..a40b5b6 --- /dev/null +++ b/pkg/validation/validate_sms_test.go @@ -0,0 +1,269 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/solapi/solactl/pkg/types" +) + +func TestValidateSMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + opts Options + wantN int + wantField string + }{ + { + name: "valid_sms", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "hello"}, + wantN: 0, + }, + { + name: "valid_sms_exactly_90bytes", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 90)}, + wantN: 0, + }, + { + name: "text_91bytes_exceeds", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 91)}, + wantN: 1, + wantField: "text", + }, + { + name: "korean_45chars_90bytes_ok", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("가", 45)}, + wantN: 0, + }, + { + name: "korean_46chars_92bytes_exceeds", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("가", 46)}, + wantN: 1, + wantField: "text", + }, + { + name: "mixed_88ascii_1korean_90bytes_ok", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 88) + "가"}, + wantN: 0, + }, + { + name: "mixed_89ascii_1korean_91bytes_exceeds", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 89) + "가"}, + wantN: 1, + wantField: "text", + }, + { + name: "text_empty", + msg: types.Message{To: "01012345678", From: "01011112222", Text: ""}, + wantN: 1, + wantField: "text", + }, + { + name: "from_missing", + msg: types.Message{To: "01012345678", From: "", Text: "hello"}, + wantN: 1, + wantField: "from", + }, + { + name: "subject_forbidden", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "hello", Subject: "제목"}, + wantN: 1, + wantField: "subject", + }, + { + name: "imageId_forbidden", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "hello", ImageID: "img-1"}, + wantN: 1, + wantField: "imageId", + }, + { + name: "multiple_errors", + msg: types.Message{To: "01012345678", From: "", Text: "", Subject: "제목", ImageID: "img-1"}, + wantN: 4, // from + text + subject + imageId + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateSMS(&tt.msg, 0, tt.opts) + if len(errs) != tt.wantN { + t.Errorf("validateSMS() got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + break + } + } + if !found { + t.Errorf("expected error on field %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateLMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + opts Options + wantN int + wantField string + }{ + { + name: "valid_lms", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 100)}, + wantN: 0, + }, + { + name: "valid_lms_2000bytes", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("가", 1000)}, + wantN: 0, + }, + { + name: "text_exceeds_2000bytes", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("가", 1001)}, + wantN: 1, + wantField: "text", + }, + { + name: "valid_with_subject_40bytes", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", Subject: strings.Repeat("가", 20)}, + wantN: 0, + }, + { + name: "subject_exceeds_40bytes_strict", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", Subject: strings.Repeat("가", 21)}, + opts: Options{Strict: true}, + wantN: 1, + wantField: "subject", + }, + { + name: "subject_exceeds_40bytes_nonstrict_no_error", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", Subject: strings.Repeat("가", 21)}, + opts: Options{Strict: false}, + wantN: 0, + }, + { + name: "subject_required_in_strict", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text"}, + opts: Options{Strict: true}, + wantN: 1, + wantField: "subject", + }, + { + name: "subject_optional_in_nonstrict", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text"}, + opts: Options{Strict: false}, + wantN: 0, + }, + { + name: "imageId_forbidden", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1"}, + wantN: 1, + wantField: "imageId", + }, + { + name: "from_missing", + msg: types.Message{To: "01012345678", Text: "text"}, + wantN: 1, + wantField: "from", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateLMS(&tt.msg, 0, tt.opts) + if len(errs) != tt.wantN { + t.Errorf("validateLMS() got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + break + } + } + if !found { + t.Errorf("expected error on field %q, got: %v", tt.wantField, errs) + } + } + }) + } +} + +func TestValidateMMS(t *testing.T) { + tests := []struct { + name string + msg types.Message + opts Options + wantN int + wantField string + }{ + { + name: "valid_mms", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1"}, + wantN: 0, + }, + { + name: "imageId_missing", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text"}, + wantN: 1, + wantField: "imageId", + }, + { + name: "text_exceeds_2000bytes", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("가", 1001), ImageID: "img-1"}, + wantN: 1, + wantField: "text", + }, + { + name: "subject_required_strict", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1"}, + opts: Options{Strict: true}, + wantN: 1, + wantField: "subject", + }, + { + name: "subject_optional_nonstrict", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1"}, + opts: Options{Strict: false}, + wantN: 0, + }, + { + name: "from_missing", + msg: types.Message{To: "01012345678", Text: "text", ImageID: "img-1"}, + wantN: 1, + wantField: "from", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + errs := validateMMS(&tt.msg, 0, tt.opts) + if len(errs) != tt.wantN { + t.Errorf("validateMMS() got %d errors, want %d: %v", len(errs), tt.wantN, errs) + } + if tt.wantField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantField { + found = true + break + } + } + if !found { + t.Errorf("expected error on field %q, got: %v", tt.wantField, errs) + } + } + }) + } +} diff --git a/pkg/validation/validate_test.go b/pkg/validation/validate_test.go new file mode 100644 index 0000000..cd9fd8f --- /dev/null +++ b/pkg/validation/validate_test.go @@ -0,0 +1,223 @@ +package validation + +import ( + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/solapi/solactl/pkg/types" +) + +func TestValidateMessages_ValidSMS(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs != nil { + t.Errorf("expected no errors, got %v", errs) + } + if msgs[0].Type != "SMS" { + t.Errorf("expected auto-detected type SMS, got %q", msgs[0].Type) + } +} + +func TestValidateMessages_AutoTypeDetect(t *testing.T) { + tests := []struct { + name string + msg types.Message + wantType string + }{ + { + name: "sms", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "short"}, + wantType: "SMS", + }, + { + name: "lms_long_text", + msg: types.Message{To: "01012345678", From: "01011112222", Text: strings.Repeat("a", 91)}, + wantType: "LMS", + }, + { + name: "mms_with_image", + msg: types.Message{To: "01012345678", From: "01011112222", Text: "text", ImageID: "img-1"}, + wantType: "MMS", + }, + { + name: "ata_with_kakao", + msg: types.Message{ + To: "01012345678", Text: "알림톡", + KakaoOptions: &types.KakaoOptions{PfID: "pf", TemplateID: "tpl"}, + }, + wantType: "ATA", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{tt.msg} + _ = ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if msgs[0].Type != tt.wantType { + t.Errorf("auto-detect = %q, want %q", msgs[0].Type, tt.wantType) + } + }) + } +} + +func TestValidateMessages_NoAutoTypeDetect(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + } + _ = ValidateMessages(msgs, Options{AutoTypeDetect: false}) + if msgs[0].Type != "" { + t.Errorf("type should remain empty when auto-detect disabled, got %q", msgs[0].Type) + } +} + +func TestValidateMessages_ExplicitType(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello", Type: "LMS"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs != nil { + t.Errorf("expected no errors, got %v", errs) + } + // Type should remain as explicitly set + if msgs[0].Type != "LMS" { + t.Errorf("type should remain LMS, got %q", msgs[0].Type) + } +} + +func TestValidateMessages_MultipleMessages(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + {To: "01099998888", From: "01011112222", Text: "world"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs != nil { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateMessages_MultipleErrors(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "", Text: ""}, // to empty, text empty, from missing + {To: "abc", Text: "hello"}, // invalid phone + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs == nil { + t.Fatal("expected errors, got nil") + } + if len(errs) < 2 { + t.Errorf("expected at least 2 errors, got %d", len(errs)) + } +} + +func TestValidateMessages_DuplicateRecipients(t *testing.T) { + t.Run("duplicates_rejected_by_default", func(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + {To: "01012345678", From: "01011112222", Text: "world"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs == nil { + t.Fatal("expected duplicate error") + } + found := false + for _, e := range errs { + if e.Code == "1026" { + found = true + } + } + if !found { + t.Errorf("expected code 1026 for duplicates, got: %v", errs) + } + }) + + t.Run("duplicates_allowed", func(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + {To: "01012345678", From: "01011112222", Text: "world"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true, AllowDuplicates: true}) + // Should not have duplicate errors (may have other errors) + for _, e := range errs { + if e.Code == "1026" { + t.Errorf("should not have duplicate error when AllowDuplicates=true") + } + } + }) +} + +func TestValidateMessages_PhoneNormalization(t *testing.T) { + t.Cleanup(func() {}) + msgs := []types.Message{ + {To: "010-1234-5678", From: "01011112222", Text: "hello"}, + } + errs := ValidateMessages(msgs, Options{AutoTypeDetect: true}) + if errs != nil { + t.Fatalf("unexpected errors: %v", errs) + } + if msgs[0].To != "01012345678" { + t.Errorf("To not normalized: %q", msgs[0].To) + } +} + +func TestValidateMessages_EmptySlice(t *testing.T) { + t.Cleanup(func() {}) + errs := ValidateMessages([]types.Message{}, Options{}) + if errs != nil { + t.Errorf("expected nil for empty slice, got %v", errs) + } +} + +func TestValidateMessages_NilSlice(t *testing.T) { + t.Cleanup(func() {}) + errs := ValidateMessages(nil, Options{}) + if errs != nil { + t.Errorf("expected nil for nil slice, got %v", errs) + } +} + +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() + msgs := []types.Message{ + {To: "01012345678", From: "01011112222", Text: "hello"}, + } + _ = ValidateMessages(msgs, Options{AutoTypeDetect: true}) + }() + } + wg.Wait() +} + +func FuzzValidateMessages(f *testing.F) { + f.Add("01012345678", "01011112222", "hello") + f.Add("", "", "") + f.Add("+82-10-1234-5678", "010", strings.Repeat("가", 100)) + f.Add("abc", "xyz", strings.Repeat("a", 200)) + + f.Fuzz(func(t *testing.T, to, from, text string) { + if !utf8.ValidString(to) || !utf8.ValidString(from) || !utf8.ValidString(text) { + return + } + msgs := []types.Message{ + {To: to, From: from, Text: text}, + } + // Must not panic + _ = ValidateMessages(msgs, Options{AutoTypeDetect: true}) + }) +}