377 lines
10 KiB
Go
377 lines
10 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
// ── Predicate Tests ─────────────────────────
|
|
|
|
func TestAlwaysAvailable(t *testing.T) {
|
|
cases := []ToolContext{
|
|
{},
|
|
{ChannelType: "direct"},
|
|
{IsVisitor: true},
|
|
{WorkspaceID: "ws-1", WorkflowID: "wf-1", TeamID: "t-1"},
|
|
}
|
|
for i, tc := range cases {
|
|
if !AlwaysAvailable(tc) {
|
|
t.Errorf("case %d: AlwaysAvailable returned false", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequireWorkspace(t *testing.T) {
|
|
if RequireWorkspace(ToolContext{}) {
|
|
t.Error("Expected false when WorkspaceID is empty")
|
|
}
|
|
if !RequireWorkspace(ToolContext{WorkspaceID: "ws-123"}) {
|
|
t.Error("Expected true when WorkspaceID is set")
|
|
}
|
|
}
|
|
|
|
func TestRequireWorkflow(t *testing.T) {
|
|
if RequireWorkflow(ToolContext{}) {
|
|
t.Error("Expected false when WorkflowID is empty")
|
|
}
|
|
if !RequireWorkflow(ToolContext{WorkflowID: "wf-456"}) {
|
|
t.Error("Expected true when WorkflowID is set")
|
|
}
|
|
}
|
|
|
|
func TestRequireTeam(t *testing.T) {
|
|
if RequireTeam(ToolContext{}) {
|
|
t.Error("Expected false when TeamID is empty")
|
|
}
|
|
if !RequireTeam(ToolContext{TeamID: "team-1"}) {
|
|
t.Error("Expected true when TeamID is set")
|
|
}
|
|
}
|
|
|
|
func TestDenyVisitor(t *testing.T) {
|
|
if !DenyVisitor(ToolContext{IsVisitor: false}) {
|
|
t.Error("Expected true for authenticated user")
|
|
}
|
|
if DenyVisitor(ToolContext{IsVisitor: true}) {
|
|
t.Error("Expected false for visitor")
|
|
}
|
|
}
|
|
|
|
func TestAll(t *testing.T) {
|
|
wsAndNonVisitor := All(RequireWorkspace, DenyVisitor)
|
|
|
|
cases := []struct {
|
|
name string
|
|
tc ToolContext
|
|
expect bool
|
|
}{
|
|
{"both satisfied", ToolContext{WorkspaceID: "ws-1", IsVisitor: false}, true},
|
|
{"no workspace", ToolContext{WorkspaceID: "", IsVisitor: false}, false},
|
|
{"is visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
|
{"neither satisfied", ToolContext{WorkspaceID: "", IsVisitor: true}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := wsAndNonVisitor(tc.tc); got != tc.expect {
|
|
t.Errorf("got %v, want %v", got, tc.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAllEmpty(t *testing.T) {
|
|
// All() with no predicates should pass (vacuous truth)
|
|
pred := All()
|
|
if !pred(ToolContext{}) {
|
|
t.Error("All() with no predicates should return true")
|
|
}
|
|
}
|
|
|
|
func TestAny(t *testing.T) {
|
|
wsOrWorkflow := Any(RequireWorkspace, RequireWorkflow)
|
|
|
|
cases := []struct {
|
|
name string
|
|
tc ToolContext
|
|
expect bool
|
|
}{
|
|
{"workspace only", ToolContext{WorkspaceID: "ws-1"}, true},
|
|
{"workflow only", ToolContext{WorkflowID: "wf-1"}, true},
|
|
{"both", ToolContext{WorkspaceID: "ws-1", WorkflowID: "wf-1"}, true},
|
|
{"neither", ToolContext{}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := wsOrWorkflow(tc.tc); got != tc.expect {
|
|
t.Errorf("got %v, want %v", got, tc.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnyEmpty(t *testing.T) {
|
|
// Any() with no predicates should fail (no match)
|
|
pred := Any()
|
|
if pred(ToolContext{}) {
|
|
t.Error("Any() with no predicates should return false")
|
|
}
|
|
}
|
|
|
|
func TestNot(t *testing.T) {
|
|
notVisitor := Not(func(tc ToolContext) bool { return tc.IsVisitor })
|
|
if !notVisitor(ToolContext{IsVisitor: false}) {
|
|
t.Error("Not(visitor) should return true for non-visitor")
|
|
}
|
|
if notVisitor(ToolContext{IsVisitor: true}) {
|
|
t.Error("Not(visitor) should return false for visitor")
|
|
}
|
|
}
|
|
|
|
func TestComposedPredicates(t *testing.T) {
|
|
// workspace_create: available when NO workspace AND not visitor
|
|
workspaceCreate := All(Not(RequireWorkspace), DenyVisitor)
|
|
|
|
cases := []struct {
|
|
name string
|
|
tc ToolContext
|
|
expect bool
|
|
}{
|
|
{"no ws, authenticated", ToolContext{}, true},
|
|
{"has ws, authenticated", ToolContext{WorkspaceID: "ws-1"}, false},
|
|
{"no ws, visitor", ToolContext{IsVisitor: true}, false},
|
|
{"has ws, visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := workspaceCreate(tc.tc); got != tc.expect {
|
|
t.Errorf("got %v, want %v", got, tc.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── BaseTool Tests ──────────────────────────
|
|
|
|
type testToolWithBase struct {
|
|
BaseTool
|
|
}
|
|
|
|
func (t *testToolWithBase) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "always_tool",
|
|
Description: "test tool with BaseTool embed",
|
|
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
|
}
|
|
}
|
|
|
|
func (t *testToolWithBase) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
|
return `{"ok":true}`, nil
|
|
}
|
|
|
|
func TestBaseToolSatisfiesContextualTool(t *testing.T) {
|
|
tool := &testToolWithBase{}
|
|
|
|
// Must satisfy ContextualTool
|
|
ct, ok := interface{}(tool).(ContextualTool)
|
|
if !ok {
|
|
t.Fatal("testToolWithBase should satisfy ContextualTool interface")
|
|
}
|
|
|
|
// BaseTool.Availability() should return AlwaysAvailable
|
|
if !ct.Availability()(ToolContext{IsVisitor: true}) {
|
|
t.Error("BaseTool.Availability() should be AlwaysAvailable")
|
|
}
|
|
}
|
|
|
|
// ── AvailableFor Tests ──────────────────────
|
|
|
|
// testContextTool overrides availability with a custom predicate.
|
|
type testContextTool struct {
|
|
BaseTool
|
|
name string
|
|
req Require
|
|
}
|
|
|
|
func (t *testContextTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: t.name,
|
|
Description: "test contextual tool: " + t.name,
|
|
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
|
}
|
|
}
|
|
|
|
func (t *testContextTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
|
return `{"ok":true}`, nil
|
|
}
|
|
|
|
func (t *testContextTool) Availability() Require {
|
|
return t.req
|
|
}
|
|
|
|
// testPlainTool is a tool that does NOT implement ContextualTool —
|
|
// only the base Tool interface. It should always be included.
|
|
type testPlainTool struct {
|
|
name string
|
|
}
|
|
|
|
func (t *testPlainTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: t.name,
|
|
Description: "plain tool: " + t.name,
|
|
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
|
}
|
|
}
|
|
|
|
func (t *testPlainTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
|
return `{"ok":true}`, nil
|
|
}
|
|
|
|
func TestAvailableFor(t *testing.T) {
|
|
// Save and restore global registry
|
|
origRegistry := registry
|
|
defer func() { registry = origRegistry }()
|
|
|
|
registry = map[string]Tool{
|
|
"always_tool": &testToolWithBase{},
|
|
"ws_tool": &testContextTool{name: "ws_tool", req: RequireWorkspace},
|
|
"visitor_denied": &testContextTool{name: "visitor_denied", req: DenyVisitor},
|
|
"ws_no_visitor": &testContextTool{name: "ws_no_visitor", req: All(RequireWorkspace, DenyVisitor)},
|
|
"plain_tool": &testPlainTool{name: "plain_tool"},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
tctx ToolContext
|
|
disabled map[string]bool
|
|
expect []string // expected tool names (sorted doesn't matter, just set membership)
|
|
}{
|
|
{
|
|
name: "empty context — only always + plain",
|
|
tctx: ToolContext{},
|
|
expect: []string{"always_tool", "visitor_denied", "plain_tool"},
|
|
},
|
|
{
|
|
name: "workspace context — ws tools available",
|
|
tctx: ToolContext{WorkspaceID: "ws-1"},
|
|
expect: []string{"always_tool", "ws_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
|
},
|
|
{
|
|
name: "visitor with workspace — visitor_denied and ws_no_visitor excluded",
|
|
tctx: ToolContext{WorkspaceID: "ws-1", IsVisitor: true},
|
|
expect: []string{"always_tool", "ws_tool", "plain_tool"},
|
|
},
|
|
{
|
|
name: "disabled tool excluded",
|
|
tctx: ToolContext{WorkspaceID: "ws-1"},
|
|
disabled: map[string]bool{"ws_tool": true},
|
|
expect: []string{"always_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
|
},
|
|
{
|
|
name: "visitor no workspace — minimal set",
|
|
tctx: ToolContext{IsVisitor: true},
|
|
expect: []string{"always_tool", "plain_tool"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
disabled := tc.disabled
|
|
if disabled == nil {
|
|
disabled = map[string]bool{}
|
|
}
|
|
defs := AvailableFor(tc.tctx, disabled)
|
|
|
|
got := make(map[string]bool, len(defs))
|
|
for _, d := range defs {
|
|
got[d.Name] = true
|
|
}
|
|
|
|
// Check expected tools are present
|
|
for _, name := range tc.expect {
|
|
if !got[name] {
|
|
t.Errorf("expected tool %q not found in result", name)
|
|
}
|
|
}
|
|
// Check no unexpected tools
|
|
expect := make(map[string]bool, len(tc.expect))
|
|
for _, name := range tc.expect {
|
|
expect[name] = true
|
|
}
|
|
for name := range got {
|
|
if !expect[name] {
|
|
t.Errorf("unexpected tool %q in result", name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAvailableForBackwardCompat(t *testing.T) {
|
|
// AvailableFor with empty ToolContext should behave like
|
|
// the old AllDefinitionsFiltered for plain tools
|
|
origRegistry := registry
|
|
defer func() { registry = origRegistry }()
|
|
|
|
registry = map[string]Tool{
|
|
"tool_a": &testPlainTool{name: "tool_a"},
|
|
"tool_b": &testPlainTool{name: "tool_b"},
|
|
"tool_c": &testPlainTool{name: "tool_c"},
|
|
}
|
|
|
|
// No disabled, empty context — all plain tools should appear
|
|
defs := AvailableFor(ToolContext{}, map[string]bool{})
|
|
if len(defs) != 3 {
|
|
t.Errorf("Expected 3 tools, got %d", len(defs))
|
|
}
|
|
|
|
// Disable one
|
|
defs = AvailableFor(ToolContext{}, map[string]bool{"tool_b": true})
|
|
if len(defs) != 2 {
|
|
t.Errorf("Expected 2 tools, got %d", len(defs))
|
|
}
|
|
for _, d := range defs {
|
|
if d.Name == "tool_b" {
|
|
t.Error("tool_b should be filtered out")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── ToolContext from ExecutionContext ────────
|
|
|
|
func TestToolContextFromExecContext(t *testing.T) {
|
|
// Verify the fields map correctly between the two structs.
|
|
// This is a compile-time/structural test — when ToolContext is
|
|
// built from ExecutionContext in the completion handler, these
|
|
// fields must align.
|
|
exec := ExecutionContext{
|
|
UserID: "u-1",
|
|
ChannelID: "ch-1",
|
|
PersonaID: "p-1",
|
|
WorkspaceID: "ws-1",
|
|
WorkflowID: "wf-1",
|
|
TeamID: "t-1",
|
|
}
|
|
|
|
// Simulate what the completion handler will do
|
|
tc := ToolContext{
|
|
WorkspaceID: exec.WorkspaceID,
|
|
WorkflowID: exec.WorkflowID,
|
|
TeamID: exec.TeamID,
|
|
PersonaID: exec.PersonaID,
|
|
// ChannelType and IsVisitor come from channel record / gin context
|
|
}
|
|
|
|
if tc.WorkspaceID != "ws-1" {
|
|
t.Errorf("WorkspaceID mismatch: %q", tc.WorkspaceID)
|
|
}
|
|
if tc.WorkflowID != "wf-1" {
|
|
t.Errorf("WorkflowID mismatch: %q", tc.WorkflowID)
|
|
}
|
|
if tc.TeamID != "t-1" {
|
|
t.Errorf("TeamID mismatch: %q", tc.TeamID)
|
|
}
|
|
if tc.PersonaID != "p-1" {
|
|
t.Errorf("PersonaID mismatch: %q", tc.PersonaID)
|
|
}
|
|
}
|