readysite / website / internal / assist / tools_test.go
30.7 KB
tools_test.go
package assist_test

import (
	"encoding/json"
	"testing"

	"github.com/readysite/readysite/pkg/assistant"
	"github.com/readysite/readysite/website/internal/assist"
	"github.com/readysite/readysite/website/models"
)

// =============================================================================
// Tool Definitions
// =============================================================================

func TestToolDefinitions(t *testing.T) {
	t.Run("PageTools", func(t *testing.T) {
		expectedNames := []string{
			"create_page", "update_page", "delete_page", "delete_pages", "get_page", "list_pages",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
				continue
			}
			if tool.Name != name {
				t.Errorf("expected tool name %q, got %q", name, tool.Name)
			}
		}

		// Verify required parameters
		createPage := assist.GetTool("create_page")
		assertRequiredParams(t, createPage, "title", "html")

		updatePage := assist.GetTool("update_page")
		assertRequiredParams(t, updatePage, "id")

		deletePage := assist.GetTool("delete_page")
		assertRequiredParams(t, deletePage, "id")

		deletePages := assist.GetTool("delete_pages")
		assertRequiredParams(t, deletePages, "ids")

		getPage := assist.GetTool("get_page")
		assertRequiredParams(t, getPage, "id")
	})

	t.Run("CollectionTools", func(t *testing.T) {
		expectedNames := []string{
			"create_collection", "update_collection", "delete_collection",
			"delete_collections", "get_collection", "list_collections",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		createCol := assist.GetTool("create_collection")
		assertRequiredParams(t, createCol, "name")

		updateCol := assist.GetTool("update_collection")
		assertRequiredParams(t, updateCol, "id")
	})

	t.Run("DocumentTools", func(t *testing.T) {
		expectedNames := []string{
			"create_document", "create_documents", "update_document",
			"delete_document", "get_document", "query_documents",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		createDoc := assist.GetTool("create_document")
		assertRequiredParams(t, createDoc, "collection_id", "data")

		queryDocs := assist.GetTool("query_documents")
		assertRequiredParams(t, queryDocs, "collection_id")
	})

	t.Run("PartialTools", func(t *testing.T) {
		expectedNames := []string{
			"create_partial", "update_partial", "delete_partial", "get_partial", "list_partials",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		createPartial := assist.GetTool("create_partial")
		assertRequiredParams(t, createPartial, "name", "html")

		updatePartial := assist.GetTool("update_partial")
		assertRequiredParams(t, updatePartial, "id")
	})

	t.Run("FileTools", func(t *testing.T) {
		expectedNames := []string{
			"update_file", "get_file", "list_files", "read_file",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		updateFile := assist.GetTool("update_file")
		assertRequiredParams(t, updateFile, "id")

		readFile := assist.GetTool("read_file")
		assertRequiredParams(t, readFile, "id")
	})

	t.Run("NoteTools", func(t *testing.T) {
		expectedNames := []string{
			"create_note", "list_notes", "get_note", "update_note", "delete_note",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		createNote := assist.GetTool("create_note")
		assertRequiredParams(t, createNote, "type", "category", "title", "content")

		getNote := assist.GetTool("get_note")
		assertRequiredParams(t, getNote, "id")

		updateNote := assist.GetTool("update_note")
		assertRequiredParams(t, updateNote, "id")

		deleteNote := assist.GetTool("delete_note")
		assertRequiredParams(t, deleteNote, "id")
	})

	t.Run("UserTools", func(t *testing.T) {
		expectedNames := []string{
			"create_user", "update_user", "delete_user", "list_users",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		createUser := assist.GetTool("create_user")
		assertRequiredParams(t, createUser, "email", "password")

		updateUser := assist.GetTool("update_user")
		assertRequiredParams(t, updateUser, "id")

		deleteUser := assist.GetTool("delete_user")
		assertRequiredParams(t, deleteUser, "id")
	})

	t.Run("UtilityTools", func(t *testing.T) {
		expectedNames := []string{
			"validate_template", "navigate_user",
		}
		for _, name := range expectedNames {
			tool := assist.GetTool(name)
			if tool == nil {
				t.Errorf("expected tool %q to exist", name)
			}
		}

		validateTemplate := assist.GetTool("validate_template")
		assertRequiredParams(t, validateTemplate, "html")

		navigateUser := assist.GetTool("navigate_user")
		assertRequiredParams(t, navigateUser, "url")
	})

	t.Run("GetToolReturnsNilForUnknown", func(t *testing.T) {
		tool := assist.GetTool("nonexistent_tool")
		if tool != nil {
			t.Error("expected nil for unknown tool")
		}
	})
}

// =============================================================================
// User Tool Execution
// =============================================================================

func TestCreateUserTool(t *testing.T) {
	exec := assist.NewExecutor(nil)

	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"test@example.com","password":"securepass123","name":"Test User","role":"user"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	var resp map[string]any
	if err := json.Unmarshal([]byte(result), &resp); err != nil {
		t.Fatalf("failed to parse result: %v", err)
	}

	id, ok := resp["id"].(string)
	if !ok || id == "" {
		t.Fatal("expected non-empty user ID in response")
	}
	if resp["email"] != "test@example.com" {
		t.Errorf("expected email 'test@example.com', got %v", resp["email"])
	}
	if resp["name"] != "Test User" {
		t.Errorf("expected name 'Test User', got %v", resp["name"])
	}
	if resp["role"] != "user" {
		t.Errorf("expected role 'user', got %v", resp["role"])
	}

	// Verify user was created in DB
	user, err := models.Users.Get(id)
	if err != nil {
		t.Fatalf("failed to get created user: %v", err)
	}
	if user.Email != "test@example.com" {
		t.Errorf("expected email 'test@example.com', got %q", user.Email)
	}
	if user.Name != "Test User" {
		t.Errorf("expected name 'Test User', got %q", user.Name)
	}
	if user.Role != "user" {
		t.Errorf("expected role 'user', got %q", user.Role)
	}
	if !user.Verified {
		t.Error("expected admin-created user to be auto-verified")
	}

	// Cleanup
	models.Users.Delete(user)
}

func TestCreateUserTool_DefaultRole(t *testing.T) {
	exec := assist.NewExecutor(nil)

	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"default-role@example.com","password":"securepass123"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	var resp map[string]any
	if err := json.Unmarshal([]byte(result), &resp); err != nil {
		t.Fatalf("failed to parse result: %v", err)
	}

	if resp["role"] != "user" {
		t.Errorf("expected default role 'user', got %v", resp["role"])
	}

	// Cleanup
	id := resp["id"].(string)
	user, _ := models.Users.Get(id)
	if user != nil {
		models.Users.Delete(user)
	}
}

func TestCreateUserTool_DuplicateEmail(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create first user
	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"duplicate@example.com","password":"securepass123"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error creating first user: %v", err)
	}

	var resp map[string]any
	json.Unmarshal([]byte(result), &resp)
	firstID := resp["id"].(string)

	// Try to create duplicate
	_, err = exec.Execute(assistant.ToolCall{
		ID:        "call_2",
		Name:      "create_user",
		Arguments: `{"email":"duplicate@example.com","password":"securepass456"}`,
	})
	if err == nil {
		t.Error("expected error for duplicate email")
	}

	// Cleanup
	user, _ := models.Users.Get(firstID)
	if user != nil {
		models.Users.Delete(user)
	}
}

func TestCreateUserTool_Validation(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Missing email
	_, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"","password":"securepass123"}`,
	})
	if err == nil {
		t.Error("expected error for empty email")
	}

	// Missing password
	_, err = exec.Execute(assistant.ToolCall{
		ID:        "call_2",
		Name:      "create_user",
		Arguments: `{"email":"valid@example.com","password":""}`,
	})
	if err == nil {
		t.Error("expected error for empty password")
	}

	// Short password
	_, err = exec.Execute(assistant.ToolCall{
		ID:        "call_3",
		Name:      "create_user",
		Arguments: `{"email":"valid@example.com","password":"short"}`,
	})
	if err == nil {
		t.Error("expected error for short password")
	}
}

func TestUpdateUserTool(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create user first
	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"update-me@example.com","password":"securepass123","name":"Original Name","role":"user"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error creating user: %v", err)
	}

	var createResp map[string]any
	json.Unmarshal([]byte(result), &createResp)
	userID := createResp["id"].(string)

	// Update the user
	result, err = exec.Execute(assistant.ToolCall{
		ID:        "call_2",
		Name:      "update_user",
		Arguments: `{"id":"` + userID + `","name":"Updated Name","role":"admin"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error updating user: %v", err)
	}

	var updateResp map[string]any
	if err := json.Unmarshal([]byte(result), &updateResp); err != nil {
		t.Fatalf("failed to parse update result: %v", err)
	}

	if updateResp["name"] != "Updated Name" {
		t.Errorf("expected name 'Updated Name', got %v", updateResp["name"])
	}
	if updateResp["role"] != "admin" {
		t.Errorf("expected role 'admin', got %v", updateResp["role"])
	}

	// Verify in DB
	user, err := models.Users.Get(userID)
	if err != nil {
		t.Fatalf("failed to get user: %v", err)
	}
	if user.Name != "Updated Name" {
		t.Errorf("expected name 'Updated Name' in DB, got %q", user.Name)
	}
	if user.Role != "admin" {
		t.Errorf("expected role 'admin' in DB, got %q", user.Role)
	}

	// Cleanup
	models.Users.Delete(user)
}

func TestUpdateUserTool_NotFound(t *testing.T) {
	exec := assist.NewExecutor(nil)

	_, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "update_user",
		Arguments: `{"id":"nonexistent-user-id","name":"No One"}`,
	})
	if err == nil {
		t.Error("expected error for nonexistent user")
	}
}

func TestDeleteUserTool(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create user first
	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "create_user",
		Arguments: `{"email":"delete-me@example.com","password":"securepass123"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error creating user: %v", err)
	}

	var createResp map[string]any
	json.Unmarshal([]byte(result), &createResp)
	userID := createResp["id"].(string)

	// Delete the user
	_, err = exec.Execute(assistant.ToolCall{
		ID:        "call_2",
		Name:      "delete_user",
		Arguments: `{"id":"` + userID + `"}`,
	})
	if err != nil {
		t.Fatalf("unexpected error deleting user: %v", err)
	}

	// Verify user was deleted
	_, err = models.Users.Get(userID)
	if err == nil {
		t.Error("expected user to be deleted from DB")
	}
}

func TestDeleteUserTool_NotFound(t *testing.T) {
	exec := assist.NewExecutor(nil)

	_, err := exec.Execute(assistant.ToolCall{
		ID:        "call_1",
		Name:      "delete_user",
		Arguments: `{"id":"nonexistent-user-id"}`,
	})
	if err == nil {
		t.Error("expected error for nonexistent user")
	}
}

func TestListUsersTool(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create multiple users
	var userIDs []string
	for _, email := range []string{"list-user-1@example.com", "list-user-2@example.com", "list-user-3@example.com"} {
		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_create",
			Name:      "create_user",
			Arguments: `{"email":"` + email + `","password":"securepass123","name":"` + email + `"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error creating user %s: %v", email, err)
		}
		var resp map[string]any
		json.Unmarshal([]byte(result), &resp)
		userIDs = append(userIDs, resp["id"].(string))
	}

	// List all users
	result, err := exec.Execute(assistant.ToolCall{
		ID:        "call_list",
		Name:      "list_users",
		Arguments: `{}`,
	})
	if err != nil {
		t.Fatalf("unexpected error listing users: %v", err)
	}

	var resp map[string]any
	if err := json.Unmarshal([]byte(result), &resp); err != nil {
		t.Fatalf("failed to parse list result: %v", err)
	}

	count, ok := resp["count"].(float64)
	if !ok {
		t.Fatal("expected count in response")
	}
	if int(count) < 3 {
		t.Errorf("expected at least 3 users, got %d", int(count))
	}

	users, ok := resp["users"].([]any)
	if !ok {
		t.Fatal("expected users array in response")
	}
	if len(users) < 3 {
		t.Errorf("expected at least 3 users in array, got %d", len(users))
	}

	// Cleanup
	for _, id := range userIDs {
		user, _ := models.Users.Get(id)
		if user != nil {
			models.Users.Delete(user)
		}
	}
}

// =============================================================================
// Note Tool Execution
// =============================================================================

func TestNoteTools(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create a note
	t.Run("CreateNote", func(t *testing.T) {
		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "create_note",
			Arguments: `{"type":"preference","category":"style","title":"Dark Theme","content":"User prefers dark theme for all pages"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		id, ok := resp["id"].(string)
		if !ok || id == "" {
			t.Fatal("expected non-empty note ID")
		}
		if resp["title"] != "Dark Theme" {
			t.Errorf("expected title 'Dark Theme', got %v", resp["title"])
		}

		// Verify in DB
		note, err := models.Notes.Get(id)
		if err != nil {
			t.Fatalf("failed to get note: %v", err)
		}
		if note.Type != "preference" {
			t.Errorf("expected type 'preference', got %q", note.Type)
		}
		if note.Category != "style" {
			t.Errorf("expected category 'style', got %q", note.Category)
		}
		if note.Content != "User prefers dark theme for all pages" {
			t.Errorf("unexpected content: %q", note.Content)
		}
		if note.Source != "ai" {
			t.Errorf("expected source 'ai', got %q", note.Source)
		}
		if !note.Active {
			t.Error("expected note to be active by default")
		}

		// Cleanup
		models.Notes.Delete(note)
	})

	t.Run("CreateNote_InvalidType", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "create_note",
			Arguments: `{"type":"invalid","category":"style","title":"Test","content":"Test"}`,
		})
		if err == nil {
			t.Error("expected error for invalid note type")
		}
	})

	t.Run("CreateNote_InvalidCategory", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "create_note",
			Arguments: `{"type":"preference","category":"invalid","title":"Test","content":"Test"}`,
		})
		if err == nil {
			t.Error("expected error for invalid note category")
		}
	})

	// Get a note
	t.Run("GetNote", func(t *testing.T) {
		// Create note for retrieval
		note := &models.Note{
			Type:     "convention",
			Category: "content",
			Title:    "Get Test Note",
			Content:  "This note is for get testing",
			Source:   "ai",
			Active:   true,
		}
		id, _ := models.Notes.Insert(note)
		defer func() {
			n, _ := models.Notes.Get(id)
			if n != nil {
				models.Notes.Delete(n)
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "get_note",
			Arguments: `{"id":"` + id + `"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		if resp["id"] != id {
			t.Errorf("expected id %q, got %v", id, resp["id"])
		}
		if resp["title"] != "Get Test Note" {
			t.Errorf("expected title 'Get Test Note', got %v", resp["title"])
		}
		if resp["type"] != "convention" {
			t.Errorf("expected type 'convention', got %v", resp["type"])
		}
	})

	t.Run("GetNote_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "get_note",
			Arguments: `{"id":"nonexistent-note"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent note")
		}
	})

	// Update a note
	t.Run("UpdateNote", func(t *testing.T) {
		// Create note to update
		note := &models.Note{
			Type:     "learned",
			Category: "behavior",
			Title:    "Original Title",
			Content:  "Original content",
			Source:   "ai",
			Active:   true,
		}
		id, _ := models.Notes.Insert(note)
		defer func() {
			n, _ := models.Notes.Get(id)
			if n != nil {
				models.Notes.Delete(n)
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "update_note",
			Arguments: `{"id":"` + id + `","title":"Updated Title","content":"Updated content","active":false}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		if resp["title"] != "Updated Title" {
			t.Errorf("expected title 'Updated Title', got %v", resp["title"])
		}

		// Verify in DB
		updated, err := models.Notes.Get(id)
		if err != nil {
			t.Fatalf("failed to get updated note: %v", err)
		}
		if updated.Title != "Updated Title" {
			t.Errorf("expected title 'Updated Title' in DB, got %q", updated.Title)
		}
		if updated.Content != "Updated content" {
			t.Errorf("expected content 'Updated content' in DB, got %q", updated.Content)
		}
		if updated.Active {
			t.Error("expected note to be inactive after update")
		}
	})

	t.Run("UpdateNote_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "update_note",
			Arguments: `{"id":"nonexistent-note","title":"No"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent note")
		}
	})

	// Delete a note
	t.Run("DeleteNote", func(t *testing.T) {
		// Create note to delete
		note := &models.Note{
			Type:     "preference",
			Category: "structure",
			Title:    "Delete Me",
			Content:  "This note will be deleted",
			Source:   "ai",
			Active:   true,
		}
		id, _ := models.Notes.Insert(note)

		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "delete_note",
			Arguments: `{"id":"` + id + `"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// Verify deletion
		_, err = models.Notes.Get(id)
		if err == nil {
			t.Error("expected note to be deleted")
		}
	})

	t.Run("DeleteNote_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "delete_note",
			Arguments: `{"id":"nonexistent-note"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent note")
		}
	})

	// List notes
	t.Run("ListNotes", func(t *testing.T) {
		// Create several notes
		var noteIDs []string
		for i, noteData := range []struct {
			typ, category, title string
		}{
			{"preference", "style", "List Note 1"},
			{"convention", "content", "List Note 2"},
			{"learned", "behavior", "List Note 3"},
		} {
			note := &models.Note{
				Type:     noteData.typ,
				Category: noteData.category,
				Title:    noteData.title,
				Content:  "Content for listing",
				Source:   "ai",
				Active:   true,
			}
			id, err := models.Notes.Insert(note)
			if err != nil {
				t.Fatalf("failed to create note %d: %v", i, err)
			}
			noteIDs = append(noteIDs, id)
		}
		defer func() {
			for _, id := range noteIDs {
				n, _ := models.Notes.Get(id)
				if n != nil {
					models.Notes.Delete(n)
				}
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "list_notes",
			Arguments: `{}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		count, ok := resp["count"].(float64)
		if !ok {
			t.Fatal("expected count in response")
		}
		if int(count) < 3 {
			t.Errorf("expected at least 3 notes, got %d", int(count))
		}

		notes, ok := resp["notes"].([]any)
		if !ok {
			t.Fatal("expected notes array in response")
		}
		if len(notes) < 3 {
			t.Errorf("expected at least 3 notes in array, got %d", len(notes))
		}
	})

	t.Run("ListNotes_FilterByType", func(t *testing.T) {
		// Create notes of specific type
		note := &models.Note{
			Type:     "preference",
			Category: "style",
			Title:    "Filter Type Note",
			Content:  "For type filtering",
			Source:   "ai",
			Active:   true,
		}
		id, _ := models.Notes.Insert(note)
		defer func() {
			n, _ := models.Notes.Get(id)
			if n != nil {
				models.Notes.Delete(n)
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "list_notes",
			Arguments: `{"type":"preference"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		notes := resp["notes"].([]any)
		for _, n := range notes {
			noteMap := n.(map[string]any)
			if noteMap["type"] != "preference" {
				t.Errorf("expected all notes to be type 'preference', got %v", noteMap["type"])
			}
		}
	})
}

// =============================================================================
// Partial Tool Execution
// =============================================================================

func TestPartialTools(t *testing.T) {
	exec := assist.NewExecutor(nil)

	// Create a partial
	t.Run("CreatePartial", func(t *testing.T) {
		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "create_partial",
			Arguments: `{"name":"Test Header","html":"<header><h1>My Site</h1></header>","id":"test-header","description":"Site header","published":true}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		if resp["id"] != "test-header" {
			t.Errorf("expected id 'test-header', got %v", resp["id"])
		}
		if resp["name"] != "Test Header" {
			t.Errorf("expected name 'Test Header', got %v", resp["name"])
		}

		// Verify in DB
		partial, err := models.Partials.Get("test-header")
		if err != nil {
			t.Fatalf("failed to get partial: %v", err)
		}
		if partial.Name != "Test Header" {
			t.Errorf("expected name 'Test Header', got %q", partial.Name)
		}
		if partial.HTML != "<header><h1>My Site</h1></header>" {
			t.Errorf("expected HTML '<header><h1>My Site</h1></header>', got %q", partial.HTML)
		}
		if partial.Description != "Site header" {
			t.Errorf("expected description 'Site header', got %q", partial.Description)
		}
		if !partial.Published {
			t.Error("expected partial to be published")
		}

		// Cleanup
		models.Partials.Delete(partial)
	})

	// Get a partial
	t.Run("GetPartial", func(t *testing.T) {
		// Create partial for retrieval
		partial := &models.Partial{
			Name:        "Get Test Partial",
			Description: "For get testing",
			HTML:        "<nav>Navigation</nav>",
			Published:   true,
		}
		partial.ID = "get-test-partial"
		models.Partials.Insert(partial)
		defer func() {
			p, _ := models.Partials.Get("get-test-partial")
			if p != nil {
				models.Partials.Delete(p)
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "get_partial",
			Arguments: `{"id":"get-test-partial"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		if resp["id"] != "get-test-partial" {
			t.Errorf("expected id 'get-test-partial', got %v", resp["id"])
		}
		if resp["name"] != "Get Test Partial" {
			t.Errorf("expected name 'Get Test Partial', got %v", resp["name"])
		}
		if resp["html"] != "<nav>Navigation</nav>" {
			t.Errorf("expected html '<nav>Navigation</nav>', got %v", resp["html"])
		}
		if resp["published"] != true {
			t.Errorf("expected published true, got %v", resp["published"])
		}
	})

	t.Run("GetPartial_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "get_partial",
			Arguments: `{"id":"nonexistent-partial"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent partial")
		}
	})

	// Update a partial
	t.Run("UpdatePartial", func(t *testing.T) {
		// Create partial to update
		partial := &models.Partial{
			Name:        "Original Partial",
			Description: "Original description",
			HTML:        "<footer>Old Footer</footer>",
			Published:   false,
		}
		partial.ID = "update-test-partial"
		models.Partials.Insert(partial)
		defer func() {
			p, _ := models.Partials.Get("update-test-partial")
			if p != nil {
				models.Partials.Delete(p)
			}
		}()

		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "update_partial",
			Arguments: `{"id":"update-test-partial","name":"Updated Partial","html":"<footer>New Footer</footer>","published":true}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		if resp["name"] != "Updated Partial" {
			t.Errorf("expected name 'Updated Partial', got %v", resp["name"])
		}

		// Verify in DB
		updated, err := models.Partials.Get("update-test-partial")
		if err != nil {
			t.Fatalf("failed to get updated partial: %v", err)
		}
		if updated.Name != "Updated Partial" {
			t.Errorf("expected name 'Updated Partial' in DB, got %q", updated.Name)
		}
		if updated.HTML != "<footer>New Footer</footer>" {
			t.Errorf("expected updated HTML, got %q", updated.HTML)
		}
		if !updated.Published {
			t.Error("expected partial to be published after update")
		}
	})

	t.Run("UpdatePartial_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "update_partial",
			Arguments: `{"id":"nonexistent-partial","name":"No"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent partial")
		}
	})

	// Delete a partial
	t.Run("DeletePartial", func(t *testing.T) {
		// Create partial to delete
		partial := &models.Partial{
			Name:      "Delete Me Partial",
			HTML:      "<div>Delete</div>",
			Published: true,
		}
		partial.ID = "delete-test-partial"
		models.Partials.Insert(partial)

		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "delete_partial",
			Arguments: `{"id":"delete-test-partial"}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// Verify deletion
		_, err = models.Partials.Get("delete-test-partial")
		if err == nil {
			t.Error("expected partial to be deleted")
		}
	})

	t.Run("DeletePartial_NotFound", func(t *testing.T) {
		_, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "delete_partial",
			Arguments: `{"id":"nonexistent-partial"}`,
		})
		if err == nil {
			t.Error("expected error for nonexistent partial")
		}
	})

	// List partials
	t.Run("ListPartials", func(t *testing.T) {
		// Create partials
		for _, p := range []struct {
			id, name string
			published bool
		}{
			{"list-partial-1", "List Partial 1", true},
			{"list-partial-2", "List Partial 2", true},
			{"list-partial-3", "List Partial 3", false},
		} {
			partial := &models.Partial{
				Name:      p.name,
				HTML:      "<div>" + p.name + "</div>",
				Published: p.published,
			}
			partial.ID = p.id
			models.Partials.Insert(partial)
		}
		defer func() {
			for _, id := range []string{"list-partial-1", "list-partial-2", "list-partial-3"} {
				p, _ := models.Partials.Get(id)
				if p != nil {
					models.Partials.Delete(p)
				}
			}
		}()

		// List published only (default)
		result, err := exec.Execute(assistant.ToolCall{
			ID:        "call_1",
			Name:      "list_partials",
			Arguments: `{}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var resp map[string]any
		if err := json.Unmarshal([]byte(result), &resp); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		partials := resp["partials"].([]any)
		for _, p := range partials {
			pMap := p.(map[string]any)
			if pMap["published"] != true {
				t.Errorf("expected only published partials in default listing, got unpublished: %v", pMap["id"])
			}
		}

		// List including unpublished
		result, err = exec.Execute(assistant.ToolCall{
			ID:        "call_2",
			Name:      "list_partials",
			Arguments: `{"include_unpublished":true}`,
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		var respAll map[string]any
		if err := json.Unmarshal([]byte(result), &respAll); err != nil {
			t.Fatalf("failed to parse result: %v", err)
		}

		countAll := int(respAll["count"].(float64))
		countPublished := int(resp["count"].(float64))
		if countAll <= countPublished {
			t.Errorf("expected more partials when including unpublished (all=%d, published=%d)", countAll, countPublished)
		}
	})
}

// =============================================================================
// Helpers
// =============================================================================

// assertRequiredParams verifies that a tool has the expected required parameters.
func assertRequiredParams(t *testing.T, tool *assistant.Tool, expected ...string) {
	t.Helper()
	if tool == nil {
		t.Error("tool is nil")
		return
	}
	if tool.Parameters == nil {
		if len(expected) > 0 {
			t.Errorf("tool %q has no parameters, expected required: %v", tool.Name, expected)
		}
		return
	}

	for _, param := range expected {
		found := false
		for _, req := range tool.Parameters.Required {
			if req == param {
				found = true
				break
			}
		}
		if !found {
			t.Errorf("tool %q: expected parameter %q to be required (required list: %v)", tool.Name, param, tool.Parameters.Required)
		}
	}
}
← Back