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:
Blaž Dular
2026-06-21 12:01:39 +02:00
committed by GitHub
parent 875b58d9ca
commit 974f1d8e44
14 changed files with 840 additions and 1 deletions

View 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

View 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

View 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

View 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

View 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

View 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