diff --git a/.gitignore b/.gitignore index 01684a8..3655f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ # Git worktrees for parallel development .worktrees/ - # Claude Code personal workflows (local only) .claude/ CLAUDE.md diff --git a/app/controllers/solid_queue_monitor/base_controller.rb b/app/controllers/solid_queue_monitor/base_controller.rb index ed49147..19fe641 100644 --- a/app/controllers/solid_queue_monitor/base_controller.rb +++ b/app/controllers/solid_queue_monitor/base_controller.rb @@ -6,7 +6,7 @@ def paginate(relation) PaginationService.new(relation, current_page, per_page).paginate end - def render_page(title, content) + def render_page(title, content, search_query: nil) # Get flash message from instance variable (set by set_flash_message) or session message = @flash_message message_type = @flash_type @@ -27,7 +27,8 @@ def render_page(title, content) title: title, content: content, message: message, - message_type: message_type + message_type: message_type, + search_query: search_query ).generate render html: html.html_safe diff --git a/app/controllers/solid_queue_monitor/search_controller.rb b/app/controllers/solid_queue_monitor/search_controller.rb new file mode 100644 index 0000000..ca0108e --- /dev/null +++ b/app/controllers/solid_queue_monitor/search_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class SearchController < BaseController + def index + query = params[:q] + results = SearchService.new(query).search + + render_page('Search', SearchResultsPresenter.new(query, results).render, search_query: query) + end + end +end diff --git a/app/presenters/solid_queue_monitor/search_results_presenter.rb b/app/presenters/solid_queue_monitor/search_results_presenter.rb new file mode 100644 index 0000000..83a3cab --- /dev/null +++ b/app/presenters/solid_queue_monitor/search_results_presenter.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class SearchResultsPresenter < BasePresenter + def initialize(query, results) + @query = query + @results = results + end + + def render + section_wrapper('Search Results', generate_content) + end + + private + + def generate_content + if @query.blank? + generate_empty_query_message + elsif total_count.zero? + generate_no_results_message + else + generate_results_summary + generate_all_sections + end + end + + def generate_empty_query_message + <<-HTML +
+

Enter a search term in the header to find jobs across all categories.

+
+ HTML + end + + def generate_no_results_message + <<-HTML +
+

No results found for "#{escape_html(@query)}"

+

0 results

+
+ HTML + end + + def generate_results_summary + <<-HTML +
+

Found #{total_count} #{total_count == 1 ? 'result' : 'results'} for "#{escape_html(@query)}"

+
+ HTML + end + + def generate_all_sections + sections = [] + sections << generate_ready_section if @results[:ready].any? + sections << generate_scheduled_section if @results[:scheduled].any? + sections << generate_failed_section if @results[:failed].any? + sections << generate_in_progress_section if @results[:in_progress].any? + sections << generate_completed_section if @results[:completed].any? + sections << generate_recurring_section if @results[:recurring].any? + sections.join + end + + def generate_ready_section + generate_section('Ready Jobs', @results[:ready]) do |execution| + generate_job_row(execution.job, execution.queue_name, execution.created_at) + end + end + + def generate_scheduled_section + generate_section('Scheduled Jobs', @results[:scheduled]) do |execution| + generate_job_row(execution.job, execution.queue_name, execution.scheduled_at, 'Scheduled for') + end + end + + def generate_failed_section + generate_section('Failed Jobs', @results[:failed]) do |execution| + generate_failed_row(execution) + end + end + + def generate_in_progress_section + generate_section('In Progress Jobs', @results[:in_progress]) do |execution| + generate_job_row(execution.job, execution.job.queue_name, execution.created_at, 'Started at') + end + end + + def generate_completed_section + generate_section('Completed Jobs', @results[:completed]) do |job| + generate_completed_row(job) + end + end + + def generate_recurring_section + generate_section('Recurring Tasks', @results[:recurring]) do |task| + generate_recurring_row(task) + end + end + + def generate_section(title, items, &block) + <<-HTML +
+

#{title} (#{items.size})

+
+ + + + #{section_headers(title)} + + + + #{items.map(&block).join} + +
+
+
+ HTML + end + + def section_headers(title) + case title + when 'Recurring Tasks' + 'KeyClassScheduleQueue' + when 'Failed Jobs' + 'JobQueueErrorFailed At' + when 'Completed Jobs' + 'JobQueueArgumentsCompleted At' + else + 'JobQueueArgumentsTime' + end + end + + def generate_job_row(job, queue_name, time, time_label = 'Created at') + <<-HTML + + #{job.class_name} + #{queue_link(queue_name)} + #{format_arguments(job.arguments)} + + #{time_label}: #{format_datetime(time)} + + + HTML + end + + def generate_failed_row(execution) + job = execution.job + <<-HTML + + #{job.class_name} + #{queue_link(job.queue_name)} +
#{escape_html(execution.error.to_s.truncate(100))}
+ + #{format_datetime(execution.created_at)} + + + HTML + end + + def generate_completed_row(job) + <<-HTML + + #{job.class_name} + #{queue_link(job.queue_name)} + #{format_arguments(job.arguments)} + + #{format_datetime(job.finished_at)} + + + HTML + end + + def generate_recurring_row(task) + <<-HTML + + #{task.key} + #{task.class_name || '-'} + #{task.schedule} + #{queue_link(task.queue_name)} + + HTML + end + + def total_count + @total_count ||= @results.values.sum(&:size) + end + + def escape_html(text) + text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"') + end + end +end diff --git a/app/services/solid_queue_monitor/html_generator.rb b/app/services/solid_queue_monitor/html_generator.rb index 51ab10f..3a37d68 100644 --- a/app/services/solid_queue_monitor/html_generator.rb +++ b/app/services/solid_queue_monitor/html_generator.rb @@ -5,11 +5,12 @@ class HtmlGenerator include Rails.application.routes.url_helpers include SolidQueueMonitor::Engine.routes.url_helpers - def initialize(title:, content:, message: nil, message_type: nil) + def initialize(title:, content:, message: nil, message_type: nil, search_query: nil) @title = title @content = content @message = message @message_type = message_type + @search_query = search_query end def generate @@ -107,7 +108,8 @@ def generate_header <<-HTML
-

Solid Queue Monitor

+

Solid Queue Monitor

+ #{generate_search_box}
#{generate_auto_refresh_controls} #{generate_theme_toggle} @@ -128,6 +130,25 @@ def generate_footer HTML end + def generate_search_box + search_value = @search_query ? escape_html(@search_query) : '' + <<-HTML +
+ + +
+ HTML + end + + def escape_html(text) + text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"') + end + def generate_auto_refresh_controls return '' unless SolidQueueMonitor.auto_refresh_enabled diff --git a/app/services/solid_queue_monitor/search_service.rb b/app/services/solid_queue_monitor/search_service.rb new file mode 100644 index 0000000..8292ffa --- /dev/null +++ b/app/services/solid_queue_monitor/search_service.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class SearchService + RESULTS_LIMIT = 25 + + def initialize(query) + @query = query + end + + def search + return empty_results if @query.blank? + + term = "%#{sanitize_query(@query)}%" + + { + ready: search_ready_jobs(term), + scheduled: search_scheduled_jobs(term), + failed: search_failed_jobs(term), + in_progress: search_in_progress_jobs(term), + completed: search_completed_jobs(term), + recurring: search_recurring_tasks(term) + } + end + + private + + def empty_results + { + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [] + } + end + + def sanitize_query(query) + # Escape % to prevent LIKE pattern injection + # We don't escape _ because it requires database-specific ESCAPE clauses + query.to_s.gsub('%', '\%') + end + + def search_ready_jobs(term) + SolidQueue::ReadyExecution + .joins(:job) + .where(job_search_conditions, term: term) + .includes(:job) + .limit(RESULTS_LIMIT) + end + + def search_scheduled_jobs(term) + SolidQueue::ScheduledExecution + .joins(:job) + .where(job_search_conditions, term: term) + .includes(:job) + .limit(RESULTS_LIMIT) + end + + def search_failed_jobs(term) + SolidQueue::FailedExecution + .joins(:job) + .where(failed_job_search_conditions, term: term) + .includes(:job) + .limit(RESULTS_LIMIT) + end + + def search_in_progress_jobs(term) + SolidQueue::ClaimedExecution + .joins(:job) + .where(job_search_conditions, term: term) + .includes(:job) + .limit(RESULTS_LIMIT) + end + + def search_completed_jobs(term) + SolidQueue::Job + .where.not(finished_at: nil) + .where(completed_job_search_conditions, term: term) + .order(finished_at: :desc) + .limit(RESULTS_LIMIT) + end + + def search_recurring_tasks(term) + SolidQueue::RecurringTask + .where(recurring_task_search_conditions, term: term) + .limit(RESULTS_LIMIT) + end + + def job_search_conditions + <<~SQL.squish + solid_queue_jobs.class_name LIKE :term + OR solid_queue_jobs.queue_name LIKE :term + OR solid_queue_jobs.arguments LIKE :term + OR solid_queue_jobs.active_job_id LIKE :term + SQL + end + + def failed_job_search_conditions + <<~SQL.squish + solid_queue_jobs.class_name LIKE :term + OR solid_queue_jobs.queue_name LIKE :term + OR solid_queue_jobs.arguments LIKE :term + OR solid_queue_jobs.active_job_id LIKE :term + OR solid_queue_failed_executions.error LIKE :term + SQL + end + + def completed_job_search_conditions + <<~SQL.squish + class_name LIKE :term + OR queue_name LIKE :term + OR arguments LIKE :term + OR active_job_id LIKE :term + SQL + end + + def recurring_task_search_conditions + <<~SQL.squish + solid_queue_recurring_tasks.key LIKE :term + OR solid_queue_recurring_tasks.class_name LIKE :term + SQL + end + end +end diff --git a/app/services/solid_queue_monitor/stylesheet_generator.rb b/app/services/solid_queue_monitor/stylesheet_generator.rb index 05a88b2..6b5e242 100644 --- a/app/services/solid_queue_monitor/stylesheet_generator.rb +++ b/app/services/solid_queue_monitor/stylesheet_generator.rb @@ -66,6 +66,16 @@ def generate margin-bottom: 0.5rem; } + .solid_queue_monitor .header-title-link { + color: var(--text-color); + text-decoration: none; + transition: color 0.2s; + } + + .solid_queue_monitor .header-title-link:hover { + color: var(--primary-color); + } + .solid_queue_monitor .navigation { display: flex; flex-wrap: wrap; @@ -1178,6 +1188,98 @@ def generate gap: 0.75rem; } + /* Header Search Box */ + .solid_queue_monitor .header-search-form { + display: flex; + align-items: center; + gap: 0; + flex: 1; + max-width: 400px; + margin: 0 1rem; + } + + .solid_queue_monitor .header-search-input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--input-border); + border-right: none; + border-radius: 0.375rem 0 0 0.375rem; + font-size: 0.875rem; + background: var(--input-background); + color: var(--text-color); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + } + + .solid_queue_monitor .header-search-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + + .solid_queue_monitor .header-search-input::placeholder { + color: var(--text-muted); + } + + .solid_queue_monitor .header-search-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + background: var(--primary-color); + color: white; + border: 1px solid var(--primary-color); + border-radius: 0 0.375rem 0.375rem 0; + cursor: pointer; + transition: background-color 0.2s; + } + + .solid_queue_monitor .header-search-button:hover { + background: #2563eb; + border-color: #2563eb; + } + + .solid_queue_monitor .header-search-button svg { + width: 16px; + height: 16px; + } + + @media (max-width: 768px) { + .solid_queue_monitor .header-search-form { + max-width: 100%; + margin: 0.5rem 0; + order: 3; + width: 100%; + } + } + + /* Search Results Page */ + .solid_queue_monitor .results-summary { + margin: 1rem 0; + padding: 0.75rem 1rem; + background: var(--card-background); + border-radius: 0.375rem; + box-shadow: var(--card-shadow); + } + + .solid_queue_monitor .results-summary p { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; + } + + .solid_queue_monitor .search-results-section { + margin-top: 1.5rem; + } + + .solid_queue_monitor .search-results-section h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-color); + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + } + /* Workers Page Styles */ .solid_queue_monitor .workers-summary { display: grid; diff --git a/config/routes.rb b/config/routes.rb index 31defc9..6078fc1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,7 @@ root to: 'overview#index' get 'chart_data', to: 'overview#chart_data', as: :chart_data + get 'search', to: 'search#index', as: :search resources :ready_jobs, only: [:index] resources :scheduled_jobs, only: [:index] diff --git a/spec/presenters/solid_queue_monitor/search_results_presenter_spec.rb b/spec/presenters/solid_queue_monitor/search_results_presenter_spec.rb new file mode 100644 index 0000000..a33a823 --- /dev/null +++ b/spec/presenters/solid_queue_monitor/search_results_presenter_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SolidQueueMonitor::SearchResultsPresenter do + subject { described_class.new(query, results) } + + let(:query) { 'TestJob' } + let(:results) do + { + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [] + } + end + + describe '#render' do + it 'returns HTML content' do + expect(subject.render).to be_a(String) + expect(subject.render).to include('section-wrapper') + end + + it 'includes the search query in the title' do + expect(subject.render).to include('TestJob') + end + + it 'does not include a duplicate search form (uses header search)' do + html = subject.render + expect(html).not_to include('name="q"') + end + + context 'with empty results' do + it 'shows no results message' do + html = subject.render + expect(html).to include('No results found') + end + + it 'shows total count of 0' do + html = subject.render + expect(html).to include('0 results') + end + end + + context 'with ready job results' do + let(:job) { create(:solid_queue_job, class_name: 'TestJobReady', queue_name: 'default') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: job) } + let(:results) do + { + ready: [ready_execution], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [] + } + end + + it 'shows the ready jobs section' do + html = subject.render + expect(html).to include('Ready Jobs') + expect(html).to include('TestJobReady') + end + + it 'includes a link to the job details' do + html = subject.render + expect(html).to include("jobs/#{job.id}") + end + + it 'shows count of 1 result' do + html = subject.render + expect(html).to include('1 result') + end + end + + context 'with scheduled job results' do + let(:job) { create(:solid_queue_job, class_name: 'TestJobScheduled') } + let!(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: job) } + let(:results) do + { + ready: [], + scheduled: [scheduled_execution], + failed: [], + in_progress: [], + completed: [], + recurring: [] + } + end + + it 'shows the scheduled jobs section' do + html = subject.render + expect(html).to include('Scheduled Jobs') + expect(html).to include('TestJobScheduled') + end + end + + context 'with failed job results' do + let(:job) { create(:solid_queue_job, class_name: 'TestJobFailed') } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: job, error: 'Connection refused') } + let(:results) do + { + ready: [], + scheduled: [], + failed: [failed_execution], + in_progress: [], + completed: [], + recurring: [] + } + end + + it 'shows the failed jobs section' do + html = subject.render + expect(html).to include('Failed Jobs') + expect(html).to include('TestJobFailed') + end + + it 'shows the error message' do + html = subject.render + expect(html).to include('Connection refused') + end + end + + context 'with in_progress job results' do + let(:job) { create(:solid_queue_job, class_name: 'TestJobInProgress') } + let!(:claimed_execution) { create(:solid_queue_claimed_execution, job: job) } + let(:results) do + { + ready: [], + scheduled: [], + failed: [], + in_progress: [claimed_execution], + completed: [], + recurring: [] + } + end + + it 'shows the in progress jobs section' do + html = subject.render + expect(html).to include('In Progress Jobs') + expect(html).to include('TestJobInProgress') + end + end + + context 'with completed job results' do + let!(:completed_job) { create(:solid_queue_job, :completed, class_name: 'TestJobCompleted') } + let(:results) do + { + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [completed_job], + recurring: [] + } + end + + it 'shows the completed jobs section' do + html = subject.render + expect(html).to include('Completed Jobs') + expect(html).to include('TestJobCompleted') + end + + it 'includes a link to the job details' do + html = subject.render + expect(html).to include("jobs/#{completed_job.id}") + end + end + + context 'with recurring task results' do + let!(:recurring_task) { create(:solid_queue_recurring_task, key: 'test_cleanup', class_name: 'TestCleanupJob') } + let(:results) do + { + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [recurring_task] + } + end + + it 'shows the recurring tasks section' do + html = subject.render + expect(html).to include('Recurring Tasks') + expect(html).to include('test_cleanup') + end + end + + context 'with results across multiple categories' do + let(:ready_job) { create(:solid_queue_job, class_name: 'TestReadyJob') } + let(:failed_job) { create(:solid_queue_job, class_name: 'TestFailedJob') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: ready_job) } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: failed_job) } + let(:results) do + { + ready: [ready_execution], + scheduled: [], + failed: [failed_execution], + in_progress: [], + completed: [], + recurring: [] + } + end + + it 'shows total count across all categories' do + html = subject.render + expect(html).to include('2 results') + end + + it 'shows both sections' do + html = subject.render + expect(html).to include('Ready Jobs') + expect(html).to include('Failed Jobs') + end + end + + context 'with blank query' do + let(:query) { '' } + + it 'shows appropriate message' do + html = subject.render + expect(html).to include('Enter a search term') + end + end + end +end diff --git a/spec/requests/solid_queue_monitor/search_spec.rb b/spec/requests/solid_queue_monitor/search_spec.rb new file mode 100644 index 0000000..56999ac --- /dev/null +++ b/spec/requests/solid_queue_monitor/search_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Search' do + describe 'GET /search' do + it 'returns a successful response' do + get '/search' + + expect(response).to have_http_status(:ok) + end + + it 'displays the search page' do + get '/search' + + expect(response.body).to include('Search') + expect(response.body).to include('name="q"') + end + + context 'without query parameter' do + it 'shows enter search term message' do + get '/search' + + expect(response.body).to include('Enter a search term') + end + end + + context 'with empty query parameter' do + it 'shows enter search term message' do + get '/search', params: { q: '' } + + expect(response.body).to include('Enter a search term') + end + end + + context 'with valid search query' do + let!(:job) { create(:solid_queue_job, class_name: 'UserMailerJob', queue_name: 'mailers') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: job) } + + it 'displays search results' do + get '/search', params: { q: 'UserMailer' } + + expect(response.body).to include('UserMailerJob') + end + + it 'shows the search query in results' do + get '/search', params: { q: 'UserMailer' } + + expect(response.body).to include('UserMailer') + end + end + + context 'with no matching results' do + it 'shows no results message' do + get '/search', params: { q: 'NonExistentJob' } + + expect(response.body).to include('No results found') + end + end + + context 'when searching across different job types' do + let!(:ready_job) { create(:solid_queue_job, class_name: 'TestSearchJob') } + let!(:scheduled_job) { create(:solid_queue_job, class_name: 'TestSearchScheduled') } + let!(:failed_job) { create(:solid_queue_job, class_name: 'TestSearchFailed') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: ready_job) } + let!(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: scheduled_job) } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: failed_job, error: 'Test error') } + + it 'finds ready jobs' do + get '/search', params: { q: 'TestSearchJob' } + + expect(response.body).to include('TestSearchJob') + expect(response.body).to include('Ready Jobs') + end + + it 'finds scheduled jobs' do + get '/search', params: { q: 'TestSearchScheduled' } + + expect(response.body).to include('TestSearchScheduled') + expect(response.body).to include('Scheduled Jobs') + end + + it 'finds failed jobs' do + get '/search', params: { q: 'TestSearchFailed' } + + expect(response.body).to include('TestSearchFailed') + expect(response.body).to include('Failed Jobs') + end + end + + context 'when searching by error message' do + let!(:job) { create(:solid_queue_job, class_name: 'SomeJob') } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: job, error: 'Connection refused to host') } + + it 'finds failed jobs by error message' do + get '/search', params: { q: 'Connection refused' } + + expect(response.body).to include('SomeJob') + expect(response.body).to include('Connection refused') + end + end + + context 'when searching recurring tasks' do + let!(:recurring_task) { create(:solid_queue_recurring_task, key: 'daily_report_task', class_name: 'DailyReportJob') } + + it 'finds recurring tasks by key' do + get '/search', params: { q: 'daily_report' } + + expect(response.body).to include('daily_report_task') + expect(response.body).to include('Recurring Tasks') + end + end + end + + context 'with authentication enabled' do + before do + allow(SolidQueueMonitor).to receive_messages(authentication_enabled: true, username: 'admin', password: 'password123') + end + + let(:valid_credentials) do + ActionController::HttpAuthentication::Basic.encode_credentials('admin', 'password123') + end + + it 'requires authentication' do + get '/search' + + expect(response).to have_http_status(:unauthorized) + end + + it 'allows access with valid credentials' do + get '/search', headers: { 'HTTP_AUTHORIZATION' => valid_credentials } + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/services/solid_queue_monitor/search_service_spec.rb b/spec/services/solid_queue_monitor/search_service_spec.rb new file mode 100644 index 0000000..b63d124 --- /dev/null +++ b/spec/services/solid_queue_monitor/search_service_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SolidQueueMonitor::SearchService do + describe '#search' do + subject { described_class.new(query).search } + + context 'with blank query' do + let(:query) { '' } + + it 'returns empty results for all categories' do + expect(subject).to eq({ + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [] + }) + end + end + + context 'with nil query' do + let(:query) { nil } + + it 'returns empty results for all categories' do + expect(subject).to eq({ + ready: [], + scheduled: [], + failed: [], + in_progress: [], + completed: [], + recurring: [] + }) + end + end + + context 'when searching by class_name' do + let(:query) { 'UserMailer' } + let!(:matching_job) { create(:solid_queue_job, class_name: 'UserMailerJob') } + let!(:non_matching_job) { create(:solid_queue_job, class_name: 'OrderProcessor') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: matching_job) } + + it 'returns matching ready jobs' do + expect(subject[:ready].map(&:job)).to include(matching_job) + expect(subject[:ready].map(&:job)).not_to include(non_matching_job) + end + end + + context 'when searching by queue_name' do + let(:query) { 'mailers' } + let!(:matching_job) { create(:solid_queue_job, queue_name: 'mailers') } + let!(:non_matching_job) { create(:solid_queue_job, queue_name: 'default') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: matching_job) } + + it 'returns matching ready jobs' do + expect(subject[:ready].map(&:job)).to include(matching_job) + expect(subject[:ready].map(&:job)).not_to include(non_matching_job) + end + end + + context 'when searching by arguments' do + let(:query) { 'user@example.com' } + let!(:matching_job) { create(:solid_queue_job, arguments: '["user@example.com"]') } + let!(:non_matching_job) { create(:solid_queue_job, arguments: '["other@test.com"]') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: matching_job) } + + it 'returns matching ready jobs' do + expect(subject[:ready].map(&:job)).to include(matching_job) + expect(subject[:ready].map(&:job)).not_to include(non_matching_job) + end + end + + context 'when searching by active_job_id' do + let(:job_id) { 'abc-123-def-456' } + let(:query) { 'abc-123' } + let!(:matching_job) { create(:solid_queue_job, active_job_id: job_id) } + let!(:non_matching_job) { create(:solid_queue_job, active_job_id: 'xyz-789') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: matching_job) } + + it 'returns matching ready jobs' do + expect(subject[:ready].map(&:job)).to include(matching_job) + expect(subject[:ready].map(&:job)).not_to include(non_matching_job) + end + end + + context 'when searching scheduled jobs' do + let(:query) { 'ScheduledTask' } + let!(:job) { create(:solid_queue_job, class_name: 'ScheduledTaskJob') } + let!(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: job) } + + it 'returns matching scheduled jobs' do + expect(subject[:scheduled].map(&:job)).to include(job) + end + end + + context 'when searching failed jobs by error message' do + let(:query) { 'Connection refused' } + let!(:job) { create(:solid_queue_job, class_name: 'SomeJob') } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: job, error: 'Error: Connection refused to host') } + + it 'returns matching failed jobs' do + expect(subject[:failed].map(&:job)).to include(job) + end + end + + context 'when searching failed jobs by class_name' do + let(:query) { 'FailingJob' } + let!(:job) { create(:solid_queue_job, class_name: 'FailingJobProcessor') } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: job) } + + it 'returns matching failed jobs' do + expect(subject[:failed].map(&:job)).to include(job) + end + end + + context 'when searching in_progress jobs' do + let(:query) { 'ProcessingJob' } + let!(:job) { create(:solid_queue_job, class_name: 'ProcessingJobWorker') } + let!(:claimed_execution) { create(:solid_queue_claimed_execution, job: job) } + + it 'returns matching in_progress jobs' do + expect(subject[:in_progress].map(&:job)).to include(job) + end + end + + context 'when searching completed jobs' do + let(:query) { 'CompletedTask' } + let!(:completed_job) { create(:solid_queue_job, :completed, class_name: 'CompletedTaskJob') } + let!(:non_completed_job) { create(:solid_queue_job, class_name: 'CompletedTaskPending') } + + it 'returns matching completed jobs' do + expect(subject[:completed]).to include(completed_job) + end + + it 'does not include non-completed jobs' do + expect(subject[:completed]).not_to include(non_completed_job) + end + end + + context 'when searching completed jobs by active_job_id' do + let(:job_id) { '9b00ebba-0448-438d-8af2-79c5aae3d204' } + let(:query) { job_id } + let!(:completed_job) { create(:solid_queue_job, :completed, active_job_id: job_id) } + + it 'returns matching completed jobs by job ID' do + expect(subject[:completed]).to include(completed_job) + end + end + + context 'when searching completed jobs by arguments' do + let(:query) { 'order_123' } + let!(:completed_job) { create(:solid_queue_job, :completed, arguments: '{"order_id":"order_123"}') } + + it 'returns matching completed jobs by arguments' do + expect(subject[:completed]).to include(completed_job) + end + end + + context 'when searching recurring tasks by key' do + let(:query) { 'daily_cleanup' } + let!(:recurring_task) { create(:solid_queue_recurring_task, key: 'daily_cleanup_task') } + + it 'returns matching recurring tasks' do + expect(subject[:recurring]).to include(recurring_task) + end + end + + context 'when searching recurring tasks by class_name' do + let(:query) { 'CleanupJob' } + let!(:recurring_task) { create(:solid_queue_recurring_task, class_name: 'CleanupJobWorker') } + + it 'returns matching recurring tasks' do + expect(subject[:recurring]).to include(recurring_task) + end + end + + context 'with case insensitive search' do + let(:query) { 'usermailer' } + let!(:job) { create(:solid_queue_job, class_name: 'UserMailerJob') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: job) } + + it 'matches regardless of case' do + expect(subject[:ready].map(&:job)).to include(job) + end + end + + context 'with results across multiple categories' do + let(:query) { 'TestJob' } + let!(:ready_job) { create(:solid_queue_job, class_name: 'TestJobReady') } + let!(:scheduled_job) { create(:solid_queue_job, class_name: 'TestJobScheduled') } + let!(:failed_job) { create(:solid_queue_job, class_name: 'TestJobFailed') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: ready_job) } + let!(:scheduled_execution) { create(:solid_queue_scheduled_execution, job: scheduled_job) } + let!(:failed_execution) { create(:solid_queue_failed_execution, job: failed_job) } + + it 'returns results in all matching categories' do + expect(subject[:ready].map(&:job)).to include(ready_job) + expect(subject[:scheduled].map(&:job)).to include(scheduled_job) + expect(subject[:failed].map(&:job)).to include(failed_job) + end + end + + context 'with result limits' do + let(:query) { 'BulkJob' } + + before do + 30.times do |i| + job = create(:solid_queue_job, class_name: "BulkJob#{i}") + create(:solid_queue_ready_execution, job: job) + end + end + + it 'limits results to 25 per category' do + expect(subject[:ready].size).to eq(25) + end + end + + context 'with no matching results' do + let(:query) { 'NonExistentJobClassName' } + let!(:job) { create(:solid_queue_job, class_name: 'SomeOtherJob') } + let!(:ready_execution) { create(:solid_queue_ready_execution, job: job) } + + it 'returns empty arrays for all categories' do + expect(subject[:ready]).to be_empty + expect(subject[:scheduled]).to be_empty + expect(subject[:failed]).to be_empty + expect(subject[:in_progress]).to be_empty + expect(subject[:completed]).to be_empty + expect(subject[:recurring]).to be_empty + end + end + + context 'with SQL injection attempt' do + let(:query) { "'; DROP TABLE solid_queue_jobs; --" } + + it 'safely handles malicious input' do + expect { subject }.not_to raise_error + end + end + end +end