diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 583737761..708c0aabf 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -30,7 +30,13 @@ module Assistant Function::GetBudget, Function::ImportBankStatement, Function::SearchFamilyFiles, - Function::CreateGoal + Function::CreateGoal, + Function::GetTags, + Function::CreateTag, + Function::UpdateTag, + Function::GetCategories, + Function::CreateCategory, + Function::UpdateCategory ] end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb index 4e918cb54..99e4df300 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/function.rb @@ -79,6 +79,10 @@ class Assistant::Function user.family end + def valid_uuid?(str) + UuidFormat.valid?(str) + end + # To save tokens, we provide the AI metadata about the series and a flat array of # raw, formatted values which it can infer dates from def to_ai_time_series(series) diff --git a/app/models/assistant/function/create_category.rb b/app/models/assistant/function/create_category.rb new file mode 100644 index 000000000..8d1a6c76c --- /dev/null +++ b/app/models/assistant/function/create_category.rb @@ -0,0 +1,81 @@ +class Assistant::Function::CreateCategory < Assistant::Function + class << self + def name + "create_category" + end + + def description + <<~INSTRUCTIONS + Creates a new category for the user's family. + + Categories support two levels of hierarchy: a top-level category can have subcategories, + but subcategories cannot have children. Provide parent_id (from get_categories) to make + a subcategory — it will inherit the parent's color automatically. + + If icon is omitted it is suggested from the name. If color is omitted a palette color is used + (ignored for subcategories since color is inherited). Category names must be unique. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Category name (must be unique within the family)" + }, + color: { + type: "string", + description: "Hex color code (e.g. #e99537). Ignored for subcategories. Defaults to a palette color." + }, + icon: { + type: "string", + description: "Lucide icon name (e.g. 'shopping-cart'). Suggested from name if omitted." + }, + parent_id: { + type: "string", + description: "ID of an existing top-level category to nest under (makes this a subcategory). Use get_categories to find ids." + } + } + ) + end + + def call(params = {}) + name = params["name"].to_s.strip + return error("name_required", "Please provide a name for the category.") if name.blank? + + color = params["color"].presence || Category::COLORS.sample + icon = params["icon"].presence || Category.suggested_icon(name) + attrs = { name: name, color: color, lucide_icon: icon } + + if params["parent_id"].present? + return error("parent_not_found", "Parent category with id '#{params["parent_id"]}' not found.") unless valid_uuid?(params["parent_id"]) + parent = family.categories.find_by(id: params["parent_id"]) + return error("parent_not_found", "Parent category with id '#{params["parent_id"]}' not found.") unless parent + attrs[:parent] = parent + end + + category = family.categories.new(attrs) + + if category.save + { success: true, category: serialize(category), message: "Category '#{category.name_with_parent}' created." } + else + error("validation_failed", category.errors.full_messages.join("; ")) + end + end + + private + def serialize(c) + { id: c.id, name: c.name, name_with_parent: c.name_with_parent, color: c.color, icon: c.lucide_icon, parent_id: c.parent_id } + end + + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/create_tag.rb b/app/models/assistant/function/create_tag.rb new file mode 100644 index 000000000..4acffa838 --- /dev/null +++ b/app/models/assistant/function/create_tag.rb @@ -0,0 +1,56 @@ +class Assistant::Function::CreateTag < Assistant::Function + class << self + def name + "create_tag" + end + + def description + <<~INSTRUCTIONS + Creates a new tag for the user's family. + + Tags are applied to transactions to organize them beyond categories. + If color is omitted, one is chosen automatically from the default palette. + Tag names must be unique within the family. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Tag name (must be unique within the family)" + }, + color: { + type: "string", + description: "Hex color code (e.g. #e99537). If omitted, one is chosen from the default palette." + } + } + ) + end + + def call(params = {}) + name = params["name"].to_s.strip + return error("name_required", "Please provide a name for the tag.") if name.blank? + + color = params["color"].presence || Tag::COLORS.sample + tag = family.tags.new(name: name, color: color) + + if tag.save + { success: true, tag: { id: tag.id, name: tag.name, color: tag.color }, message: "Tag '#{tag.name}' created." } + else + error("validation_failed", tag.errors.full_messages.join("; ")) + end + end + + private + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/get_categories.rb b/app/models/assistant/function/get_categories.rb new file mode 100644 index 000000000..f7bf643d8 --- /dev/null +++ b/app/models/assistant/function/get_categories.rb @@ -0,0 +1,36 @@ +class Assistant::Function::GetCategories < Assistant::Function + class << self + def name + "get_categories" + end + + def description + <<~INSTRUCTIONS + Returns all categories for the user's family, ordered alphabetically by hierarchy. + + Each entry includes id, name, color, icon, parent_id (null for top-level), and + name_with_parent (e.g. "Food & Drink > Restaurants"). Use this before creating + subcategories or referencing a category by id in update_category. + INSTRUCTIONS + end + end + + def call(params = {}) + categories = family.categories.alphabetically_by_hierarchy + + { + categories: categories.map { |c| + { + id: c.id, + name: c.name, + name_with_parent: c.name_with_parent, + color: c.color, + icon: c.lucide_icon, + parent_id: c.parent_id, + is_subcategory: c.subcategory? + } + }, + total: categories.size + } + end +end diff --git a/app/models/assistant/function/get_tags.rb b/app/models/assistant/function/get_tags.rb new file mode 100644 index 000000000..b4dfdcd98 --- /dev/null +++ b/app/models/assistant/function/get_tags.rb @@ -0,0 +1,25 @@ +class Assistant::Function::GetTags < Assistant::Function + class << self + def name + "get_tags" + end + + def description + <<~INSTRUCTIONS + Returns all tags defined for the user's family, sorted alphabetically. + + Use this when the user wants to see available tags or before referencing + a tag in another operation like create_tag or update_tag. + INSTRUCTIONS + end + end + + def call(params = {}) + tags = family.tags.alphabetically + + { + tags: tags.map { |t| { id: t.id, name: t.name, color: t.color } }, + total: tags.size + } + end +end diff --git a/app/models/assistant/function/update_category.rb b/app/models/assistant/function/update_category.rb new file mode 100644 index 000000000..73e50e3b4 --- /dev/null +++ b/app/models/assistant/function/update_category.rb @@ -0,0 +1,73 @@ +class Assistant::Function::UpdateCategory < Assistant::Function + class << self + def name + "update_category" + end + + def description + <<~INSTRUCTIONS + Updates an existing category's name, color, or icon. + + Use get_categories first to find the category id. At least one of name, color, + or icon must be supplied. Changing a parent's color does not cascade to existing + subcategories (their colors are set at creation time). + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "id" ], + properties: { + id: { + type: "string", + description: "ID of the category to update (use get_categories to find it)" + }, + name: { + type: "string", + description: "New name for the category (optional)" + }, + color: { + type: "string", + description: "New hex color code (optional)" + }, + icon: { + type: "string", + description: "New Lucide icon name (optional)" + } + } + ) + end + + def call(params = {}) + return error("not_found", "Category with id '#{params["id"]}' not found.") unless valid_uuid?(params["id"]) + category = family.categories.find_by(id: params["id"]) + return error("not_found", "Category with id '#{params["id"]}' not found.") unless category + + attrs = {} + attrs[:name] = params["name"].to_s.strip if params["name"].present? + attrs[:color] = params["color"].to_s.strip if params["color"].present? + attrs[:lucide_icon] = params["icon"].to_s.strip if params["icon"].present? + + return error("no_changes", "Provide at least one of name, color, or icon to update.") if attrs.empty? + + if category.update(attrs) + { success: true, category: serialize(category), message: "Category '#{category.name_with_parent}' updated." } + else + error("validation_failed", category.errors.full_messages.join("; ")) + end + end + + private + def serialize(c) + { id: c.id, name: c.name, name_with_parent: c.name_with_parent, color: c.color, icon: c.lucide_icon, parent_id: c.parent_id } + end + + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/update_tag.rb b/app/models/assistant/function/update_tag.rb new file mode 100644 index 000000000..750b3184b --- /dev/null +++ b/app/models/assistant/function/update_tag.rb @@ -0,0 +1,63 @@ +class Assistant::Function::UpdateTag < Assistant::Function + class << self + def name + "update_tag" + end + + def description + <<~INSTRUCTIONS + Updates an existing tag's name or color. + + Identify the tag by its current name. At least one of new_name or color must be provided. + Use get_tags first to confirm the tag exists. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Current name of the tag to update", + enum: family_tag_names + }, + new_name: { + type: "string", + description: "New name for the tag (optional)" + }, + color: { + type: "string", + description: "New hex color code (optional)" + } + } + ) + end + + def call(params = {}) + tag = family.tags.find_by(name: params["name"].to_s.strip) + return error("not_found", "Tag '#{params["name"]}' not found.") unless tag + + attrs = {} + attrs[:name] = params["new_name"].strip if params["new_name"].present? + attrs[:color] = params["color"].strip if params["color"].present? + + return error("no_changes", "Provide at least one of new_name or color to update.") if attrs.empty? + + if tag.update(attrs) + { success: true, tag: { id: tag.id, name: tag.name, color: tag.color }, message: "Tag updated." } + else + error("validation_failed", tag.errors.full_messages.join("; ")) + end + end + + private + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/test/models/assistant/function/create_category_test.rb b/test/models/assistant/function/create_category_test.rb new file mode 100644 index 000000000..059ca05be --- /dev/null +++ b/test/models/assistant/function/create_category_test.rb @@ -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 diff --git a/test/models/assistant/function/create_tag_test.rb b/test/models/assistant/function/create_tag_test.rb new file mode 100644 index 000000000..81b4badc5 --- /dev/null +++ b/test/models/assistant/function/create_tag_test.rb @@ -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 diff --git a/test/models/assistant/function/get_categories_test.rb b/test/models/assistant/function/get_categories_test.rb new file mode 100644 index 000000000..7d48462a9 --- /dev/null +++ b/test/models/assistant/function/get_categories_test.rb @@ -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 diff --git a/test/models/assistant/function/get_tags_test.rb b/test/models/assistant/function/get_tags_test.rb new file mode 100644 index 000000000..b5c9ba9c7 --- /dev/null +++ b/test/models/assistant/function/get_tags_test.rb @@ -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 diff --git a/test/models/assistant/function/update_category_test.rb b/test/models/assistant/function/update_category_test.rb new file mode 100644 index 000000000..70e83a485 --- /dev/null +++ b/test/models/assistant/function/update_category_test.rb @@ -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 diff --git a/test/models/assistant/function/update_tag_test.rb b/test/models/assistant/function/update_tag_test.rb new file mode 100644 index 000000000..e86cd6445 --- /dev/null +++ b/test/models/assistant/function/update_tag_test.rb @@ -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