Files
gitlab-ce/spec/scripts/merge_request_query_differ_spec.rb
2025-04-28 15:09:57 +00:00

399 lines
15 KiB
Ruby
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
require 'fast_spec_helper'
require_relative '../../scripts/merge_request_query_differ'
RSpec.describe MergeRequestQueryDiffer, feature_category: :tooling do
let(:logger) { instance_double(Logger) }
let(:sql_fingerprint_extractor) { instance_double(SQLFingerprintExtractor) }
let(:file_content) do
%(
{"fingerprint":"def456","normalized":"SELECT * FROM projects WHERE user_id = $1"}
{"normalized":"SELECT * FROM issues"}
invalid json line,
{"fingerprint":"abc123","normalized":"SELECT * FROM users WHERE id = $1"}
)
end
let(:empty_file) { Tempfile.new(%w[mr_auto_explain.ndjson]) }
let(:temp_file) { Tempfile.new(%w[mr_auto_explain.ndjson]) }
before do
allow(Logger).to receive(:new).and_return(logger)
allow(SQLFingerprintExtractor).to receive(:new).and_return(sql_fingerprint_extractor)
allow(logger).to receive_messages(info: nil, warn: nil, error: nil)
allow(differ).to receive(:write_report)
end
subject(:differ) { described_class.new(empty_file.path, logger) }
describe "#run" do
context "when no queries are found in MR" do
it "exits early and writes an appropriate report" do
allow(sql_fingerprint_extractor).to receive(:extract_queries_from_file).and_return([])
result = differ.run
expect(result).to eq(0)
expect(differ).to have_received(:write_report).with(
differ.output_file,
"# SQL Query Analysis\n\nNo queries found in this MR."
)
end
end
context "when queries without fingerprints are found in MR" do
it "exits early without further processing" do
allow(differ).to receive(:get_master_fingerprints)
allow(sql_fingerprint_extractor).to receive(:extract_queries_from_file)
.and_return([{ 'normalized' => 'SELECT * FROM issues' }])
result = differ.run
expect(differ).not_to have_received(:get_master_fingerprints)
expect(result).to eq(0)
end
end
context "when no master fingerprints are found" do
it "exits early without comparing queries" do
allow(sql_fingerprint_extractor).to receive(:extract_queries_from_file)
.and_return([{ 'fingerprint' => 'fp1', 'normalized' => 'SELECT * FROM users' }])
allow(differ).to receive(:get_master_fingerprints).and_return(Set.new)
allow(differ).to receive(:filter_new_queries)
result = differ.run
expect(differ).not_to have_received(:filter_new_queries)
expect(result).to eq(0)
end
end
context "when everything works as expected" do
it "processes the entire pipeline from extraction to report generation" do
allow(sql_fingerprint_extractor).to receive(:extract_queries_from_file)
.and_return([
{ 'fingerprint' => 'fp3', 'normalized' => 'SELECT * FROM issues' },
{ 'fingerprint' => 'fp1', 'normalized' => 'SELECT * FROM users' },
{ 'fingerprint' => 'fp2', 'normalized' => 'SELECT * FROM projects' }
])
allow(differ).to receive(:get_master_fingerprints).and_return(Set.new(['fp1']))
allow(differ.report_generator).to receive(:generate).and_return("# Sample Test Report")
result = differ.run
expect(differ).to have_received(:write_report).with(differ.output_file, "# Sample Test Report")
expect(result).to eq(2) # Two new queries (fp2 and fp3)
end
end
context "when errors occur" do
it "handles errors gracefully" do
allow(sql_fingerprint_extractor).to receive(:extract_queries_from_file)
.and_raise(StandardError.new("Test error"))
result = differ.run
expect(differ).to have_received(:write_report).with(
differ.output_file,
"# SQL Query Analysis\n\n Analysis failed: Test error"
)
expect(result).to eq(0)
end
end
end
describe "#get_master_fingerprints" do
it "downloads and extracts fingerprints from the consolidated package" do
package_content = "mock_package_content"
master_fingerprints = Set.new(%w[fd96528f933e7661 b10ab3c1b7bf923e b65a3b193bb3d1fb])
allow(differ).to receive(:download_consolidated_package).and_return(package_content)
allow(sql_fingerprint_extractor).to receive(:extract_from_tar_gz)
.with(package_content)
.and_return(master_fingerprints)
result = differ.get_master_fingerprints
expect(result).to be_a(Set)
expect(result.size).to eq(3)
expect(result).to eq(master_fingerprints)
end
it "handles download failures" do
allow(differ).to receive(:download_consolidated_package).and_return(nil)
result = differ.get_master_fingerprints
expect(result).to be_a(Set)
expect(result).to be_empty
expect(logger).to have_received(:error).with("Failed to download consolidated package")
end
it "handles extraction errors" do
allow(differ).to receive(:download_consolidated_package).and_return("package_content")
allow(sql_fingerprint_extractor).to receive(:extract_from_tar_gz)
.and_raise(StandardError.new("Extraction error"))
result = differ.get_master_fingerprints
expect(result).to be_a(Set)
expect(result).to be_empty
expect(logger).to have_received(:error).with("Error loading master fingerprints: Extraction error")
end
end
describe "#filter_new_queries" do
let(:mr_queries) do
[
{ 'fingerprint' => 'fp3', 'normalized' => 'SELECT * FROM issues' },
{ 'fingerprint' => 'fp1', 'normalized' => 'SELECT * FROM users' },
{ 'fingerprint' => 'fp2', 'normalized' => 'SELECT * FROM projects' }
]
end
it "identifies queries with fingerprints not present in master" do
master_fingerprints = Set.new(['fp2'])
result = differ.filter_new_queries(mr_queries, master_fingerprints)
expect(result.pluck('fingerprint')).to contain_exactly('fp1', 'fp3')
end
it "filters out all queries when all fingerprints are in master" do
master_fingerprints = Set.new(%w[fp2 fp1 fp3])
result = differ.filter_new_queries(mr_queries, master_fingerprints)
expect(result).to be_empty
end
it "writes a report when no new queries are found" do
master_fingerprints = Set.new(%w[fp2 fp1 fp3])
differ.filter_new_queries(mr_queries, master_fingerprints)
expect(differ).to have_received(:write_report).with(differ.output_file, /No new SQL queries detected in this MR/)
end
end
describe "#download_consolidated_package" do
let(:url) { URI(MergeRequestQueryDiffer::CONSOLIDATED_FINGERPRINTS_URL) }
let(:max_size_mb) { 10 }
it "downloads the package when file size is acceptable" do
head_response = instance_double(Net::HTTPSuccess, is_a?: true, :[] => "5242880") # 5MB
package_content = "mock package content"
allow(differ).to receive(:make_request).with(url, method: :head, parse_json: false).and_return(head_response)
allow(differ).to receive(:make_request).with(url, method: :get, parse_json: false).and_return(package_content)
result = differ.download_consolidated_package(max_size_mb)
expect(result).to eq(package_content)
end
it "aborts download when file size is too large" do
head_response = instance_double(Net::HTTPSuccess, is_a?: true, :[] => ((max_size_mb + 1) * (1024**2)).to_s) # 11MB
allow(differ).to receive(:make_request).with(url, method: :head, parse_json: false).and_return(head_response)
allow(differ).to receive(:make_request).with(url, method: :get, parse_json: false)
result = differ.download_consolidated_package(max_size_mb)
expect(differ).to have_received(:make_request).with(url, method: :head, parse_json: false)
expect(differ).not_to have_received(:make_request).with(url, method: :get, parse_json: false)
expect(result).to be_nil
end
it "proceeds with download when size check fails" do
package_content = "mock package content"
allow(differ).to receive(:make_request)
.with(url, method: :head, parse_json: false)
.and_raise(StandardError.new("Size check failed"))
allow(differ).to receive(:make_request)
.with(url, method: :get, parse_json: false)
.and_return(package_content)
result = differ.download_consolidated_package(max_size_mb)
expect(result).to eq(package_content)
expect(logger).to have_received(:warn).with(/Warning: Could not validate file size/)
expect(differ).to have_received(:make_request).with(url, method: :get, parse_json: false)
end
end
describe "#make_request" do
let(:test_url) { URI("https://gitlab.example.com/foo/bar") }
let(:http) { instance_double(Net::HTTP) }
let(:request) { instance_double(Net::HTTP::Get) }
let(:success_response) { Net::HTTPSuccess.new('1.1', '200', 'OK') }
before do
allow(Net::HTTP).to receive(:new).with(any_args).and_return(http)
allow(http).to receive(:use_ssl=)
allow(http).to receive(:read_timeout=)
allow(success_response).to receive(:body).and_return('{"data":"success"}')
allow(http).to receive(:request).and_return(success_response)
end
context "with authentication headers" do
before do
allow(Net::HTTP::Get).to receive(:new).and_return(request)
allow(request).to receive(:[]=)
end
it "set PRIVATE-TOKEN when GITLAB_TOKEN present" do
stub_env('GITLAB_TOKEN', "test-gitlab-token")
differ.make_request(test_url)
expect(request).to have_received(:[]=).with('PRIVATE-TOKEN', "test-gitlab-token")
end
it "set JOB-TOKEN when CI_JOB_TOKEN present" do
stub_env('CI_JOB_TOKEN', "test-ci-job-token")
differ.make_request(test_url)
expect(request).to have_received(:[]=).with('JOB-TOKEN', "test-ci-job-token")
end
it "prefers GITLAB_TOKEN over CI_JOB_TOKEN" do
stub_env('CI_JOB_TOKEN', "test-ci-job-token")
stub_env('GITLAB_TOKEN', "test-gitlab-token")
differ.make_request(test_url)
expect(request).to have_received(:[]=).with('PRIVATE-TOKEN', "test-gitlab-token")
expect(request).not_to have_received(:[]=).with('JOB-TOKEN', "test-ci-job-token")
end
end
it "stops retrying after max attempts" do
result = differ.make_request(test_url, attempt: 4, max_attempts: 3)
expect(result).to eq([])
expect(logger).to have_received(:info).with("Maximum retry attempts (3) reached for rate limiting")
end
it "returns parsed JSON for successful requests" do
result = differ.make_request(test_url)
expect(result).to eq({ "data" => "success" })
end
it "returns raw response body when parse_json is false" do
allow(success_response).to receive(:body).and_return('raw response data')
result = differ.make_request(test_url, parse_json: false)
expect(result).to eq('raw response data')
end
it "supports HEAD requests" do
result = differ.make_request(test_url, method: :head, parse_json: false)
expect(result).to eq(success_response)
end
context "when handling errors" do
it "retries on common server errors" do
allow(http).to receive(:request).and_return(
Net::HTTPServiceUnavailable.new('1.1', '503', 'Service Unavailable'),
Net::HTTPTooManyRequests.new('1.1', '429', 'Too Many Requests'),
success_response
)
allow(differ).to receive(:sleep)
result = differ.make_request(test_url)
expect(result).to eq({ "data" => "success" })
end
it "returns empty json when parse_json is true" do
allow(http).to receive(:request).and_return(Net::HTTPFatalError)
expect(differ.make_request(test_url, method: :get, parse_json: true)).to eq([])
end
it "returns nil when parse json is false" do
allow(http).to receive(:request).and_return(Net::HTTPFatalError)
expect(differ.make_request(test_url, method: :get, parse_json: false)).to be_nil
end
it "logs error when resource not found" do
allow(http).to receive(:request).and_return(Net::HTTPNotFound.new('1.1', '404', 'Test 404'))
expect(differ.make_request(test_url, method: :get, parse_json: true)).to eq([])
expect(logger).to have_received(:error).with(/HTTP request failed: 404 - Test 404/)
end
it "logs error if an unsupported method is passed" do
result = differ.make_request(test_url, method: :put, parse_json: false)
expect(result).to be_nil
expect(logger).to have_received(:error).with(/Error making request: Unsupported HTTP method: put/)
end
it "returns nil and logs error when exception occurs" do
allow(http).to receive(:request).and_raise(StandardError.new("Testing Error"))
result = differ.make_request(test_url, parse_json: false)
expect(result).to be_nil
expect(logger).to have_received(:error).with("Error making request: Testing Error")
end
it "returns empty array and logs error when JSON parsing fails" do
allow(success_response).to receive(:body).and_return('invalid json')
result = differ.make_request(test_url)
expect(result).to eq([])
expect(logger).to have_received(:error).with(/Failed to parse JSON/)
end
end
end
describe "#write_report" do
subject(:differ) { described_class.new(empty_file, logger) }
before do
allow(logger).to receive(:info)
allow(differ).to receive(:write_report).and_call_original
allow(File).to receive(:write)
end
it "writes content to file and logs success" do
differ.write_report("test.txt", "content")
expect(logger).to have_received(:info).with("Report saved to test.txt")
end
it "logs errors when file write fails" do
allow(File).to receive(:write).and_raise(StandardError.new("Write error"))
differ.write_report("test.txt", "content")
expect(logger).to have_received(:error).with("Could not write report to file: Write error")
end
end
describe "ReportGenerator" do
let(:report_generator) { differ.report_generator }
it "generates a report with new queries" do
report = report_generator.generate([{ 'fingerprint' => 'fp1', 'normalized' => 'SELECT * FROM users' }])
expect(report).to include("# SQL Query Analysis")
expect(report).to include("Identified potential 1 new SQL queries")
expect(report).to include("Query 1")
expect(report).to include("SELECT * FROM users")
expect(report).to include("fp1")
end
it "includes execution plans when available" do
report = report_generator.generate([
{ 'fingerprint' => 'fp1', 'normalized' => 'SELECT * FROM users', 'plan' => { 'Node Type' => 'Index Scan' } }
])
expect(report).to include("Execution Plan")
expect(report).to include("Index Scan")
end
it "handles empty query list" do
report = report_generator.generate([])
expect(report).to include("No new SQL queries detected in this MR")
end
it "formats hash plan" do
hash_plan = { 'Node Type' => 'Index Scan' }
result = report_generator.format_plan(hash_plan)
expect(result).to include(' "Node Type": "Index Scan"')
end
it "formats non hash plan" do
result = report_generator.format_plan(1234)
expect(result).to include('1234')
end
end
end