feat: 初始化shop-crm-agent项目,实现基础CLI与CRM操作能力
本提交初始化完整的shop-crm-agent项目: 1. 创建基础项目结构与配置文件(.gitignore、go.mod/go.sum) 2. 添加项目许可协议与文档(README.md、AGENTS.md、SKILL.md) 3. 实现核心CLI入口与命令框架 4. 封装Protobuf API客户端与完整的命令实现 5. 添加单元测试覆盖核心功能与边界情况 6. 提供构建脚本与版本管理能力
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
api "code.zhecent.com/open/shop-crm-agent/internal/api"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestClientSendsBearerProtobufAndChecksBusinessHeader(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != apiPath+"/projects/get" {
|
||||
t.Errorf("path = %s", request.URL.Path)
|
||||
}
|
||||
if request.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
if request.Header.Get("Content-Type") != "application/x-protobuf" {
|
||||
t.Errorf("content type = %q", request.Header.Get("Content-Type"))
|
||||
}
|
||||
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 200, Message: "success"}, Project: &api.Project{Id: 9}})
|
||||
writer.Header().Set("Content-Type", "application/x-protobuf")
|
||||
_, _ = writer.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{ProjectId: 9})
|
||||
if err != nil {
|
||||
t.Fatalf("project get: %v", err)
|
||||
}
|
||||
if response.Project.GetId() != 9 {
|
||||
t.Fatalf("project id = %d", response.Project.GetId())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportsHTTPProtocolAndSizeFailures(t *testing.T) {
|
||||
t.Run("http error preserves protobuf header", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 401, Message: "unauthorized"}})
|
||||
_, _ = writer.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "token", server.Client())
|
||||
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401: unauthorized") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if response.GetHeader().GetCode() != 401 {
|
||||
t.Fatalf("header = %#v", response.GetHeader())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed protobuf", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write([]byte{0xff})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "token", server.Client())
|
||||
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "解析 Protobuf 响应失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response size limit", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write(make([]byte, maxResponseSize+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "token", server.Client())
|
||||
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "16 MiB") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientHonorsHTTPClientTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}))
|
||||
defer server.Close()
|
||||
httpClient := server.Client()
|
||||
httpClient.Timeout = time.Millisecond
|
||||
client, _ := New(server.URL, "token", httpClient)
|
||||
_, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
|
||||
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRepresentativeOperationRoutes(t *testing.T) {
|
||||
var paths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
paths = append(paths, request.URL.Path)
|
||||
var response proto.Message
|
||||
switch request.URL.Path {
|
||||
case apiPath + "/projects/create":
|
||||
response = &api.ProjectResponse{Header: &api.ResponseHeader{Code: 200}}
|
||||
case apiPath + "/quotations/delete/preview":
|
||||
response = &api.DeletePreviewResponse{Header: &api.ResponseHeader{Code: 200}}
|
||||
case apiPath + "/quotation/edit/command":
|
||||
response = &api.QuotationEditCommandResponse{Header: &api.ResponseHeader{Code: 200}}
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", request.URL.Path)
|
||||
}
|
||||
data, _ := proto.Marshal(response)
|
||||
_, _ = writer.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "token", server.Client())
|
||||
if _, err := client.ProjectCreate(context.Background(), &api.ProjectCreateRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.QuotationDeletePreview(context.Background(), &api.QuotationDeletePreviewRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.EditCommand(context.Background(), &api.QuotationEditCommandRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{apiPath + "/projects/create", apiPath + "/quotations/delete/preview", apiPath + "/quotation/edit/command"}
|
||||
if strings.Join(paths, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("paths = %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRejectsMissingConfigurationAndBusinessError(t *testing.T) {
|
||||
if _, err := New("", "token", nil); err == nil {
|
||||
t.Fatal("empty base URL accepted")
|
||||
}
|
||||
if _, err := New("https://example.com", "", nil); err == nil {
|
||||
t.Fatal("empty token accepted")
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
data, _ := proto.Marshal(&api.CommonResponse{Header: &api.ResponseHeader{Code: 400, Message: "bad request", RequestId: "request-123", Replayed: true}})
|
||||
_, _ = writer.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "token", server.Client())
|
||||
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("business error was accepted")
|
||||
}
|
||||
if response.GetHeader() == nil || !response.GetHeader().Replayed || response.GetHeader().RequestId != "request-123" {
|
||||
t.Fatalf("business error response = %#v", response.GetHeader())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user