# frozen_string_literal: true RSpec.describe DiscourseAi::Utils::AiStaffActionLogger do fab!(:admin) fab!(:llm_model) fab!(:ai_persona) fab!(:group) subject { described_class.new(admin) } describe "#log_creation" do it "logs creation of an entity with field configuration" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) # Create field configuration field_config = { name: {}, provider: {}, url: {}, api_key: { type: :sensitive } } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } # Setup model with sensitive data llm_model.update!(api_key: "secret_key") subject.log_creation("llm_model", llm_model, field_config, entity_details) expect(staff_action_logger).to have_received(:log_custom).with( "create_ai_llm_model", hash_including( "model_id" => llm_model.id, "name" => llm_model.name, "provider" => llm_model.provider, "url" => llm_model.url, "api_key" => "[FILTERED]", ), ) end it "handles large text fields with type declaration" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) # Create a persona with a large system prompt large_prompt = "a" * 200 ai_persona.update!(system_prompt: large_prompt) # Create entity details entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } field_config = { name: {}, description: {}, system_prompt: { type: :large_text } } subject.log_creation("persona", ai_persona, field_config, entity_details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "create_ai_persona", hash_including( "persona_id" => ai_persona.id, "name" => ai_persona.name, "system_prompt" => an_instance_of(String), ), ) do |action, details| # Check that system_prompt was truncated expect(details["system_prompt"].length).to be < 200 end end it "allows excluding fields from extraction" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) field_config = { name: { }, display_name: { }, provider: { extract: false, }, # Should be excluded url: { }, } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } subject.log_creation("llm_model", llm_model, field_config, entity_details) expect(staff_action_logger).to have_received(:log_custom).with( "create_ai_llm_model", hash_including( "model_id" => llm_model.id, "name" => llm_model.name, "display_name" => llm_model.display_name, "url" => llm_model.url, ), ) do |action, details| # Provider should not be present expect(details).not_to have_key("provider") end end end describe "#log_update" do it "handles empty arrays and complex JSON properly" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) # Setup initial attributes with empty JSON arrays initial_attributes = { "name" => "Old Name", "allowed_group_ids" => [] } # Update with complex JSON ai_persona.update!(name: "New Name", allowed_group_ids: [group.id, 999]) field_config = { name: {}, json_fields: %w[allowed_group_ids] } # Create entity details entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "update_ai_persona", hash_including( "persona_id" => ai_persona.id, "persona_name" => ai_persona.name, "name" => "Old Name → New Name", "allowed_group_ids" => "updated", ), ) end it "logs changes to attributes based on field configuration" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) initial_attributes = { "name" => "Old Name", "display_name" => "Old Display Name", "provider" => "open_ai", "api_key" => "old_secret", } llm_model.update!( name: "New Name", display_name: "New Display Name", provider: "anthropic", api_key: "new_secret", ) field_config = { name: {}, display_name: {}, provider: {}, api_key: { type: :sensitive } } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "update_ai_llm_model", hash_including( "model_id" => llm_model.id, "name" => "Old Name → New Name", "display_name" => "Old Display Name → New Display Name", "provider" => "open_ai → anthropic", "api_key" => "updated", # Not showing actual values ), ) end it "doesn't log when there are no changes" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) initial_attributes = { "name" => llm_model.name, "display_name" => llm_model.display_name, "provider" => llm_model.provider, } field_config = { name: {}, display_name: {}, provider: {} } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) # Verify log_custom was not called expect(staff_action_logger).not_to have_received(:log_custom) end it "handles fields marked as not to be tracked" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) initial_attributes = { "name" => "Old Name", "display_name" => "Old Display Name", "provider" => "open_ai", } llm_model.update!(name: "New Name", display_name: "New Display Name", provider: "anthropic") field_config = { name: { }, display_name: { }, provider: { track: false, }, # Should not be tracked even though it changed } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) # Provider should not appear in the logged changes expect(staff_action_logger).to have_received(:log_custom).with( "update_ai_llm_model", hash_including( "model_id" => llm_model.id, "name" => "Old Name → New Name", "display_name" => "Old Display Name → New Display Name", ), ) do |action, details| expect(details).not_to have_key("provider") end end it "handles json fields properly" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) # Setup initial attributes with JSON fields initial_attributes = { "name" => "Old Name", "tools" => [["search", { "base_query" => "test" }, true]], } # Update with different JSON ai_persona.update!( name: "New Name", tools: [["search", { "base_query" => "updated" }, true], ["categories", {}, false]], ) field_config = { name: {}, json_fields: %w[tools] } # Create entity details entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "update_ai_persona", hash_including( "persona_id" => ai_persona.id, "persona_name" => ai_persona.name, "name" => "Old Name → New Name", "tools" => "updated", ), ) end end describe "#log_deletion" do it "logs deletion with the correct entity type" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) details = { model_id: llm_model.id, display_name: llm_model.display_name, name: llm_model.name, } subject.log_deletion("llm_model", details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "delete_ai_llm_model", hash_including( "model_id" => details[:model_id], "display_name" => details[:display_name], "name" => details[:name], ), ) end end describe "#log_custom" do it "delegates to StaffActionLogger#log_custom" do staff_action_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) allow(staff_action_logger).to receive(:log_custom) details = { key: "value" } subject.log_custom("custom_action_type", details) # Verify with have_received expect(staff_action_logger).to have_received(:log_custom).with( "custom_action_type", hash_including("key" => details[:key]), ) end end describe "Special cases from controllers" do context "with EmbeddingDefinition" do fab!(:embedding_definition) { Fabricate( :embedding_definition, display_name: "Test Embedding", dimensions: 768, provider: "open_ai" ) } it "includes dimensions in logged data" do # Setup staff_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) allow(staff_logger).to receive(:log_custom) # Create entity details entity_details = { embedding_id: embedding_definition.id, subject: embedding_definition.display_name } # Field config without dimensions field_config = { display_name: {}, provider: {}, url: {} } logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) logger.log_creation("embedding", embedding_definition, field_config, entity_details) # Verify with have_received expect(staff_logger).to have_received(:log_custom).with( "create_ai_embedding", hash_including("dimensions" => 768) ) end end context "with LlmModel quotas" do before do # Create a quota for the model @quota = Fabricate(:llm_quota, llm_model: llm_model, group: group, max_tokens: 1000) end it "handles quota changes in log_llm_model_creation" do # Setup staff_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) allow(staff_logger).to receive(:log_custom) # Call the method directly as it would be called from the controller logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) field_config = { display_name: {}, name: {} } # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } log_details = entity_details.dup log_details.merge!(logger.send(:extract_entity_attributes, llm_model, field_config)) # Add quota information as a special case log_details[:quotas] = llm_model .llm_quotas .map do |quota| "Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s" end .join("; ") logger.log_custom("create_ai_llm_model", log_details) # Verify with have_received expect(staff_logger).to have_received(:log_custom).with( "create_ai_llm_model", hash_including( "model_id" => llm_model.id, "model_name" => llm_model.name, "display_name" => llm_model.display_name, ), ) expect(staff_logger).to have_received(:log_custom).with( "create_ai_llm_model", hash_including("quotas" => a_string_including("Group #{group.id}", "1000 tokens")), ) end it "handles quota changes in log_llm_model_update" do initial_quotas = llm_model.llm_quotas.map(&:attributes) # Update the quota @quota.update!(max_tokens: 2000) current_quotas = llm_model.llm_quotas.reload.map(&:attributes) # Setup staff_logger = instance_double(StaffActionLogger) allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) allow(staff_logger).to receive(:log_custom) # Simulate the special quota handling in the controller logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) changes = {} # Track quota changes separately as they're a special case if initial_quotas != current_quotas initial_quota_summary = initial_quotas .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } .join("; ") current_quota_summary = current_quotas .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } .join("; ") changes[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}" end # Create entity details entity_details = { model_id: llm_model.id, model_name: llm_model.name, display_name: llm_model.display_name, } log_details = entity_details.dup.merge(changes) logger.log_custom("update_ai_llm_model", log_details) # Verify with have_received expect(staff_logger).to have_received(:log_custom).with( "update_ai_llm_model", hash_including( "model_id" => llm_model.id, "quotas" => a_string_including("1000 tokens", "2000 tokens"), ), ) end end end end