mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
* feat(tools): add Serply as a web_search provider Serply is a SERP API returning Google web results on a tenant-supplied key. It joins the existing chain the same way Exa, Tavily and Brave do: keyed from config_secrets, skipped silently when no key is stored, and selectable through the provider parameter for cross-engine corroboration. Freshness maps onto Google's tbs recency filter, which Serply forwards upstream. Only the pd/pw/pm/py shortcuts are sent: upstream ignores the equivalent cdr date range and answers with unfiltered results, so a range leaves tbs unset rather than naming a filter the results do not honour. The settings form gained a fifth sortable provider, so the locked DuckDuckGo card now derives its position number instead of hardcoding it, and a stored provider_order is reconciled against the full provider list rather than against Parallel alone. * test(tools): use a locally named round trip stub in the serply test The serply cancellation test borrowed parallelRoundTripFunc from web_search_parallel_test.go. Define serplyRoundTripFunc alongside the test that uses it so the file does not depend on another provider's test helper.
275 lines
7.4 KiB
Go
275 lines
7.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// fakeSearchProvider implements SearchProvider for chain-resolution tests.
|
|
type fakeSearchProvider struct{ name string }
|
|
|
|
func (f *fakeSearchProvider) Name() string { return f.name }
|
|
func (f *fakeSearchProvider) Search(_ context.Context, _ searchParams) ([]searchResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// chainNames extracts provider names in order for terse assertions.
|
|
func chainNames(chain []SearchProvider) []string {
|
|
out := make([]string, 0, len(chain))
|
|
for _, p := range chain {
|
|
out = append(out, p.Name())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fakeSecretsStore implements store.ConfigSecretsStore for unit tests.
|
|
type fakeSecretsStore struct {
|
|
data map[uuid.UUID]map[string]string // tenantID → key → value
|
|
}
|
|
|
|
func newFakeSecretsStore() *fakeSecretsStore {
|
|
return &fakeSecretsStore{data: make(map[uuid.UUID]map[string]string)}
|
|
}
|
|
|
|
func (f *fakeSecretsStore) Get(ctx context.Context, key string) (string, error) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
m, ok := f.data[tid]
|
|
if !ok {
|
|
return "", sql.ErrNoRows
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return "", sql.ErrNoRows
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (f *fakeSecretsStore) Set(ctx context.Context, key, value string) error {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
if _, ok := f.data[tid]; !ok {
|
|
f.data[tid] = make(map[string]string)
|
|
}
|
|
f.data[tid][key] = value
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSecretsStore) Delete(ctx context.Context, key string) error {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
m, ok := f.data[tid]
|
|
if ok {
|
|
delete(m, key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSecretsStore) GetAll(ctx context.Context) (map[string]string, error) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
m, ok := f.data[tid]
|
|
if !ok {
|
|
return make(map[string]string), nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// --- JSON unmarshal tests ---
|
|
|
|
func TestWebSearchChainOverride_UnmarshalJSON_ProviderOrder(t *testing.T) {
|
|
data := []byte(`{"provider_order":["brave","exa"],"brave":{"enabled":false}}`)
|
|
var o WebSearchChainOverride
|
|
if err := o.UnmarshalJSON(data); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if len(o.ProviderOrder) != 2 || o.ProviderOrder[0] != "brave" {
|
|
t.Errorf("ProviderOrder: got %v", o.ProviderOrder)
|
|
}
|
|
po, ok := o.Providers["brave"]
|
|
if !ok || po.Enabled == nil || *po.Enabled != false {
|
|
t.Errorf("brave override: got %+v", po)
|
|
}
|
|
}
|
|
|
|
func TestWebSearchChainOverride_UnmarshalJSON_Empty(t *testing.T) {
|
|
var o WebSearchChainOverride
|
|
if err := o.UnmarshalJSON([]byte(`{}`)); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if len(o.ProviderOrder) != 0 || len(o.Providers) != 0 {
|
|
t.Errorf("expected empty override: %+v", o)
|
|
}
|
|
}
|
|
|
|
// --- Chain resolution tests (scenarios 1-6 from phase file) ---
|
|
|
|
func TestBuildChainFromStorage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tenantID uuid.UUID
|
|
override string // JSON string for builtin_tool_tenant_configs.settings web_search field
|
|
secrets map[string]string
|
|
wantNames []string
|
|
wantLen int
|
|
}{
|
|
{
|
|
name: "scenario 1: no secrets, no override → DDG only",
|
|
tenantID: uuid.New(),
|
|
override: "",
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "scenario 2: override brave+exa, brave key present → [brave, exa, duckduckgo]",
|
|
tenantID: uuid.New(),
|
|
override: `{"provider_order":["brave","exa"]}`,
|
|
secrets: map[string]string{
|
|
"tools.web.brave.api_key": "test-key-brave-123",
|
|
"tools.web.exa.api_key": "test-key-exa-456",
|
|
},
|
|
wantNames: []string{"brave", "exa", "duckduckgo"},
|
|
wantLen: 3,
|
|
},
|
|
{
|
|
name: "scenario 3: override brave only, no brave key → [duckduckgo] (DDG always present)",
|
|
tenantID: uuid.New(),
|
|
override: `{"provider_order":["brave"]}`,
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "scenario 4: override with unknown provider name → skipped, DDG present",
|
|
tenantID: uuid.New(),
|
|
override: `{"provider_order":["unknown_provider","brave"]}`,
|
|
secrets: map[string]string{
|
|
"tools.web.brave.api_key": "test-key-brave-789",
|
|
},
|
|
wantNames: []string{"brave", "duckduckgo"},
|
|
wantLen: 2,
|
|
},
|
|
{
|
|
name: "scenario 5: DDG explicitly disabled → still present (force-enabled)",
|
|
tenantID: uuid.New(),
|
|
override: `{"duckduckgo":{"enabled":false}}`,
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "scenario 6: Brave explicitly disabled, key present → skipped, DDG present",
|
|
tenantID: uuid.New(),
|
|
override: `{"brave":{"enabled":false}}`,
|
|
secrets: map[string]string{
|
|
"tools.web.brave.api_key": "test-key-brave-disabled",
|
|
},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "explicit Parallel needs no secret and stays before DDG",
|
|
tenantID: uuid.New(),
|
|
override: `{"provider_order":["parallel"],"parallel":{"enabled":true,"max_results":3}}`,
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"parallel", "duckduckgo"},
|
|
wantLen: 2,
|
|
},
|
|
{
|
|
name: "Serply key present → sits before DDG in the default order",
|
|
tenantID: uuid.New(),
|
|
override: "",
|
|
secrets: map[string]string{
|
|
"tools.web.serply.api_key": "test-key-serply",
|
|
},
|
|
wantNames: []string{"serply", "duckduckgo"},
|
|
wantLen: 2,
|
|
},
|
|
{
|
|
name: "Serply without a key is skipped despite the default order",
|
|
tenantID: uuid.New(),
|
|
override: "",
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "explicitly disabled Parallel is skipped",
|
|
tenantID: uuid.New(),
|
|
override: `{"provider_order":["parallel"],"parallel":{"enabled":false}}`,
|
|
secrets: map[string]string{},
|
|
wantNames: []string{"duckduckgo"},
|
|
wantLen: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
ctx = store.WithTenantID(ctx, tt.tenantID)
|
|
|
|
// Set up fake secrets
|
|
fake := newFakeSecretsStore()
|
|
for k, v := range tt.secrets {
|
|
fake.Set(ctx, k, v)
|
|
}
|
|
|
|
// Set up override in context (tenant-layer settings)
|
|
if tt.override != "" {
|
|
settingsMap := BuiltinToolSettings{
|
|
"web_search": []byte(tt.override),
|
|
}
|
|
ctx = WithTenantToolSettings(ctx, settingsMap)
|
|
}
|
|
|
|
// Resolve chain
|
|
chain := BuildChainFromStorage(ctx, fake)
|
|
|
|
if len(chain) != tt.wantLen {
|
|
t.Errorf("chain length: got %d, want %d", len(chain), tt.wantLen)
|
|
}
|
|
|
|
names := chainNames(chain)
|
|
if len(names) != len(tt.wantNames) {
|
|
t.Errorf("provider names: got %v, want %v", names, tt.wantNames)
|
|
return
|
|
}
|
|
|
|
for i, want := range tt.wantNames {
|
|
if names[i] != want {
|
|
t.Errorf("provider %d: got %s, want %s", i, names[i], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Cache tests (see tenant_chain_cache_test.go for comprehensive cache tests) ---
|
|
|
|
func TestTenantChainCache_SetGet(t *testing.T) {
|
|
c := newTenantChainCache()
|
|
|
|
tid := uuid.New()
|
|
providers := []SearchProvider{&fakeSearchProvider{"ddg"}}
|
|
c.Set(tid, providers)
|
|
|
|
got, ok := c.Get(tid)
|
|
if !ok || len(got) != 1 || got[0].Name() != "ddg" {
|
|
t.Errorf("cache Get: ok=%v got=%v", ok, chainNames(got))
|
|
}
|
|
}
|