diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a106fcd..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CI - -on: - push: - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - - - name: Install dependencies - run: | - gem install rubocop rubocop-rails rubocop-performance - - - name: Run linter - run: | - chmod +x bin/lint - bin/lint - - syntax: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - - - name: Check Ruby syntax - run: | - find . -name "*.rb" -not -path "./vendor/*" -not -path "./tmp/*" -exec ruby -c {} \; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a6229da --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run linter + run: bin/lint diff --git a/.rubocop.yml b/.rubocop.yml index cec8a7a..92d2976 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,17 +1,19 @@ AllCops: - NewCops: enable TargetRubyVersion: 3.2 - Include: - - "app/**/*.rb" - - "Gemfile" + NewCops: enable Exclude: - "db/**/*" - "bin/**/*" - "config/**/*" - "script/**/*" - - "test/**/*" - "vendor/**/*" - - "lib/**/*" + +Metrics: + Enabled: false + +Naming/VariableNumber: + CheckMethodNames: false + CheckSymbols: false Style/Documentation: Enabled: false @@ -22,12 +24,6 @@ Style/FrozenStringLiteralComment: Style/StringLiterals: EnforcedStyle: double_quotes -Metrics/MethodLength: - Max: 20 - -Metrics/AbcSize: - Max: 25 - Style/SymbolArray: EnforcedStyle: brackets @@ -37,5 +33,8 @@ Style/GuardClause: Style/IfUnlessModifier: Enabled: false +Style/TrailingCommaInHashLiteral: + Enabled: false + Style/Proc: Enabled: false diff --git a/bin/lint b/bin/lint index bf46752..a4dd650 100755 --- a/bin/lint +++ b/bin/lint @@ -5,8 +5,11 @@ set -e +echo "Installing Rubocop..." +gem install 'rubocop:~> 1.76.0' 'rubocop-rails:~> 2.32.0' 'rubocop-performance:~> 1.25.0' + echo "Running Rubocop linter..." -rubocop --ignore-parent-exclusion --force-exclusion app/ Gemfile $@ +rubocop --ignore-parent-exclusion --force-exclusion $@ exit_code=$? diff --git a/init.rb b/init.rb index a3c8e75..c042b95 100644 --- a/init.rb +++ b/init.rb @@ -14,7 +14,8 @@ Redmine::Plugin.register :redmine_semantic_search do name "Semantic Search" author "Sami Hindi @ Renuo" - description "This redmine plugin allows you to search issues using natural language, by storing the issue content in a vector database." + description "This redmine plugin allows you to search issues using natural language, " \ + "by storing the issue content in a vector database." version "0.0.1" url "https://github.com/renuo/redmine_semantic_search" author_url "https://github.com/renuo" @@ -31,7 +32,7 @@ caption: :label_redmine_semantic_search, if: Proc.new { user = User.current - Setting.plugin_redmine_semantic_search['enabled'] == '1' && + Setting.plugin_redmine_semantic_search["enabled"] == "1" && user.logged? && user.allowed_to?(:use_semantic_search, nil, global: true) } diff --git a/lib/redmine_semantic_search/hooks/view_hooks.rb b/lib/redmine_semantic_search/hooks/view_hooks.rb index 955f554..9a13a8f 100644 --- a/lib/redmine_semantic_search/hooks/view_hooks.rb +++ b/lib/redmine_semantic_search/hooks/view_hooks.rb @@ -1,9 +1,9 @@ module RedmineSemanticSearch module Hooks class ViewHooks < Redmine::Hook::ViewListener - render_on :view_issues_index_top, partial: 'redmine_semantic_search/issues_sync_button' + render_on :view_issues_index_top, partial: "redmine_semantic_search/issues_sync_button" - render_on :view_issues_list_header, partial: 'redmine_semantic_search/issues_sync_button_toolbar' + render_on :view_issues_list_header, partial: "redmine_semantic_search/issues_sync_button_toolbar" end end end diff --git a/lib/redmine_semantic_search/issue_hooks.rb b/lib/redmine_semantic_search/issue_hooks.rb index 83fb193..241041a 100644 --- a/lib/redmine_semantic_search/issue_hooks.rb +++ b/lib/redmine_semantic_search/issue_hooks.rb @@ -14,14 +14,14 @@ def controller_issues_edit_after_save(context = {}) def controller_journals_edit_post(context = {}) journal = context[:journal] - if journal.present? && journal.journalized_type == 'Issue' && plugin_enabled? + if journal.present? && journal.journalized_type == "Issue" && plugin_enabled? schedule_embedding_job(journal.journalized_id) end end def controller_journals_new_after_save(context = {}) journal = context[:journal] - if journal.present? && journal.journalized_type == 'Issue' && plugin_enabled? + if journal.present? && journal.journalized_type == "Issue" && plugin_enabled? schedule_embedding_job(journal.journalized_id) end end diff --git a/lib/tasks/redmine_semantic_search_setup.rake b/lib/tasks/redmine_semantic_search_setup.rake index 52b9b87..21e3cd3 100644 --- a/lib/tasks/redmine_semantic_search_setup.rake +++ b/lib/tasks/redmine_semantic_search_setup.rake @@ -13,19 +13,23 @@ namespace :redmine_semantic_search do puts "#{colors[:cyan]}Starting Redmine development data setup for Semantic Search plugin...#{colors[:reset]}" - admin_user = User.find_by(admin: true) || User.find_by(login: 'admin') || User.first + admin_user = User.find_by(admin: true) || User.find_by(login: "admin") || User.first unless admin_user - puts "#{colors[:red]}Error: Could not find an admin user to assign as author. Please ensure at least one user exists.#{colors[:reset]}" + puts "#{colors[:red]}Error: Could not find an admin user to assign as author. " \ + "Please ensure at least one user exists.#{colors[:reset]}" exit 1 end - puts "Using user '#{colors[:yellow]}#{admin_user.login}#{colors[:reset]}' (ID: #{admin_user.id}) as author for issues." + puts "Using user '#{colors[:yellow]}#{admin_user.login}#{colors[:reset]}' " \ + "(ID: #{admin_user.id}) as author for issues." initial_issue_status = IssueStatus.order(:position).first unless initial_issue_status - puts "#{colors[:red]}Error: No issue statuses found in the system. Please ensure Redmine has default statuses configured (e.g., via Administration > Issue Statuses).#{colors[:reset]}" + puts "#{colors[:red]}Error: No issue statuses found in the system. Please ensure Redmine has default statuses " \ + "configured (e.g., via Administration > Issue Statuses).#{colors[:reset]}" exit 1 end - puts "Using initial issue status '#{colors[:yellow]}#{initial_issue_status.name}#{colors[:reset]}' (ID: #{initial_issue_status.id}) as the default for new trackers and new issues." + puts "Using initial issue status '#{colors[:yellow]}#{initial_issue_status.name}#{colors[:reset]}' " \ + "(ID: #{initial_issue_status.id}) as the default for new trackers and new issues." bug_tracker = Tracker.find_or_create_by!(name: "Bug") do |t| t.default_status_id = initial_issue_status.id @@ -41,44 +45,83 @@ namespace :redmine_semantic_search do default_priority = IssuePriority.find_by(name: "Normal") || IssuePriority.order(:position).first unless default_priority - puts "#{colors[:red]}Error: No issue priorities found. Please ensure Redmine has default priorities configured.#{colors[:reset]}" + puts "#{colors[:red]}Error: No issue priorities found. " \ + "Please ensure Redmine has default priorities configured.#{colors[:reset]}" exit 1 end - puts "Using default priority '#{colors[:yellow]}#{default_priority.name}#{colors[:reset]}' (ID: #{default_priority.id}) for new issues." + puts "Using default priority '#{colors[:yellow]}#{default_priority.name}#{colors[:reset]}' " \ + "(ID: #{default_priority.id}) for new issues." projects_data = [ { name: "AI-Powered Content Moderation System", identifier: "ai-content-mod-#{Time.now.to_i}", - description: "Develop an AI system to automatically moderate user-generated content, flag inappropriate submissions, and reduce manual review workload.", + description: "Develop an AI system to automatically moderate user-generated content, " \ + "flag inappropriate submissions, and reduce manual review workload.", issues: [ - { subject: "Develop hate speech detection model", description: "Research and implement a machine learning model for detecting hate speech in text.", tracker: feature_tracker }, - { subject: "Integrate profanity filter API", description: "Connect with a third-party profanity filter service.", tracker: feature_tracker }, - { subject: "Image recognition for inappropriate content", description: "Build or integrate a module to identify nudity or violence in images.", tracker: feature_tracker }, - { subject: "User reporting and appeal system", description: "Allow users to report content and appeal moderation decisions.", tracker: feature_tracker }, - { subject: "Dashboard for moderators", description: "Create an admin interface for moderators to review flagged content and manage rules.", tracker: feature_tracker }, - { subject: "Performance testing for high volume", description: "Ensure the system can handle a large number of submissions per second.", tracker: bug_tracker }, - { subject: "Multilingual support for text analysis", description: "Extend text analysis capabilities to support Spanish and French.", tracker: feature_tracker }, - { subject: "False positive rate analysis", description: "Monitor and work on reducing the false positive rate of the AI models.", tracker: bug_tracker }, - { subject: "Documentation for API endpoints", description: "Provide clear documentation for the system\'s APIs.", tracker: feature_tracker }, - { subject: "Setup CI/CD pipeline for model deployment", description: "Automate the training and deployment process for new model versions.", tracker: feature_tracker } + { subject: "Develop hate speech detection model", + description: "Research and implement a machine learning model for detecting hate speech in text.", + tracker: feature_tracker }, + { subject: "Integrate profanity filter API", + description: "Connect with a third-party profanity filter service.", tracker: feature_tracker }, + { subject: "Image recognition for inappropriate content", + description: "Build or integrate a module to identify nudity or violence in images.", + tracker: feature_tracker }, + { subject: "User reporting and appeal system", + description: "Allow users to report content and appeal moderation decisions.", tracker: feature_tracker }, + { subject: "Dashboard for moderators", + description: "Create an admin interface for moderators to review flagged content and manage rules.", + tracker: feature_tracker }, + { subject: "Performance testing for high volume", + description: "Ensure the system can handle a large number of submissions per second.", + tracker: bug_tracker }, + { subject: "Multilingual support for text analysis", + description: "Extend text analysis capabilities to support Spanish and French.", + tracker: feature_tracker }, + { subject: "False positive rate analysis", + description: "Monitor and work on reducing the false positive rate of the AI models.", + tracker: bug_tracker }, + { subject: "Documentation for API endpoints", + description: "Provide clear documentation for the system's APIs.", tracker: feature_tracker }, + { subject: "Setup CI/CD pipeline for model deployment", + description: "Automate the training and deployment process for new model versions.", + tracker: feature_tracker } ] }, { name: "Smart City Environmental Monitoring", identifier: "smartcity-envmon-#{Time.now.to_i}", - description: "A project to deploy IoT sensors across the city for real-time environmental data collection (air quality, noise pollution, temperature) and analysis.", + description: "A project to deploy IoT sensors across the city for real-time environmental data collection " \ + "(air quality, noise pollution, temperature) and analysis.", issues: [ - { subject: "Select and procure IoT sensor hardware", description: "Evaluate and purchase sensors for air quality, noise, and temperature.", tracker: feature_tracker }, - { subject: "Develop sensor data ingestion platform", description: "Build a scalable platform to receive and store data from thousands of sensors.", tracker: feature_tracker }, - { subject: "Create real-time data visualization dashboard", description: "Display sensor data on a map and through charts for public and city official access.", tracker: feature_tracker }, - { subject: "Implement alert system for pollution thresholds", description: "Notify authorities when pollution levels exceed predefined safety limits.", tracker: feature_tracker }, - { subject: "Data analytics for trend identification", description: "Develop algorithms to identify pollution trends and sources.", tracker: feature_tracker }, - { subject: "Mobile app for citizen reporting", description: "Allow citizens to report environmental issues via a mobile application.", tracker: bug_tracker }, - { subject: "Ensure data security and privacy", description: "Implement robust security measures for the collected data.", tracker: feature_tracker }, - { subject: "Integrate with existing city GIS systems", description: "Overlay environmental data on current city geographical information systems.", tracker: feature_tracker }, - { subject: "Power management for remote sensors", description: "Optimize power consumption for sensors in locations without direct power access.", tracker: bug_tracker }, - { subject: "Long-term data archiving strategy", description: "Plan for the storage and accessibility of historical environmental data.", tracker: feature_tracker } + { subject: "Select and procure IoT sensor hardware", + description: "Evaluate and purchase sensors for air quality, noise, and temperature.", + tracker: feature_tracker }, + { subject: "Develop sensor data ingestion platform", + description: "Build a scalable platform to receive and store data from thousands of sensors.", + tracker: feature_tracker }, + { subject: "Create real-time data visualization dashboard", + description: "Display sensor data on a map and through charts for public and city official access.", + tracker: feature_tracker }, + { subject: "Implement alert system for pollution thresholds", + description: "Notify authorities when pollution levels exceed predefined safety limits.", + tracker: feature_tracker }, + { subject: "Data analytics for trend identification", + description: "Develop algorithms to identify pollution trends and sources.", tracker: feature_tracker }, + { subject: "Mobile app for citizen reporting", + description: "Allow citizens to report environmental issues via a mobile application.", + tracker: bug_tracker }, + { subject: "Ensure data security and privacy", + description: "Implement robust security measures for the collected data.", tracker: feature_tracker }, + { subject: "Integrate with existing city GIS systems", + description: "Overlay environmental data on current city geographical information systems.", + tracker: feature_tracker }, + { subject: "Power management for remote sensors", + description: "Optimize power consumption for sensors in locations without direct power access.", + tracker: bug_tracker }, + { subject: "Long-term data archiving strategy", + description: "Plan for the storage and accessibility of historical environmental data.", + tracker: feature_tracker } ] } ] @@ -86,30 +129,34 @@ namespace :redmine_semantic_search do projects_data.each_with_index do |p_data, index| puts "\n#{colors[:blue]}--- Creating Project #{index + 1}: #{p_data[:name]} ---#{colors[:reset]}" project = Project.find_by(identifier: p_data[:identifier]) - unless project + if project + puts "#{colors[:yellow]}Project '#{p_data[:name]}' (identifier: #{p_data[:identifier]}) already exists. " \ + "ID: #{project.id}.#{colors[:reset]}" + else project = Project.new( name: p_data[:name], identifier: p_data[:identifier], description: p_data[:description], is_public: true, - enabled_module_names: ['issue_tracking'] + enabled_module_names: ["issue_tracking"] ) project.trackers = default_trackers if project.save - puts "#{colors[:green]}Project '#{project.name}' created successfully with ID: #{project.id}.#{colors[:reset]}" + puts "#{colors[:green]}Project '#{project.name}' created successfully with ID: " \ + "#{project.id}.#{colors[:reset]}" else - puts "#{colors[:red]}Failed to create project '#{p_data[:name]}': #{project.errors.full_messages.join(', ')}#{colors[:reset]}" + puts "#{colors[:red]}Failed to create project '#{p_data[:name]}': " \ + "#{project.errors.full_messages.join(', ')}#{colors[:reset]}" next end - else - puts "#{colors[:yellow]}Project '#{p_data[:name]}' (identifier: #{p_data[:identifier]}) already exists. ID: #{project.id}.#{colors[:reset]}" end puts "Creating issues for project '#{colors[:magenta]}#{p_data[:name]}#{colors[:reset]}' (ID: #{project.id})..." p_data[:issues].each do |issue_data| existing_issue = Issue.find_by(project_id: project.id, subject: issue_data[:subject]) if existing_issue - puts "#{colors[:yellow]}Issue '#{issue_data[:subject]}' already exists in project '#{project.name}'. Skipping.#{colors[:reset]}" + puts "#{colors[:yellow]}Issue '#{issue_data[:subject]}' already exists in project '#{project.name}'. " \ + "Skipping.#{colors[:reset]}" next end @@ -124,9 +171,11 @@ namespace :redmine_semantic_search do start_date: Date.today ) if issue.save - puts "#{colors[:green]}Issue '#{issue.subject}' created successfully for project '#{project.name}'.#{colors[:reset]}" + puts "#{colors[:green]}Issue '#{issue.subject}' created successfully for project " \ + "'#{project.name}'.#{colors[:reset]}" else - puts "#{colors[:red]}Failed to create issue '#{issue_data[:subject]}' for project '#{project.name}': #{issue.errors.full_messages.join(', ')}#{colors[:reset]}" + puts "#{colors[:red]}Failed to create issue '#{issue_data[:subject]}' for project " \ + "'#{project.name}': #{issue.errors.full_messages.join(', ')}#{colors[:reset]}" end end end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 77e41e2..e5767d0 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,8 +1,8 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] do |driver_options| - driver_options.add_argument 'no-sandbox' - driver_options.add_argument 'disable-dev-shm-usage' - driver_options.add_argument 'disable-gpu' + driver_options.add_argument "no-sandbox" + driver_options.add_argument "disable-dev-shm-usage" + driver_options.add_argument "disable-gpu" end include LoginHelpers::System @@ -12,17 +12,17 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase end def log_user(login, password) - visit '/login' - fill_in 'username', with: login - fill_in 'password', with: password - click_button 'Login', wait: 5 - assert_selector '#loggedas', wait: 5 + visit "/login" + fill_in "username", with: login + fill_in "password", with: password + click_button "Login", wait: 5 + assert_selector "#loggedas", wait: 5 end def logout - if has_link?(class: 'logout') - click_link(class: 'logout', wait: 5) + if has_link?(class: "logout") + click_link(class: "logout", wait: 5) end - assert_no_selector '#loggedas', wait: 5 + assert_no_selector "#loggedas", wait: 5 end end diff --git a/test/functional/redmine_semantic_search_controller_test.rb b/test/functional/redmine_semantic_search_controller_test.rb index 0bbff14..ae861bf 100644 --- a/test/functional/redmine_semantic_search_controller_test.rb +++ b/test/functional/redmine_semantic_search_controller_test.rb @@ -1,8 +1,9 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class RedmineSemanticSearchControllerTest < Redmine::ControllerTest include ActiveJob::TestHelper include ApplicationHelper + fixtures :projects, :users, :roles, :members, :member_roles, :issues, :trackers def setup @@ -16,47 +17,47 @@ def setup def test_index get :index assert_response :success - assert_select 'h2', 'Semantic Search' - assert_select 'form#redmine-semantic-search-form' + assert_select "h2", "Semantic Search" + assert_select "form#redmine-semantic-search-form" end def test_index_with_query search_results = [ { - 'issue_id' => 1, - 'subject' => 'Test issue', - 'project_name' => 'eCookbook', - 'tracker_name' => 'Bug', - 'status_name' => 'New', - 'priority_name' => 'Normal', - 'author_name' => 'Admin', - 'assigned_to_name' => 'John Smith', - 'created_on' => Time.now.to_s, - 'updated_on' => Time.now.to_s, - 'description' => 'Test description', - 'similarity_score' => 0.85 + "issue_id" => 1, + "subject" => "Test issue", + "project_name" => "eCookbook", + "tracker_name" => "Bug", + "status_name" => "New", + "priority_name" => "Normal", + "author_name" => "Admin", + "assigned_to_name" => "John Smith", + "created_on" => Time.now.to_s, + "updated_on" => Time.now.to_s, + "description" => "Test description", + "similarity_score" => 0.85 } ] - search_service = mock('RedmineSemanticSearchService') + search_service = mock("RedmineSemanticSearchService") search_service.stubs(:search).returns(search_results) RedmineSemanticSearchService.stubs(:new).returns(search_service) - get :index, params: { q: 'test query' } + get :index, params: { q: "test query" } assert_response :success - assert_select '#search-results', 1, "Search results container not found" + assert_select "#search-results", 1, "Search results container not found" - assert_select 'dl#search-results-list', 1, "Search results list not found" - assert_select 'dl#search-results-list dt', 1, "No search result items found" + assert_select "dl#search-results-list", 1, "Search results list not found" + assert_select "dl#search-results-list dt", 1, "No search result items found" end def test_index_handles_embedding_error - search_service = mock('RedmineSemanticSearchService') + search_service = mock("RedmineSemanticSearchService") search_service.stubs(:search).raises(EmbeddingService::EmbeddingError.new("Test embedding error")) RedmineSemanticSearchService.stubs(:new).returns(search_service) - get :index, params: { q: 'test query' } + get :index, params: { q: "test query" } assert_response :success assert_equal "Test embedding error", flash[:error] assert_template layout: "base" @@ -67,7 +68,7 @@ def test_sync_embeddings_when_enabled post :sync_embeddings end - assert_redirected_to controller: 'issues', action: 'index' + assert_redirected_to controller: "issues", action: "index" assert_equal l(:notice_redmine_semantic_search_sync_embeddings_started, count: Issue.count), flash[:notice] end @@ -78,7 +79,7 @@ def test_sync_embeddings_when_disabled post :sync_embeddings end - assert_redirected_to controller: 'issues', action: 'index' + assert_redirected_to controller: "issues", action: "index" assert_equal l(:error_redmine_semantic_search_plugin_disabled), flash[:error] end @@ -113,6 +114,6 @@ def test_admin_can_access_search_when_disabled def test_anonymous_cannot_access_search @request.session[:user_id] = nil get :index - assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fsemantic_search' + assert_redirected_to "/login?back_url=http%3A%2F%2Ftest.host%2Fsemantic_search" end end diff --git a/test/functional/redmine_semantic_search_settings_test.rb b/test/functional/redmine_semantic_search_settings_test.rb index f66aae6..365b195 100644 --- a/test/functional/redmine_semantic_search_settings_test.rb +++ b/test/functional/redmine_semantic_search_settings_test.rb @@ -1,4 +1,4 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class RedmineSemanticSearchSettingsTest < Redmine::ControllerTest fixtures :users, :roles @@ -12,16 +12,16 @@ def setup def test_non_admin_cannot_access_plugin_settings @request.session[:user_id] = 2 - get :plugin, params: { id: 'redmine_semantic_search' } + get :plugin, params: { id: "redmine_semantic_search" } assert_response :forbidden post :plugin, params: { - id: 'redmine_semantic_search', + id: "redmine_semantic_search", settings: { - enabled: '1', - base_url: 'https://malicious-api.com', - embedding_model: 'custom-model', - search_limit: '100' + enabled: "1", + base_url: "https://malicious-api.com", + embedding_model: "custom-model", + search_limit: "100" } } assert_response :forbidden diff --git a/test/integration/issue_creation_with_embedding_test.rb b/test/integration/issue_creation_with_embedding_test.rb index c217a52..9a3eb2e 100644 --- a/test/integration/issue_creation_with_embedding_test.rb +++ b/test/integration/issue_creation_with_embedding_test.rb @@ -1,8 +1,9 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class IssueCreationWithEmbeddingTest < Redmine::IntegrationTest include ActiveJob::TestHelper include LoginHelpers::Integration + fixtures :projects, :users, :roles, :members, :member_roles, :trackers, :issue_statuses def setup @@ -32,7 +33,7 @@ def test_issue_creation_schedules_embedding_job_when_enabled IssueEmbeddingJob.perform_later(issue_id) end - log_user(@user.login, 'jsmith') + log_user(@user.login, "jsmith") get "/projects/#{@project.identifier}/issues/new" assert_response :success @@ -40,8 +41,8 @@ def test_issue_creation_schedules_embedding_job_when_enabled post "/projects/#{@project.identifier}/issues", params: { issue: { tracker_id: @tracker.id, - subject: 'Test issue with embedding', - description: 'This is a test issue to verify embedding generation', + subject: "Test issue with embedding", + description: "This is a test issue to verify embedding generation", priority_id: IssuePriority.first.id } } @@ -50,7 +51,7 @@ def test_issue_creation_schedules_embedding_job_when_enabled follow_redirect! assert_response :success - issue_id = request.path.split('/').last.to_i + issue_id = request.path.split("/").last.to_i assert_enqueued_with(job: IssueEmbeddingJob, args: [issue_id]) end @@ -65,13 +66,13 @@ def test_issue_creation_does_not_schedule_job_when_disabled end RedmineSemanticSearch::IssueHooks.instance.singleton_class.class_eval do - define_method(:schedule_embedding_job) { |issue_id| nil } + define_method(:schedule_embedding_job) { |_issue_id| nil } end perform_enqueued_jobs do clear_enqueued_jobs - log_user(@user.login, 'jsmith') + log_user(@user.login, "jsmith") get "/projects/#{@project.identifier}/issues/new" assert_response :success @@ -79,8 +80,8 @@ def test_issue_creation_does_not_schedule_job_when_disabled post "/projects/#{@project.identifier}/issues", params: { issue: { tracker_id: @tracker.id, - subject: 'Test issue without embedding', - description: 'This is a test issue when plugin is disabled', + subject: "Test issue without embedding", + description: "This is a test issue when plugin is disabled", priority_id: IssuePriority.first.id } } diff --git a/test/integration/redmine_semantic_search_test.rb b/test/integration/redmine_semantic_search_test.rb index bbec234..bbae7bd 100644 --- a/test/integration/redmine_semantic_search_test.rb +++ b/test/integration/redmine_semantic_search_test.rb @@ -1,7 +1,8 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class RedmineSemanticSearchTest < Redmine::IntegrationTest include LoginHelpers::Integration + fixtures :projects, :users, :roles, :members, :member_roles, :issues, :trackers def setup @@ -9,24 +10,24 @@ def setup @role = Role.find(1) @role.add_permission!(:use_semantic_search) - ENV['OPENAI_API_KEY'] = 'test_api_key' + ENV["OPENAI_API_KEY"] = "test_api_key" @issue = Issue.find(1) @embedding = IssueEmbedding.create!( issue: @issue, embedding_vector: [0.1] * 2000, - content_hash: 'test_hash', - model_used: 'text-embedding-ada-002' + content_hash: "test_hash", + model_used: "text-embedding-ada-002" ) @mock_results = [ { - 'issue_id' => @issue.id, - 'subject' => @issue.subject, - 'project_name' => @issue.project.name, - 'tracker_name' => @issue.tracker.name, - 'updated_on' => @issue.updated_on.to_s, - 'distance' => 0.25 + "issue_id" => @issue.id, + "subject" => @issue.subject, + "project_name" => @issue.project.name, + "tracker_name" => @issue.tracker.name, + "updated_on" => @issue.updated_on.to_s, + "distance" => 0.25 } ] RedmineSemanticSearchService.any_instance.stubs(:search).returns(@mock_results) @@ -37,35 +38,35 @@ def setup end def teardown - ENV.delete('OPENAI_API_KEY') + ENV.delete("OPENAI_API_KEY") RedmineSemanticSearchController.any_instance.unstub(:check_if_enabled) end def test_semantic_search_happy_path - log_user(@user.login, 'jsmith') + log_user(@user.login, "jsmith") - get '/semantic_search' + get "/semantic_search" assert_response :success - assert_select 'h2', 'Semantic Search' + assert_select "h2", "Semantic Search" - get '/semantic_search', params: { q: 'test query' } + get "/semantic_search", params: { q: "test query" } assert_response :success - assert_select 'dl#search-results-list dt', 1 - assert_select 'dl#search-results-list dt a.issue-link', text: "Issue ##{@issue.id}: #{@issue.subject}" + assert_select "dl#search-results-list dt", 1 + assert_select "dl#search-results-list dt a.issue-link", text: "Issue ##{@issue.id}: #{@issue.subject}" end def test_semantic_search_requires_login - get '/semantic_search' - assert_redirected_to '/login?back_url=http%3A%2F%2Fwww.example.com%2Fsemantic_search' + get "/semantic_search" + assert_redirected_to "/login?back_url=http%3A%2F%2Fwww.example.com%2Fsemantic_search" end def test_semantic_search_requires_permission @role.remove_permission!(:use_semantic_search) - log_user(@user.login, 'jsmith') + log_user(@user.login, "jsmith") - get '/semantic_search' + get "/semantic_search" assert_response :forbidden end end diff --git a/test/system/redmine_semantic_search_system_test.rb b/test/system/redmine_semantic_search_system_test.rb index 8658ae0..8677cc0 100644 --- a/test/system/redmine_semantic_search_system_test.rb +++ b/test/system/redmine_semantic_search_system_test.rb @@ -1,32 +1,32 @@ -require File.expand_path('../../application_system_test_case', __FILE__) +require File.expand_path("../application_system_test_case", __dir__) class RedmineSemanticSearchSystemTest < ApplicationSystemTestCase fixtures :projects, :users, :roles, :members, :member_roles, :trackers def setup ActiveSupport.to_time_preserves_timezone = true - @user = User.find_by(login: 'jsmith') || users(:users_002) - @role = Role.find_by(name: 'Manager') || roles(:roles_001) + @user = User.find_by(login: "jsmith") || users(:users_002) + @role = Role.find_by(name: "Manager") || roles(:roles_001) @role.add_permission!(:use_semantic_search) - ENV['OPENAI_API_KEY'] = 'test_api_key' + ENV["OPENAI_API_KEY"] = "test_api_key" - @project = Project.find_by(identifier: 'ecookbook') || projects(:projects_001) + @project = Project.find_by(identifier: "ecookbook") || projects(:projects_001) @tracker = Tracker.first @issue = Issue.create!( project: @project, tracker: @tracker, author: @user, - subject: 'Test issue for semantic search', - description: 'This is a test issue created for semantic search testing' + subject: "Test issue for semantic search", + description: "This is a test issue created for semantic search testing" ) @embedding = IssueEmbedding.create!( issue: @issue, embedding_vector: [0.1] * 2000, - content_hash: 'test_hash', - model_used: 'text-embedding-ada-002' + content_hash: "test_hash", + model_used: "text-embedding-ada-002" ) EmbeddingService.any_instance.stubs(:generate_embedding).returns([0.1] * 2000) @@ -54,28 +54,28 @@ def setup RedmineSemanticSearchController.any_instance.stubs(:check_if_enabled).returns(true) logout - log_user(@user.login, 'jsmith') + log_user(@user.login, "jsmith") end def teardown - ENV.delete('OPENAI_API_KEY') + ENV.delete("OPENAI_API_KEY") @embedding.destroy if @embedding && IssueEmbedding.exists?(@embedding.id) @issue.destroy if @issue && Issue.exists?(@issue.id) RedmineSemanticSearchController.any_instance.unstub(:check_if_enabled) end test "semantic search end-to-end happy path" do - visit '/semantic_search' + visit "/semantic_search" - assert_selector 'h2', text: 'Semantic Search' - assert_selector 'form#redmine-semantic-search-form' + assert_selector "h2", text: "Semantic Search" + assert_selector "form#redmine-semantic-search-form" - within '#redmine-semantic-search-form' do - fill_in 'q', with: 'test query about bug issues' - click_button 'Search' + within "#redmine-semantic-search-form" do + fill_in "q", with: "test query about bug issues" + click_button "Search" end - assert_selector 'dl#search-results-list', wait: 5 + assert_selector "dl#search-results-list", wait: 5 assert_selector "dt a[href='/issues/#{@issue.id}']" @@ -90,14 +90,14 @@ def teardown RedmineSemanticSearchService.any_instance.unstub(:search) RedmineSemanticSearchService.any_instance.stubs(:search).returns([]) - visit '/semantic_search' + visit "/semantic_search" - within '#redmine-semantic-search-form' do - fill_in 'q', with: 'query with no results' - click_button 'Search' + within "#redmine-semantic-search-form" do + fill_in "q", with: "query with no results" + click_button "Search" end - assert_selector 'p.nodata', wait: 5 + assert_selector "p.nodata", wait: 5 end test "semantic search page is accessible only to authorized users" do @@ -105,9 +105,9 @@ def teardown Capybara.reset_sessions! - visit '/semantic_search' + visit "/semantic_search" - assert_current_path(/\/login/, url: true) + assert_current_path(%r{/login}, url: true) end test "top_menu_item_is_hidden_when_plugin_is_disabled" do @@ -115,7 +115,7 @@ def teardown Capybara.reset_sessions! admin_user = User.find(1) - new_password = 'SecureP@ssw0rd1' + new_password = "SecureP@ssw0rd1" admin_user.password = new_password admin_user.password_confirmation = new_password admin_user.status = User::STATUS_ACTIVE @@ -128,11 +128,11 @@ def teardown log_user(admin_user.login, new_password) - Setting.plugin_redmine_semantic_search = Setting.plugin_redmine_semantic_search.merge('enabled' => '0') + Setting.plugin_redmine_semantic_search = Setting.plugin_redmine_semantic_search.merge("enabled" => "0") - visit '/' + visit "/" - within '#top-menu' do + within "#top-menu" do assert_no_link I18n.t(:label_semantic_search), wait: 3 end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6e58df0..d9c8b35 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,21 +1,21 @@ -require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') +require File.expand_path("#{File.dirname(__FILE__)}/../../../test/test_helper") -require 'mocha/minitest' +require "mocha/minitest" -require 'simplecov' +require "simplecov" SimpleCov.start do - add_filter 'test' - add_filter 'plugins/redmine_semantic_search/init.rb' - add_filter 'plugins/redmine_semantic_search/lib/redmine_semantic_search/' - add_filter 'plugins/redmine_semantic_search/db/migrate/' - add_filter 'plugins/redmine_semantic_search/config/' + add_filter "test" + add_filter "plugins/redmine_semantic_search/init.rb" + add_filter "plugins/redmine_semantic_search/lib/redmine_semantic_search/" + add_filter "plugins/redmine_semantic_search/db/migrate/" + add_filter "plugins/redmine_semantic_search/config/" enable_coverage :branch minimum_coverage line: 100, branch: 100 add_filter do |source_file| - !source_file.filename.include?('plugins/redmine_semantic_search') + !source_file.filename.include?("plugins/redmine_semantic_search") end - track_files 'plugins/redmine_semantic_search/**/*.rb' + track_files "plugins/redmine_semantic_search/**/*.rb" end ActiveJob::Base.queue_adapter = :test @@ -23,9 +23,7 @@ ActiveSupport.to_time_preserves_timezone = true class EmbeddingServiceMock - def initialize; end - - def generate_embedding(text) + def generate_embedding(_text) Array.new(2000) { 0.1 } end @@ -47,13 +45,13 @@ def prepare_issue_content(issue) module LoginHelpers module Integration def log_user(login, password) - get '/login' + get "/login" assert_response :success - post '/login', params: { + post "/login", params: { username: login, password: password } - assert_redirected_to '/my/page' + assert_redirected_to "/my/page" follow_redirect! assert_equal login, User.find(session[:user_id]).login end @@ -61,18 +59,18 @@ def log_user(login, password) module System def log_user(login, password) - visit '/login' - fill_in 'username', with: login - fill_in 'password', with: password - click_button 'Login', wait: 5 - assert_selector '#loggedas', wait: 5 + visit "/login" + fill_in "username", with: login + fill_in "password", with: password + click_button "Login", wait: 5 + assert_selector "#loggedas", wait: 5 end def logout - if has_link?(class: 'logout') - click_link(class: 'logout', wait: 5) + if has_link?(class: "logout") + click_link(class: "logout", wait: 5) end - assert_no_selector '#loggedas', wait: 5 + assert_no_selector "#loggedas", wait: 5 end end end diff --git a/test/unit/embedding_service_test.rb b/test/unit/embedding_service_test.rb index 975857a..b245874 100644 --- a/test/unit/embedding_service_test.rb +++ b/test/unit/embedding_service_test.rb @@ -1,23 +1,23 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class EmbeddingServiceTest < ActiveSupport::TestCase fixtures :issues, :journals, :time_entries def setup - ENV['OPENAI_API_KEY'] = 'test_api_key' + ENV["OPENAI_API_KEY"] = "test_api_key" - @mock_client = mock('OpenAI::Client') + @mock_client = mock("OpenAI::Client") OpenAI::Client.stubs(:new).returns(@mock_client) @service = EmbeddingService.new end def teardown - ENV.delete('OPENAI_API_KEY') + ENV.delete("OPENAI_API_KEY") end def test_initialize_raises_error_without_api_key - ENV.delete('OPENAI_API_KEY') + ENV.delete("OPENAI_API_KEY") assert_raises(EmbeddingService::EmbeddingError) do EmbeddingService.new end @@ -81,18 +81,22 @@ def test_prepare_issue_content issue = Issue.find(1) issue.update_columns( - subject: 'Test subject', - description: 'Test description' + subject: "Test subject", + description: "Test description" ) Journal.where(journalized: issue).delete_all Journal.connection.execute( - "INSERT INTO journals (journalized_id, journalized_type, user_id, notes, created_on) VALUES (#{issue.id}, 'Issue', 2, 'Test comment', '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}')" + "INSERT INTO journals (journalized_id, journalized_type, user_id, notes, created_on) " \ + "VALUES (#{issue.id}, 'Issue', 2, 'Test comment', '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}')" ) TimeEntry.where(issue_id: issue.id).delete_all TimeEntry.connection.execute( - "INSERT INTO time_entries (project_id, user_id, issue_id, hours, activity_id, spent_on, comments, tyear, tmonth, tweek, created_on, updated_on) VALUES (#{issue.project_id}, 2, #{issue.id}, 1, 9, '#{Date.today.strftime('%Y-%m-%d')}', 'Test time entry comment', #{Date.today.year}, #{Date.today.month}, #{Date.today.cweek}, '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}', '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}')" + "INSERT INTO time_entries (project_id, user_id, issue_id, hours, activity_id, spent_on, " \ + "comments, tyear, tmonth, tweek, created_on, updated_on) VALUES (#{issue.project_id}, 2, #{issue.id}, 1, 9, " \ + "'#{Date.today.strftime('%Y-%m-%d')}', 'Test time entry comment', #{Date.today.year}, #{Date.today.month}, " \ + "#{Date.today.cweek}, '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}', '#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}')" ) issue.reload @@ -166,7 +170,7 @@ def test_model_dimensions_settings_are_nil end def test_prepare_issue_content_with_minimal_data - issue = Issue.new(id: 123, subject: 'Minimal Subject') + issue = Issue.new(id: 123, subject: "Minimal Subject") issue.description = nil issue.stubs(:journals).returns([]) issue.stubs(:time_entries).returns([]) @@ -178,11 +182,11 @@ def test_prepare_issue_content_with_minimal_data def test_prepare_issue_content_with_empty_and_nil_journal_notes issue = Issue.find(1) issue.journals.destroy_all - issue.update_columns(subject: 'Journal Test', description: 'Journal test desc') + issue.update_columns(subject: "Journal Test", description: "Journal test desc") Journal.create!(journalized: issue, user_id: 2, notes: nil) - Journal.create!(journalized: issue, user_id: 2, notes: '') - Journal.create!(journalized: issue, user_id: 2, notes: 'Actual comment') + Journal.create!(journalized: issue, user_id: 2, notes: "") + Journal.create!(journalized: issue, user_id: 2, notes: "Actual comment") issue.reload issue.stubs(:time_entries).returns([]) @@ -191,18 +195,21 @@ def test_prepare_issue_content_with_empty_and_nil_journal_notes assert_includes content, "Issue ##{issue.id} - Journal Test" assert_includes content, "Description: Journal test desc" assert_includes content, "Comment: Actual comment" - assert_equal 1, content.scan(/Comment:/).count + assert_equal 1, content.scan("Comment:").count assert_equal 3, content.lines.count end def test_prepare_issue_content_with_empty_and_nil_time_entry_comments issue = Issue.find(1) issue.time_entries.destroy_all - issue.update_columns(subject: 'Time Entry Test', description: 'Time entry test desc') + issue.update_columns(subject: "Time Entry Test", description: "Time entry test desc") - TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, spent_on: Date.today, comments: nil) - TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, spent_on: Date.today, comments: '') - TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, spent_on: Date.today, comments: 'Actual time entry') + TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, + spent_on: Date.today, comments: nil) + TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, + spent_on: Date.today, comments: "") + TimeEntry.create!(project_id: issue.project_id, user_id: 2, issue_id: issue.id, hours: 1, activity_id: 9, + spent_on: Date.today, comments: "Actual time entry") issue.reload issue.stubs(:journals).returns([]) @@ -211,7 +218,7 @@ def test_prepare_issue_content_with_empty_and_nil_time_entry_comments assert_includes content, "Issue ##{issue.id} - Time Entry Test" assert_includes content, "Description: Time entry test desc" assert_includes content, "Time entry note: Actual time entry" - assert_equal 1, content.scan(/Time entry note:/).count + assert_equal 1, content.scan("Time entry note:").count assert_equal 4, content.lines.count end @@ -220,7 +227,8 @@ def test_base_url_uses_setting_if_present custom_url = "http://localhost:8080/v1" Setting.plugin_redmine_semantic_search = { "base_url" => custom_url } - OpenAI::Client.expects(:new).with(access_token: 'test_api_key', uri_base: custom_url).returns(mock('OpenAI::Client')) + OpenAI::Client.expects(:new).with(access_token: "test_api_key", + uri_base: custom_url).returns(mock("OpenAI::Client")) service = EmbeddingService.new assert_not_nil service, "Service should be initialized" ensure @@ -234,7 +242,8 @@ def test_base_url_uses_default_if_setting_not_present Setting.plugin_redmine_semantic_search = {} default_url = "https://api.openai.com/v1" - OpenAI::Client.expects(:new).with(access_token: 'test_api_key', uri_base: default_url).returns(mock('OpenAI::Client')) + OpenAI::Client.expects(:new).with(access_token: "test_api_key", + uri_base: default_url).returns(mock("OpenAI::Client")) service = EmbeddingService.new assert_not_nil service, "Service should be initialized" ensure @@ -248,7 +257,8 @@ def test_base_url_uses_default_if_setting_is_nil Setting.plugin_redmine_semantic_search = nil default_url = "https://api.openai.com/v1" - OpenAI::Client.expects(:new).with(access_token: 'test_api_key', uri_base: default_url).returns(mock('OpenAI::Client')) + OpenAI::Client.expects(:new).with(access_token: "test_api_key", + uri_base: default_url).returns(mock("OpenAI::Client")) service = EmbeddingService.new assert_not_nil service, "Service should be initialized" ensure diff --git a/test/unit/issue_embedding_job_test.rb b/test/unit/issue_embedding_job_test.rb index 7947ef3..5463475 100644 --- a/test/unit/issue_embedding_job_test.rb +++ b/test/unit/issue_embedding_job_test.rb @@ -1,4 +1,4 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class IssueEmbeddingJobTest < ActiveSupport::TestCase fixtures :projects, :users, :issues @@ -50,7 +50,7 @@ def test_job_does_not_update_unchanged_embedding issue_id: @issue.id, embedding_vector: [0.1] * 2000, content_hash: content_hash, - model_used: 'text-embedding-ada-002' + model_used: "text-embedding-ada-002" ) job = IssueEmbeddingJob.new diff --git a/test/unit/issue_embedding_test.rb b/test/unit/issue_embedding_test.rb index 78eb24f..8267218 100644 --- a/test/unit/issue_embedding_test.rb +++ b/test/unit/issue_embedding_test.rb @@ -1,4 +1,4 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class IssueEmbeddingTest < ActiveSupport::TestCase fixtures :projects, :users, :issues, :journals, :time_entries @@ -8,8 +8,8 @@ def setup @embedding = IssueEmbedding.new( issue: @issue, embedding_vector: [0.1] * 2000, - content_hash: 'test_hash', - model_used: 'text-embedding-ada-002' + content_hash: "test_hash", + model_used: "text-embedding-ada-002" ) end @@ -22,17 +22,17 @@ def test_validations @embedding.issue = nil assert_not @embedding.valid? - assert_includes @embedding.errors[:issue], 'cannot be blank' + assert_includes @embedding.errors[:issue], "cannot be blank" @embedding.issue = @issue @embedding.embedding_vector = nil assert_not @embedding.valid? - assert_includes @embedding.errors[:embedding_vector], 'cannot be blank' + assert_includes @embedding.errors[:embedding_vector], "cannot be blank" @embedding.embedding_vector = [0.1] * 2000 @embedding.content_hash = nil assert_not @embedding.valid? - assert_includes @embedding.errors[:content_hash], 'cannot be blank' + assert_includes @embedding.errors[:content_hash], "cannot be blank" end def test_calculate_content_hash_all_variations @@ -40,29 +40,33 @@ def test_calculate_content_hash_all_variations issue.journals.destroy_all issue.time_entries.destroy_all - issue_subject = 'Subject for hash calculation with variations' - issue_description = 'Description for hash calculation with variations' + issue_subject = "Subject for hash calculation with variations" + issue_description = "Description for hash calculation with variations" issue.update_columns( subject: issue_subject, description: issue_description ) - journal_note1 = 'First journal note present.' - journal_note2 = 'Second journal note present.' + journal_note1 = "First journal note present." + journal_note2 = "Second journal note present." Journal.create!(journalized: issue, user_id: User.find(2).id, notes: nil) # Should be skipped by .present? Journal.create!(journalized: issue, user_id: User.find(2).id, notes: journal_note1) - Journal.create!(journalized: issue, user_id: User.find(2).id, notes: '') # Should be skipped by .present? + Journal.create!(journalized: issue, user_id: User.find(2).id, notes: "") # Should be skipped by .present? Journal.create!(journalized: issue, user_id: User.find(2).id, notes: journal_note2) - time_comment1 = 'First time entry comment present.' - time_comment2 = 'Second time entry comment present.' + time_comment1 = "First time entry comment present." + time_comment2 = "Second time entry comment present." activity_id = TimeEntryActivity.find(9).id project_id = issue.project_id - TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, project_id: project_id, comments: nil) - TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, project_id: project_id, comments: time_comment1) - TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, project_id: project_id, comments: '') - TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, project_id: project_id, comments: time_comment2) + TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, + project_id: project_id, comments: nil) + TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, + project_id: project_id, comments: time_comment1) + TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, + project_id: project_id, comments: "") + TimeEntry.create!(issue: issue, user_id: User.find(2).id, hours: 1, spent_on: Date.today, activity_id: activity_id, + project_id: project_id, comments: time_comment2) issue.reload @@ -84,13 +88,13 @@ def test_calculate_content_hash_all_variations def test_calculate_content_hash_with_no_journals_or_time_entries project = Project.find(1) - tracker = Tracker.first || Tracker.create(name: 'Test Tracker', default_status_id: IssueStatus.first.id) + tracker = Tracker.first || Tracker.create(name: "Test Tracker", default_status_id: IssueStatus.first.id) author = User.find(1) - status = IssueStatus.first || IssueStatus.create(name: 'New') - priority = IssuePriority.first || IssuePriority.create(name: 'Normal') + status = IssueStatus.first || IssueStatus.create(name: "New") + priority = IssuePriority.first || IssuePriority.create(name: "Normal") - issue_subject = 'Subject for no journals/time entries' - issue_description = 'Description for no journals/time entries' + issue_subject = "Subject for no journals/time entries" + issue_description = "Description for no journals/time entries" issue = Issue.create!( project: project, @@ -123,11 +127,12 @@ def test_calculate_content_hash_with_no_journals_or_time_entries def test_needs_update issue = Issue.find(1) - default_priority = IssuePriority.find_by(name: 'Normal') || IssuePriority.create!(name: 'Normal', position: 1) + default_priority = IssuePriority.find_by(name: "Normal") || IssuePriority.create!(name: "Normal", position: 1) issue.priority_id = default_priority.id if issue.project - default_category = issue.project.issue_categories.first || issue.project.issue_categories.create!(name: 'Default Category') + default_category = issue.project.issue_categories.first || + issue.project.issue_categories.create!(name: "Default Category") issue.category_id = default_category.id end @@ -136,12 +141,12 @@ def test_needs_update issue: issue, embedding_vector: [0.1] * 2000, content_hash: current_hash, - model_used: 'text-embedding-ada-002' + model_used: "text-embedding-ada-002" ) assert_not embedding.needs_update?(issue) - issue.update!(subject: 'Updated subject') + issue.update!(subject: "Updated subject") assert embedding.needs_update?(issue) end diff --git a/test/unit/issue_hooks_test.rb b/test/unit/issue_hooks_test.rb index c5032fb..1ef325b 100644 --- a/test/unit/issue_hooks_test.rb +++ b/test/unit/issue_hooks_test.rb @@ -1,7 +1,8 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class IssueHooksTest < ActiveSupport::TestCase include ActiveJob::TestHelper + fixtures :projects, :users, :issues, :journals, :time_entries def setup @@ -33,7 +34,7 @@ def test_hooks_schedule_job_when_enabled end clear_enqueued_jobs - journal = Journal.new(journalized: @issue, journalized_type: 'Issue', user_id: 1, notes: 'Test comment') + journal = Journal.new(journalized: @issue, journalized_type: "Issue", user_id: 1, notes: "Test comment") journal.save assert_enqueued_with(job: IssueEmbeddingJob, args: [@issue.id]) do @issue_hooks.controller_journals_new_after_save(journal: journal) @@ -52,7 +53,7 @@ def test_hooks_schedule_job_when_enabled spent_on: Date.today, activity_id: 9, project_id: @issue.project_id, - comments: 'Test time entry comment' + comments: "Test time entry comment" ) assert_enqueued_with(job: IssueEmbeddingJob, args: [@issue.id]) do @issue_hooks.controller_timelog_edit_after_save(time_entry: time_entry) @@ -77,7 +78,7 @@ def test_hooks_do_not_schedule_job_when_disabled @issue_hooks.controller_issues_edit_after_save(issue: @issue) end - journal = Journal.new(journalized: @issue, journalized_type: 'Issue', user_id: 1, notes: 'Test comment') + journal = Journal.new(journalized: @issue, journalized_type: "Issue", user_id: 1, notes: "Test comment") journal.save assert_no_enqueued_jobs do @issue_hooks.controller_journals_new_after_save(journal: journal) @@ -94,7 +95,7 @@ def test_hooks_do_not_schedule_job_when_disabled spent_on: Date.today, activity_id: 9, project_id: @issue.project_id, - comments: 'Test time entry comment' + comments: "Test time entry comment" ) assert_no_enqueued_jobs do @issue_hooks.controller_timelog_edit_after_save(time_entry: time_entry) diff --git a/test/unit/redmine_semantic_search_service_test.rb b/test/unit/redmine_semantic_search_service_test.rb index 2335505..8e6d9ca 100644 --- a/test/unit/redmine_semantic_search_service_test.rb +++ b/test/unit/redmine_semantic_search_service_test.rb @@ -1,10 +1,10 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class RedmineSemanticSearchServiceTest < ActiveSupport::TestCase fixtures :issues, :users, :projects, :trackers, :issue_statuses, :enumerations, :journals, :time_entries def setup - @mock_embedding_service = mock('EmbeddingService') + @mock_embedding_service = mock("EmbeddingService") EmbeddingService.stubs(:new).returns(@mock_embedding_service) @service = RedmineSemanticSearchService.new @@ -20,30 +20,30 @@ def test_search embedding = IssueEmbedding.new( issue: issue, embedding_vector: Array.new(2000) { rand }, - content_hash: 'test_hash', - model_used: 'text-embedding-ada-002' + content_hash: "test_hash", + model_used: "text-embedding-ada-002" ) embedding.save! mock_results = [ { - 'issue_id' => issue.id.to_s, - 'subject' => issue.subject, - 'description' => issue.description, - 'project_name' => issue.project.name, - 'created_on' => issue.created_on.to_s, - 'updated_on' => issue.updated_on.to_s, - 'tracker_id' => issue.tracker_id.to_s, - 'tracker_name' => issue.tracker.name, - 'status_name' => issue.status.name, - 'priority_name' => issue.priority.name, - 'author_firstname' => issue.author.firstname, - 'author_lastname' => issue.author.lastname, - 'author_login' => issue.author.login, - 'assigned_to_firstname' => issue.assigned_to&.firstname, - 'assigned_to_lastname' => issue.assigned_to&.lastname, - 'assigned_to_login' => issue.assigned_to&.login, - 'distance' => '0.1' + "issue_id" => issue.id.to_s, + "subject" => issue.subject, + "description" => issue.description, + "project_name" => issue.project.name, + "created_on" => issue.created_on.to_s, + "updated_on" => issue.updated_on.to_s, + "tracker_id" => issue.tracker_id.to_s, + "tracker_name" => issue.tracker.name, + "status_name" => issue.status.name, + "priority_name" => issue.priority.name, + "author_firstname" => issue.author.firstname, + "author_lastname" => issue.author.lastname, + "author_login" => issue.author.login, + "assigned_to_firstname" => issue.assigned_to&.firstname, + "assigned_to_lastname" => issue.assigned_to&.lastname, + "assigned_to_login" => issue.assigned_to&.login, + "distance" => "0.1" } ] @@ -53,29 +53,29 @@ def test_search assert_equal 1, results.size result = results.first - assert_equal issue.id.to_s, result['issue_id'] - assert_equal issue.subject, result['subject'] - assert_equal issue.project.name, result['project_name'] + assert_equal issue.id.to_s, result["issue_id"] + assert_equal issue.subject, result["subject"] + assert_equal issue.project.name, result["project_name"] - assert_nil result['distance'] - assert_nil result['author_firstname'] - assert_nil result['author_lastname'] + assert_nil result["distance"] + assert_nil result["author_firstname"] + assert_nil result["author_lastname"] if issue.author.firstname.present? || issue.author.lastname.present? expected_author_name = [issue.author.firstname, issue.author.lastname].join(" ").strip - assert_equal expected_author_name, result['author_name'] + assert_equal expected_author_name, result["author_name"] else - assert_equal issue.author.login, result['author_name'] + assert_equal issue.author.login, result["author_name"] end - assert_in_delta 0.909, result['similarity_score'], 0.001 + assert_in_delta 0.909, result["similarity_score"], 0.001 if issue.assigned_to expected_assignee_name = [issue.assigned_to.firstname, issue.assigned_to.lastname].join(" ").strip expected_assignee_name = issue.assigned_to.login if expected_assignee_name.blank? - assert_equal expected_assignee_name, result['assigned_to_name'] + assert_equal expected_assignee_name, result["assigned_to_name"] else - assert_nil result['assigned_to_name'] + assert_nil result["assigned_to_name"] end end @@ -85,28 +85,28 @@ def test_search_processes_author_info_with_login_fallback issue = Issue.find(1) # Base issue for common fields mock_raw_result = { - 'issue_id' => issue.id.to_s, - 'subject' => issue.subject, - 'description' => issue.description, - 'project_name' => issue.project.name, - 'created_on' => issue.created_on.to_s, - 'updated_on' => issue.updated_on.to_s, - 'tracker_id' => issue.tracker_id.to_s, - 'tracker_name' => issue.tracker.name, - 'status_name' => issue.status.name, - 'priority_name' => issue.priority.name, - 'author_firstname' => nil, # Key for this test branch - 'author_lastname' => nil, # Key for this test branch - 'author_login' => 'fallback_author_login', # Key for this test branch - 'assigned_to_firstname' => issue.assigned_to&.firstname, - 'assigned_to_lastname' => issue.assigned_to&.lastname, - 'assigned_to_login' => issue.assigned_to&.login, - 'distance' => '0.1' + "issue_id" => issue.id.to_s, + "subject" => issue.subject, + "description" => issue.description, + "project_name" => issue.project.name, + "created_on" => issue.created_on.to_s, + "updated_on" => issue.updated_on.to_s, + "tracker_id" => issue.tracker_id.to_s, + "tracker_name" => issue.tracker.name, + "status_name" => issue.status.name, + "priority_name" => issue.priority.name, + "author_firstname" => nil, # Key for this test branch + "author_lastname" => nil, # Key for this test branch + "author_login" => "fallback_author_login", # Key for this test branch + "assigned_to_firstname" => issue.assigned_to&.firstname, + "assigned_to_lastname" => issue.assigned_to&.lastname, + "assigned_to_login" => issue.assigned_to&.login, + "distance" => "0.1" } ActiveRecord::Base.connection.stubs(:execute).returns([mock_raw_result]) # Mock visibility check to pass the result through - mock_relation = mock('ActiveRecord::Relation') + mock_relation = mock("ActiveRecord::Relation") Issue.expects(:where).with(id: [issue.id.to_s]).returns(mock_relation) mock_relation.expects(:visible).with(@user).returns([issue]) @@ -114,10 +114,10 @@ def test_search_processes_author_info_with_login_fallback assert_equal 1, results.size result = results.first - assert_equal 'fallback_author_login', result['author_name'] - assert_nil result['author_firstname'] - assert_nil result['author_lastname'] - assert_nil result['author_login'] # Ensure original login field is removed + assert_equal "fallback_author_login", result["author_name"] + assert_nil result["author_firstname"] + assert_nil result["author_lastname"] + assert_nil result["author_login"] # Ensure original login field is removed end def test_search_processes_assignee_info_as_nil_if_no_details @@ -126,27 +126,27 @@ def test_search_processes_assignee_info_as_nil_if_no_details issue = Issue.find(1) # Base issue mock_raw_result = { - 'issue_id' => issue.id.to_s, - 'subject' => issue.subject, - 'description' => issue.description, - 'project_name' => issue.project.name, - 'created_on' => issue.created_on.to_s, - 'updated_on' => issue.updated_on.to_s, - 'tracker_id' => issue.tracker_id.to_s, - 'tracker_name' => issue.tracker.name, - 'status_name' => issue.status.name, - 'priority_name' => issue.priority.name, - 'author_firstname' => issue.author.firstname, - 'author_lastname' => issue.author.lastname, - 'author_login' => issue.author.login, - 'assigned_to_firstname' => nil, # Key for this test branch - 'assigned_to_lastname' => nil, # Key for this test branch - 'assigned_to_login' => nil, # Key for this test branch - 'distance' => '0.1' + "issue_id" => issue.id.to_s, + "subject" => issue.subject, + "description" => issue.description, + "project_name" => issue.project.name, + "created_on" => issue.created_on.to_s, + "updated_on" => issue.updated_on.to_s, + "tracker_id" => issue.tracker_id.to_s, + "tracker_name" => issue.tracker.name, + "status_name" => issue.status.name, + "priority_name" => issue.priority.name, + "author_firstname" => issue.author.firstname, + "author_lastname" => issue.author.lastname, + "author_login" => issue.author.login, + "assigned_to_firstname" => nil, # Key for this test branch + "assigned_to_lastname" => nil, # Key for this test branch + "assigned_to_login" => nil, # Key for this test branch + "distance" => "0.1" } ActiveRecord::Base.connection.stubs(:execute).returns([mock_raw_result]) - mock_relation = mock('ActiveRecord::Relation') + mock_relation = mock("ActiveRecord::Relation") Issue.expects(:where).with(id: [issue.id.to_s]).returns(mock_relation) mock_relation.expects(:visible).with(@user).returns([issue]) @@ -154,10 +154,10 @@ def test_search_processes_assignee_info_as_nil_if_no_details assert_equal 1, results.size result = results.first - assert_nil result['assigned_to_name'] - assert_nil result['assigned_to_firstname'] - assert_nil result['assigned_to_lastname'] - assert_nil result['assigned_to_login'] + assert_nil result["assigned_to_name"] + assert_nil result["assigned_to_firstname"] + assert_nil result["assigned_to_lastname"] + assert_nil result["assigned_to_login"] end def test_search_processes_assignee_info_with_login_fallback @@ -166,27 +166,27 @@ def test_search_processes_assignee_info_with_login_fallback issue = Issue.find(1) # Base issue mock_raw_result = { - 'issue_id' => issue.id.to_s, - 'subject' => issue.subject, - 'description' => issue.description, - 'project_name' => issue.project.name, - 'created_on' => issue.created_on.to_s, - 'updated_on' => issue.updated_on.to_s, - 'tracker_id' => issue.tracker_id.to_s, - 'tracker_name' => issue.tracker.name, - 'status_name' => issue.status.name, - 'priority_name' => issue.priority.name, - 'author_firstname' => issue.author.firstname, - 'author_lastname' => issue.author.lastname, - 'author_login' => issue.author.login, - 'assigned_to_firstname' => nil, # Key for this test branch - 'assigned_to_lastname' => nil, # Key for this test branch - 'assigned_to_login' => 'fallback_assignee_login', # Key for this test branch - 'distance' => '0.1' + "issue_id" => issue.id.to_s, + "subject" => issue.subject, + "description" => issue.description, + "project_name" => issue.project.name, + "created_on" => issue.created_on.to_s, + "updated_on" => issue.updated_on.to_s, + "tracker_id" => issue.tracker_id.to_s, + "tracker_name" => issue.tracker.name, + "status_name" => issue.status.name, + "priority_name" => issue.priority.name, + "author_firstname" => issue.author.firstname, + "author_lastname" => issue.author.lastname, + "author_login" => issue.author.login, + "assigned_to_firstname" => nil, # Key for this test branch + "assigned_to_lastname" => nil, # Key for this test branch + "assigned_to_login" => "fallback_assignee_login", # Key for this test branch + "distance" => "0.1" } ActiveRecord::Base.connection.stubs(:execute).returns([mock_raw_result]) - mock_relation = mock('ActiveRecord::Relation') + mock_relation = mock("ActiveRecord::Relation") Issue.expects(:where).with(id: [issue.id.to_s]).returns(mock_relation) mock_relation.expects(:visible).with(@user).returns([issue]) @@ -194,10 +194,10 @@ def test_search_processes_assignee_info_with_login_fallback assert_equal 1, results.size result = results.first - assert_equal 'fallback_assignee_login', result['assigned_to_name'] - assert_nil result['assigned_to_firstname'] - assert_nil result['assigned_to_lastname'] - assert_nil result['assigned_to_login'] # Ensure original login field is removed + assert_equal "fallback_assignee_login", result["assigned_to_name"] + assert_nil result["assigned_to_firstname"] + assert_nil result["assigned_to_lastname"] + assert_nil result["assigned_to_login"] # Ensure original login field is removed end def test_filter_by_visibility @@ -209,16 +209,16 @@ def test_filter_by_visibility visible_embedding = IssueEmbedding.new( issue: visible_issue, embedding_vector: Array.new(2000) { rand }, - content_hash: 'visible_hash', - model_used: 'text-embedding-ada-002' + content_hash: "visible_hash", + model_used: "text-embedding-ada-002" ) visible_embedding.save! invisible_embedding = IssueEmbedding.new( issue: invisible_issue, embedding_vector: Array.new(2000) { rand }, - content_hash: 'invisible_hash', - model_used: 'text-embedding-ada-002' + content_hash: "invisible_hash", + model_used: "text-embedding-ada-002" ) invisible_embedding.save! @@ -226,55 +226,55 @@ def test_filter_by_visibility mock_results = [ { - 'issue_id' => visible_issue.id.to_s, - 'subject' => visible_issue.subject, - 'description' => visible_issue.description, - 'project_name' => visible_issue.project.name, - 'created_on' => visible_issue.created_on.to_s, - 'updated_on' => visible_issue.updated_on.to_s, - 'tracker_id' => visible_issue.tracker_id.to_s, - 'tracker_name' => visible_issue.tracker.name, - 'status_name' => visible_issue.status.name, - 'priority_name' => visible_issue.priority.name, - 'author_firstname' => visible_issue.author.firstname, - 'author_lastname' => visible_issue.author.lastname, - 'author_login' => visible_issue.author.login, - 'assigned_to_firstname' => visible_issue.assigned_to&.firstname, - 'assigned_to_lastname' => visible_issue.assigned_to&.lastname, - 'assigned_to_login' => visible_issue.assigned_to&.login, - 'distance' => '0.1' + "issue_id" => visible_issue.id.to_s, + "subject" => visible_issue.subject, + "description" => visible_issue.description, + "project_name" => visible_issue.project.name, + "created_on" => visible_issue.created_on.to_s, + "updated_on" => visible_issue.updated_on.to_s, + "tracker_id" => visible_issue.tracker_id.to_s, + "tracker_name" => visible_issue.tracker.name, + "status_name" => visible_issue.status.name, + "priority_name" => visible_issue.priority.name, + "author_firstname" => visible_issue.author.firstname, + "author_lastname" => visible_issue.author.lastname, + "author_login" => visible_issue.author.login, + "assigned_to_firstname" => visible_issue.assigned_to&.firstname, + "assigned_to_lastname" => visible_issue.assigned_to&.lastname, + "assigned_to_login" => visible_issue.assigned_to&.login, + "distance" => "0.1" }, { - 'issue_id' => invisible_issue.id.to_s, - 'subject' => invisible_issue.subject, - 'description' => invisible_issue.description, - 'project_name' => invisible_issue.project.name, - 'created_on' => invisible_issue.created_on.to_s, - 'updated_on' => invisible_issue.updated_on.to_s, - 'tracker_id' => invisible_issue.tracker_id.to_s, - 'tracker_name' => invisible_issue.tracker.name, - 'status_name' => invisible_issue.status.name, - 'priority_name' => invisible_issue.priority.name, - 'author_firstname' => invisible_issue.author.firstname, - 'author_lastname' => invisible_issue.author.lastname, - 'author_login' => invisible_issue.author.login, - 'assigned_to_firstname' => invisible_issue.assigned_to&.firstname, - 'assigned_to_lastname' => invisible_issue.assigned_to&.lastname, - 'assigned_to_login' => invisible_issue.assigned_to&.login, - 'distance' => '0.2' + "issue_id" => invisible_issue.id.to_s, + "subject" => invisible_issue.subject, + "description" => invisible_issue.description, + "project_name" => invisible_issue.project.name, + "created_on" => invisible_issue.created_on.to_s, + "updated_on" => invisible_issue.updated_on.to_s, + "tracker_id" => invisible_issue.tracker_id.to_s, + "tracker_name" => invisible_issue.tracker.name, + "status_name" => invisible_issue.status.name, + "priority_name" => invisible_issue.priority.name, + "author_firstname" => invisible_issue.author.firstname, + "author_lastname" => invisible_issue.author.lastname, + "author_login" => invisible_issue.author.login, + "assigned_to_firstname" => invisible_issue.assigned_to&.firstname, + "assigned_to_lastname" => invisible_issue.assigned_to&.lastname, + "assigned_to_login" => invisible_issue.assigned_to&.login, + "distance" => "0.2" } ] ActiveRecord::Base.connection.stubs(:execute).returns(mock_results) - mock_relation = mock('ActiveRecord::Relation') + mock_relation = mock("ActiveRecord::Relation") Issue.expects(:where).with(id: [visible_issue.id.to_s, invisible_issue.id.to_s]).returns(mock_relation) mock_relation.expects(:visible).with(regular_user).returns([visible_issue]) results = @service.search(@query, regular_user) assert_equal 1, results.size - assert_equal visible_issue.id.to_s, results.first['issue_id'] + assert_equal visible_issue.id.to_s, results.first["issue_id"] end def test_search_with_empty_results @@ -313,7 +313,7 @@ def test_search_handles_database_error @service.expects(:build_search_sql).with(@query_embedding, 10).returns("SELECT 1") ActiveRecord::Base.connection.stubs(:execute) - .raises(ActiveRecord::StatementInvalid.new("Database query failed")) + .raises(ActiveRecord::StatementInvalid.new("Database query failed")) assert_raises(ActiveRecord::StatementInvalid) do @service.search(@query, @user) diff --git a/test/unit/sync_embeddings_job_test.rb b/test/unit/sync_embeddings_job_test.rb index c1a9561..137576b 100644 --- a/test/unit/sync_embeddings_job_test.rb +++ b/test/unit/sync_embeddings_job_test.rb @@ -1,7 +1,8 @@ -require File.expand_path('../../test_helper', __FILE__) +require File.expand_path("../test_helper", __dir__) class SyncEmbeddingsJobTest < ActiveSupport::TestCase include ActiveJob::TestHelper + fixtures :projects, :users, :issues def setup @@ -13,8 +14,7 @@ def setup def test_job_schedules_issue_jobs_when_enabled Setting.plugin_redmine_semantic_search = { "enabled" => "1" } - job = SyncEmbeddingsJob.new - job.perform + SyncEmbeddingsJob.new.perform assert_equal Issue.count, enqueued_jobs.size @@ -28,8 +28,7 @@ def test_job_schedules_issue_jobs_when_enabled def test_job_does_nothing_when_disabled Setting.plugin_redmine_semantic_search = { "enabled" => "0" } - job = SyncEmbeddingsJob.new - job.perform + SyncEmbeddingsJob.new.perform assert_equal 0, enqueued_jobs.size end