mirror of
https://github.com/we-promise/sure.git
synced 2026-07-21 09:15:23 +00:00
feat(mcp): add get/create/update tools for tags and categories (#2374)
* feat(mcp): add get/create/update tools for tags and categories * test(mcp): add tests for tag and category assistant functions * fix(mcp): harden category/tag assistant functions against bad tool input * refactor(mcp): use UuidFormat.valid? instead of local UUID_PATTERN * chore(mcp): remove account_filter component files that don't belong in this branch
This commit is contained in:
124
test/models/assistant/function/create_category_test.rb
Normal file
124
test/models/assistant/function/create_category_test.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::CreateCategoryTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@fn = Assistant::Function::CreateCategory.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct schema" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "create_category", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
assert_includes definition[:params_schema][:required], "name"
|
||||
end
|
||||
|
||||
test "creates a top-level category with all fields" do
|
||||
assert_difference "@family.categories.count" do
|
||||
result = @fn.call("name" => "Transport", "color" => "#4da568", "icon" => "bus")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "Transport", result[:category][:name]
|
||||
assert_equal "#4da568", result[:category][:color]
|
||||
assert_equal "bus", result[:category][:icon]
|
||||
assert_nil result[:category][:parent_id]
|
||||
end
|
||||
end
|
||||
|
||||
test "auto-suggests icon from name when omitted" do
|
||||
result = @fn.call("name" => "Groceries", "color" => "#4da568")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal Category.suggested_icon("Groceries"), result[:category][:icon]
|
||||
end
|
||||
|
||||
test "auto-assigns color from palette when omitted" do
|
||||
result = @fn.call("name" => "Mystery")
|
||||
|
||||
assert result[:success]
|
||||
assert_includes Category::COLORS, result[:category][:color]
|
||||
end
|
||||
|
||||
test "creates a subcategory under an existing parent" do
|
||||
parent = categories(:food_and_drink)
|
||||
assert_difference "@family.categories.count" do
|
||||
result = @fn.call("name" => "Fast Food", "parent_id" => parent.id)
|
||||
|
||||
assert result[:success]
|
||||
assert_equal parent.id, result[:category][:parent_id]
|
||||
assert_match "Food & Drink > Fast Food", result[:category][:name_with_parent]
|
||||
end
|
||||
end
|
||||
|
||||
test "subcategory inherits parent color" do
|
||||
parent = categories(:food_and_drink)
|
||||
result = @fn.call("name" => "Sushi", "parent_id" => parent.id)
|
||||
|
||||
assert result[:success]
|
||||
assert_equal parent.reload.color, result[:category][:color]
|
||||
end
|
||||
|
||||
test "soft error when name is whitespace only" do
|
||||
result = @fn.call("name" => " ")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "name_required", result[:error]
|
||||
end
|
||||
|
||||
test "empty string parent_id is treated as absent, creates top-level category" do
|
||||
assert_difference "@family.categories.count" do
|
||||
result = @fn.call("name" => "No Parent", "parent_id" => "")
|
||||
|
||||
assert result[:success]
|
||||
assert_nil result[:category][:parent_id]
|
||||
end
|
||||
end
|
||||
|
||||
test "soft error when color format is invalid" do
|
||||
result = @fn.call("name" => "Bad Color Cat", "color" => "not-a-color")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when nesting a subcategory under another subcategory" do
|
||||
sub = categories(:subcategory)
|
||||
result = @fn.call("name" => "Too Deep", "parent_id" => sub.id)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when name is blank" do
|
||||
result = @fn.call("name" => "")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "name_required", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when parent_id does not exist" do
|
||||
result = @fn.call("name" => "Orphan", "parent_id" => "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "parent_not_found", result[:error]
|
||||
end
|
||||
|
||||
test "soft error on duplicate name within family" do
|
||||
existing = categories(:food_and_drink)
|
||||
result = @fn.call("name" => existing.name)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "cannot use a parent from another family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_parent = other_family.categories.create!(name: "Foreign Parent", color: "#e99537", lucide_icon: "shapes")
|
||||
|
||||
result = @fn.call("name" => "Child", "parent_id" => other_parent.id)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "parent_not_found", result[:error]
|
||||
end
|
||||
end
|
||||
70
test/models/assistant/function/create_tag_test.rb
Normal file
70
test/models/assistant/function/create_tag_test.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::CreateTagTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@fn = Assistant::Function::CreateTag.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct schema" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "create_tag", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
assert_includes definition[:params_schema][:required], "name"
|
||||
end
|
||||
|
||||
test "creates a tag with name and color" do
|
||||
assert_difference "@family.tags.count" do
|
||||
result = @fn.call("name" => "Vacation", "color" => "#4da568")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "Vacation", result[:tag][:name]
|
||||
assert_equal "#4da568", result[:tag][:color]
|
||||
assert result[:tag][:id].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "auto-assigns a color from palette when omitted" do
|
||||
result = @fn.call("name" => "Auto Color")
|
||||
|
||||
assert result[:success]
|
||||
assert_includes Tag::COLORS, result[:tag][:color]
|
||||
end
|
||||
|
||||
test "soft error when name is blank" do
|
||||
result = @fn.call("name" => "")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "name_required", result[:error]
|
||||
end
|
||||
|
||||
test "soft error on duplicate name within family" do
|
||||
existing = tags(:one)
|
||||
result = @fn.call("name" => existing.name)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when name is whitespace only" do
|
||||
result = @fn.call("name" => " ")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "name_required", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when color format is invalid" do
|
||||
result = @fn.call("name" => "Bad Color", "color" => "not-a-color")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "scopes created tag to user's family" do
|
||||
@fn.call("name" => "Scoped Tag")
|
||||
tag = @family.tags.find_by(name: "Scoped Tag")
|
||||
assert tag.present?
|
||||
assert_equal @family.id, tag.family_id
|
||||
end
|
||||
end
|
||||
62
test/models/assistant/function/get_categories_test.rb
Normal file
62
test/models/assistant/function/get_categories_test.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::GetCategoriesTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@fn = Assistant::Function::GetCategories.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct name and description" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "get_categories", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
end
|
||||
|
||||
test "returns all family categories" do
|
||||
result = @fn.call
|
||||
|
||||
assert_kind_of Array, result[:categories]
|
||||
assert_equal @family.categories.count, result[:total]
|
||||
end
|
||||
|
||||
test "each category includes required fields" do
|
||||
result = @fn.call
|
||||
result[:categories].each do |c|
|
||||
assert c[:id].present?
|
||||
assert c[:name].present?
|
||||
assert c[:name_with_parent].present?
|
||||
assert c[:color].present?
|
||||
assert c[:icon].present?
|
||||
assert c.key?(:parent_id)
|
||||
assert c.key?(:is_subcategory)
|
||||
end
|
||||
end
|
||||
|
||||
test "subcategory is_subcategory is true and has parent_id" do
|
||||
result = @fn.call
|
||||
sub = result[:categories].find { |c| c[:name] == categories(:subcategory).name }
|
||||
|
||||
assert sub.present?
|
||||
assert sub[:is_subcategory]
|
||||
assert_equal categories(:food_and_drink).id, sub[:parent_id]
|
||||
end
|
||||
|
||||
test "top-level category has nil parent_id and is_subcategory false" do
|
||||
result = @fn.call
|
||||
top = result[:categories].find { |c| c[:name] == categories(:food_and_drink).name }
|
||||
|
||||
assert top.present?
|
||||
assert_not top[:is_subcategory]
|
||||
assert_nil top[:parent_id]
|
||||
end
|
||||
|
||||
test "scopes to the user's family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_family.categories.create!(name: "Foreign Category", color: "#e99537", lucide_icon: "shapes")
|
||||
|
||||
result = @fn.call
|
||||
category_names = result[:categories].map { |c| c[:name] }
|
||||
assert_not_includes category_names, "Foreign Category"
|
||||
end
|
||||
end
|
||||
44
test/models/assistant/function/get_tags_test.rb
Normal file
44
test/models/assistant/function/get_tags_test.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::GetTagsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@fn = Assistant::Function::GetTags.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct name and description" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "get_tags", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
assert_equal "object", definition[:params_schema][:type]
|
||||
end
|
||||
|
||||
test "returns all family tags sorted alphabetically" do
|
||||
result = @fn.call
|
||||
|
||||
assert_kind_of Array, result[:tags]
|
||||
assert_equal @family.tags.count, result[:total]
|
||||
|
||||
names = result[:tags].map { |t| t[:name] }
|
||||
assert_equal names.sort, names
|
||||
end
|
||||
|
||||
test "each tag includes id, name, and color" do
|
||||
result = @fn.call
|
||||
result[:tags].each do |t|
|
||||
assert t[:id].present?
|
||||
assert t[:name].present?
|
||||
assert t[:color].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "scopes to the user's family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_family.tags.create!(name: "Foreign Tag")
|
||||
|
||||
result = @fn.call
|
||||
tag_names = result[:tags].map { |t| t[:name] }
|
||||
assert_not_includes tag_names, "Foreign Tag"
|
||||
end
|
||||
end
|
||||
104
test/models/assistant/function/update_category_test.rb
Normal file
104
test/models/assistant/function/update_category_test.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::UpdateCategoryTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@category = categories(:food_and_drink)
|
||||
@fn = Assistant::Function::UpdateCategory.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct schema" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "update_category", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
assert_includes definition[:params_schema][:required], "id"
|
||||
end
|
||||
|
||||
test "updates category name" do
|
||||
result = @fn.call("id" => @category.id, "name" => "Food & Beverages")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "Food & Beverages", result[:category][:name]
|
||||
assert_equal "Food & Beverages", @category.reload.name
|
||||
end
|
||||
|
||||
test "updates category color" do
|
||||
result = @fn.call("id" => @category.id, "color" => "#6471eb")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "#6471eb", result[:category][:color]
|
||||
assert_equal "#6471eb", @category.reload.color
|
||||
end
|
||||
|
||||
test "updates category icon" do
|
||||
result = @fn.call("id" => @category.id, "icon" => "pizza")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "pizza", result[:category][:icon]
|
||||
assert_equal "pizza", @category.reload.lucide_icon
|
||||
end
|
||||
|
||||
test "updates multiple fields at once" do
|
||||
result = @fn.call("id" => @category.id, "name" => "Dining", "color" => "#db5a54", "icon" => "utensils")
|
||||
|
||||
assert result[:success]
|
||||
@category.reload
|
||||
assert_equal "Dining", @category.name
|
||||
assert_equal "#db5a54", @category.color
|
||||
assert_equal "utensils", @category.lucide_icon
|
||||
end
|
||||
|
||||
test "result includes name_with_parent for subcategory" do
|
||||
sub = categories(:subcategory)
|
||||
result = @fn.call("id" => sub.id, "icon" => "coffee")
|
||||
|
||||
assert result[:success]
|
||||
assert_match(/#{sub.parent.name}/, result[:category][:name_with_parent])
|
||||
end
|
||||
|
||||
test "soft error when id is nil" do
|
||||
result = @fn.call("name" => "X")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
|
||||
test "whitespace-only name is treated as absent, returns no_changes" do
|
||||
result = @fn.call("id" => @category.id, "name" => " ")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_changes", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when color format is invalid" do
|
||||
result = @fn.call("id" => @category.id, "color" => "not-a-color")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when category not found" do
|
||||
result = @fn.call("id" => "00000000-0000-0000-0000-000000000000", "name" => "X")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when no changes provided" do
|
||||
result = @fn.call("id" => @category.id)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_changes", result[:error]
|
||||
end
|
||||
|
||||
test "cannot update a category from another family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_cat = other_family.categories.create!(name: "Foreign", color: "#e99537", lucide_icon: "shapes")
|
||||
|
||||
result = @fn.call("id" => other_cat.id, "name" => "Hijacked")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
end
|
||||
91
test/models/assistant/function/update_tag_test.rb
Normal file
91
test/models/assistant/function/update_tag_test.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::UpdateTagTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@tag = tags(:one)
|
||||
@fn = Assistant::Function::UpdateTag.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns correct schema" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "update_tag", definition[:name]
|
||||
assert_not_empty definition[:description]
|
||||
assert_includes definition[:params_schema][:required], "name"
|
||||
end
|
||||
|
||||
test "params_schema enumerates family tag names" do
|
||||
schema = @fn.params_schema
|
||||
assert_includes schema[:properties][:name][:enum], @tag.name
|
||||
end
|
||||
|
||||
test "updates tag name" do
|
||||
result = @fn.call("name" => @tag.name, "new_name" => "Updated Name")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "Updated Name", result[:tag][:name]
|
||||
assert_equal "Updated Name", @tag.reload.name
|
||||
end
|
||||
|
||||
test "updates tag color" do
|
||||
result = @fn.call("name" => @tag.name, "color" => "#6471eb")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "#6471eb", result[:tag][:color]
|
||||
assert_equal "#6471eb", @tag.reload.color
|
||||
end
|
||||
|
||||
test "updates both name and color at once" do
|
||||
result = @fn.call("name" => @tag.name, "new_name" => "Both Updated", "color" => "#db5a54")
|
||||
|
||||
assert result[:success]
|
||||
assert_equal "Both Updated", @tag.reload.name
|
||||
assert_equal "#db5a54", @tag.reload.color
|
||||
end
|
||||
|
||||
test "soft error when tag not found" do
|
||||
result = @fn.call("name" => "Nonexistent Tag", "new_name" => "X")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when no changes provided" do
|
||||
result = @fn.call("name" => @tag.name)
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_changes", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when lookup name is empty string" do
|
||||
result = @fn.call("name" => "", "new_name" => "X")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
|
||||
test "empty string new_name is treated as absent, returns no_changes" do
|
||||
result = @fn.call("name" => @tag.name, "new_name" => "")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_changes", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when color format is invalid" do
|
||||
result = @fn.call("name" => @tag.name, "color" => "not-a-color")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "validation_failed", result[:error]
|
||||
end
|
||||
|
||||
test "cannot update a tag from another family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_tag = other_family.tags.create!(name: "Other Tag")
|
||||
|
||||
result = @fn.call("name" => other_tag.name, "new_name" => "Hijacked")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "not_found", result[:error]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user