diff --git a/brain.go b/brain.go new file mode 100644 index 0000000..9716f5f --- /dev/null +++ b/brain.go @@ -0,0 +1,215 @@ +package agentic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "forge.lthn.ai/core/go/pkg/log" +) + +// MemoryType represents the classification of a brain memory. +type MemoryType string + +const ( + MemoryFact MemoryType = "fact" + MemoryDecision MemoryType = "decision" + MemoryPattern MemoryType = "pattern" + MemoryContext MemoryType = "context" + MemoryProcedure MemoryType = "procedure" +) + +// Memory represents a single memory entry from the OpenBrain API. +type Memory struct { + ID string `json:"id"` + AgentID string `json:"agent_id,omitempty"` + Type string `json:"type"` + Content string `json:"content"` + Tags []string `json:"tags,omitempty"` + Project string `json:"project,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + SupersedesID string `json:"supersedes_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// RememberRequest is the payload for storing a new memory. +type RememberRequest struct { + Content string `json:"content"` + Type string `json:"type"` + Project string `json:"project,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Tags []string `json:"tags,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + SupersedesID string `json:"supersedes_id,omitempty"` + Source string `json:"source,omitempty"` +} + +// RememberResponse is returned after storing a memory. +type RememberResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Project string `json:"project"` + CreatedAt string `json:"created_at"` +} + +// RecallRequest is the payload for semantic search. +type RecallRequest struct { + Query string `json:"query"` + TopK int `json:"top_k,omitempty"` + Project string `json:"project,omitempty"` + Type string `json:"type,omitempty"` + AgentID string `json:"agent_id,omitempty"` + MinConfidence *float64 `json:"min_confidence,omitempty"` +} + +// RecallResponse is returned from a semantic search. +type RecallResponse struct { + Memories []Memory `json:"memories"` + Scores map[string]float64 `json:"scores"` +} + +// Remember stores a memory via POST /v1/brain/remember. +func (c *Client) Remember(ctx context.Context, req RememberRequest) (*RememberResponse, error) { + const op = "agentic.Client.Remember" + + if req.Content == "" { + return nil, log.E(op, "content is required", nil) + } + if req.Type == "" { + return nil, log.E(op, "type is required", nil) + } + + data, err := json.Marshal(req) + if err != nil { + return nil, log.E(op, "failed to marshal request", err) + } + + endpoint := c.BaseURL + "/v1/brain/remember" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var result RememberResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return &result, nil +} + +// Recall performs semantic search via POST /v1/brain/recall. +func (c *Client) Recall(ctx context.Context, req RecallRequest) (*RecallResponse, error) { + const op = "agentic.Client.Recall" + + if req.Query == "" { + return nil, log.E(op, "query is required", nil) + } + + data, err := json.Marshal(req) + if err != nil { + return nil, log.E(op, "failed to marshal request", err) + } + + endpoint := c.BaseURL + "/v1/brain/recall" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var result RecallResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return &result, nil +} + +// Forget removes a memory via DELETE /v1/brain/forget/{id}. +func (c *Client) Forget(ctx context.Context, id string) error { + const op = "agentic.Client.Forget" + + if id == "" { + return log.E(op, "memory ID is required", nil) + } + + endpoint := fmt.Sprintf("%s/v1/brain/forget/%s", c.BaseURL, url.PathEscape(id)) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return log.E(op, "API error", err) + } + + return nil +} + +// EnsureCollection ensures the Qdrant collection exists via POST /v1/brain/collections. +func (c *Client) EnsureCollection(ctx context.Context) error { + const op = "agentic.Client.EnsureCollection" + + endpoint := c.BaseURL + "/v1/brain/collections" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return log.E(op, "API error", err) + } + + return nil +} diff --git a/brain_test.go b/brain_test.go new file mode 100644 index 0000000..1dd503b --- /dev/null +++ b/brain_test.go @@ -0,0 +1,234 @@ +package agentic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Remember_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/brain/remember", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var req RememberRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "Go uses structural typing", req.Content) + assert.Equal(t, "fact", req.Type) + assert.Equal(t, "go-agentic", req.Project) + assert.Equal(t, []string{"go", "typing"}, req.Tags) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(RememberResponse{ + ID: "mem-abc-123", + Type: "fact", + Project: "go-agentic", + CreatedAt: "2026-03-03T12:00:00+00:00", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.Remember(context.Background(), RememberRequest{ + Content: "Go uses structural typing", + Type: "fact", + Project: "go-agentic", + Tags: []string{"go", "typing"}, + }) + + require.NoError(t, err) + assert.Equal(t, "mem-abc-123", result.ID) + assert.Equal(t, "fact", result.Type) + assert.Equal(t, "go-agentic", result.Project) +} + +func TestClient_Remember_Bad_EmptyContent(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + result, err := client.Remember(context.Background(), RememberRequest{ + Type: "fact", + }) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "content is required") +} + +func TestClient_Remember_Bad_EmptyType(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + result, err := client.Remember(context.Background(), RememberRequest{ + Content: "something", + }) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "type is required") +} + +func TestClient_Remember_Bad_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(APIError{Message: "validation failed"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.Remember(context.Background(), RememberRequest{ + Content: "test", + Type: "fact", + }) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "validation failed") +} + +func TestClient_Recall_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/brain/recall", r.URL.Path) + + var req RecallRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "how does typing work in Go", req.Query) + assert.Equal(t, 5, req.TopK) + assert.Equal(t, "go-agentic", req.Project) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(RecallResponse{ + Memories: []Memory{ + { + ID: "mem-abc-123", + Type: "fact", + Content: "Go uses structural typing", + Project: "go-agentic", + Confidence: 0.95, + }, + }, + Scores: map[string]float64{ + "mem-abc-123": 0.87, + }, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.Recall(context.Background(), RecallRequest{ + Query: "how does typing work in Go", + TopK: 5, + Project: "go-agentic", + }) + + require.NoError(t, err) + assert.Len(t, result.Memories, 1) + assert.Equal(t, "mem-abc-123", result.Memories[0].ID) + assert.Equal(t, "Go uses structural typing", result.Memories[0].Content) + assert.InDelta(t, 0.87, result.Scores["mem-abc-123"], 0.001) +} + +func TestClient_Recall_Good_EmptyResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(RecallResponse{ + Memories: []Memory{}, + Scores: map[string]float64{}, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.Recall(context.Background(), RecallRequest{ + Query: "something obscure", + }) + + require.NoError(t, err) + assert.Empty(t, result.Memories) + assert.Empty(t, result.Scores) +} + +func TestClient_Recall_Bad_EmptyQuery(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + result, err := client.Recall(context.Background(), RecallRequest{}) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "query is required") +} + +func TestClient_Forget_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v1/brain/forget/mem-abc-123", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"deleted": true}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.Forget(context.Background(), "mem-abc-123") + + assert.NoError(t, err) +} + +func TestClient_Forget_Bad_EmptyID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + err := client.Forget(context.Background(), "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "memory ID is required") +} + +func TestClient_Forget_Bad_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(APIError{Message: "memory not found"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.Forget(context.Background(), "nonexistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "memory not found") +} + +func TestClient_EnsureCollection_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/brain/collections", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.EnsureCollection(context.Background()) + + assert.NoError(t, err) +} + +func TestClient_EnsureCollection_Bad_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(APIError{Message: "collection setup failed"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.EnsureCollection(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "collection setup failed") +} diff --git a/score.go b/score.go new file mode 100644 index 0000000..feb15a0 --- /dev/null +++ b/score.go @@ -0,0 +1,147 @@ +package agentic + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + + "forge.lthn.ai/core/go/pkg/log" +) + +// ScoreContentRequest is the payload for content scoring. +type ScoreContentRequest struct { + Text string `json:"text"` + Prompt string `json:"prompt,omitempty"` +} + +// ScoreImprintRequest is the payload for linguistic imprint analysis. +type ScoreImprintRequest struct { + Text string `json:"text"` +} + +// ScoreResult holds the response from the scoring engine. +// The shape is proxied from the EaaS Go binary, so fields are dynamic. +type ScoreResult map[string]any + +// ScoreHealthResponse holds the health check result. +type ScoreHealthResponse struct { + Status string `json:"status"` + UpstreamStatus int `json:"upstream_status,omitempty"` +} + +// ScoreContent scores text for AI patterns via POST /v1/score/content. +func (c *Client) ScoreContent(ctx context.Context, req ScoreContentRequest) (ScoreResult, error) { + const op = "agentic.Client.ScoreContent" + + if req.Text == "" { + return nil, log.E(op, "text is required", nil) + } + + data, err := json.Marshal(req) + if err != nil { + return nil, log.E(op, "failed to marshal request", err) + } + + endpoint := c.BaseURL + "/v1/score/content" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var result ScoreResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return result, nil +} + +// ScoreImprint performs linguistic imprint analysis via POST /v1/score/imprint. +func (c *Client) ScoreImprint(ctx context.Context, req ScoreImprintRequest) (ScoreResult, error) { + const op = "agentic.Client.ScoreImprint" + + if req.Text == "" { + return nil, log.E(op, "text is required", nil) + } + + data, err := json.Marshal(req) + if err != nil { + return nil, log.E(op, "failed to marshal request", err) + } + + endpoint := c.BaseURL + "/v1/score/imprint" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var result ScoreResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return result, nil +} + +// ScoreHealth checks the scoring engine health via GET /v1/score/health. +// This endpoint does not require authentication. +func (c *Client) ScoreHealth(ctx context.Context) (*ScoreHealthResponse, error) { + const op = "agentic.Client.ScoreHealth" + + endpoint := c.BaseURL + "/v1/score/health" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + // Health endpoint is unauthenticated but we still set headers for consistency. + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", "core-agentic-client/1.0") + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var result ScoreHealthResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return &result, nil +} diff --git a/score_test.go b/score_test.go new file mode 100644 index 0000000..cb61b6e --- /dev/null +++ b/score_test.go @@ -0,0 +1,166 @@ +package agentic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_ScoreContent_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/score/content", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var req ScoreContentRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Contains(t, req.Text, "sample text for scoring") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "score": 0.23, + "confidence": 0.91, + "label": "human", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreContent(context.Background(), ScoreContentRequest{ + Text: "This is some sample text for scoring that is at least twenty characters", + }) + + require.NoError(t, err) + assert.InDelta(t, 0.23, result["score"], 0.001) + assert.Equal(t, "human", result["label"]) +} + +func TestClient_ScoreContent_Good_WithPrompt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req ScoreContentRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "Check for formality", req.Prompt) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"score": 0.5}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreContent(context.Background(), ScoreContentRequest{ + Text: "This text should be checked for formality and style patterns", + Prompt: "Check for formality", + }) + + require.NoError(t, err) + assert.InDelta(t, 0.5, result["score"], 0.001) +} + +func TestClient_ScoreContent_Bad_EmptyText(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + result, err := client.ScoreContent(context.Background(), ScoreContentRequest{}) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "text is required") +} + +func TestClient_ScoreContent_Bad_ServiceDown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "scoring_unavailable", + "message": "Could not reach the scoring service.", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreContent(context.Background(), ScoreContentRequest{ + Text: "This text needs at least twenty characters to validate", + }) + + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestClient_ScoreImprint_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/score/imprint", r.URL.Path) + + var req ScoreImprintRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.NotEmpty(t, req.Text) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "imprint": "abc123def456", + "confidence": 0.88, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreImprint(context.Background(), ScoreImprintRequest{ + Text: "This text has a distinct linguistic imprint pattern to analyse", + }) + + require.NoError(t, err) + assert.Equal(t, "abc123def456", result["imprint"]) +} + +func TestClient_ScoreImprint_Bad_EmptyText(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + result, err := client.ScoreImprint(context.Background(), ScoreImprintRequest{}) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "text is required") +} + +func TestClient_ScoreHealth_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/score/health", r.URL.Path) + // Health endpoint should not require auth token + assert.Empty(t, r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ScoreHealthResponse{ + Status: "healthy", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreHealth(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "healthy", result.Status) +} + +func TestClient_ScoreHealth_Bad_Unhealthy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(ScoreHealthResponse{ + Status: "unhealthy", + UpstreamStatus: 503, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + result, err := client.ScoreHealth(context.Background()) + + assert.Error(t, err) + assert.Nil(t, result) +}