From 87ea46f7fa2b269f065181f7765352184bb59717 Mon Sep 17 00:00:00 2001 From: Max Kanat-Alexander Date: Wed, 7 Jul 2010 14:34:25 -0700 Subject: Bug 574879: Create a test that assures the correctness of Search.pm's boolean charts r=glob, a=mkanat --- xt/README | 18 + xt/lib/Bugzilla/Test/Search.pm | 941 ++++++++++++++++++++++++ xt/lib/Bugzilla/Test/Search/AndTest.pm | 69 ++ xt/lib/Bugzilla/Test/Search/Constants.pm | 1011 ++++++++++++++++++++++++++ xt/lib/Bugzilla/Test/Search/FakeCGI.pm | 61 ++ xt/lib/Bugzilla/Test/Search/FieldTest.pm | 564 ++++++++++++++ xt/lib/Bugzilla/Test/Search/InjectionTest.pm | 77 ++ xt/lib/Bugzilla/Test/Search/OperatorTest.pm | 110 +++ xt/lib/Bugzilla/Test/Search/OrTest.pm | 186 +++++ xt/search.t | 96 +++ 10 files changed, 3133 insertions(+) create mode 100644 xt/README create mode 100644 xt/lib/Bugzilla/Test/Search.pm create mode 100644 xt/lib/Bugzilla/Test/Search/AndTest.pm create mode 100644 xt/lib/Bugzilla/Test/Search/Constants.pm create mode 100644 xt/lib/Bugzilla/Test/Search/FakeCGI.pm create mode 100644 xt/lib/Bugzilla/Test/Search/FieldTest.pm create mode 100644 xt/lib/Bugzilla/Test/Search/InjectionTest.pm create mode 100644 xt/lib/Bugzilla/Test/Search/OperatorTest.pm create mode 100644 xt/lib/Bugzilla/Test/Search/OrTest.pm create mode 100644 xt/search.t (limited to 'xt') diff --git a/xt/README b/xt/README new file mode 100644 index 000000000..22f9f171b --- /dev/null +++ b/xt/README @@ -0,0 +1,18 @@ +The tests in this directory require a working database, as opposed +to the tests in t/, which simply test the code without a working +installation. + +Some of the tests may modify your current working installation, even +if only temporarily. To run the tests that modify your database, +set the environment variable BZ_WRITE_TESTS to 1. + +Some tests also take additional, optional arguments. You can pass arguments +to tests like: + + prove xt/search.t :: --long --operators=equals,notequals + +Note the "::"--that is necessary to note that the arguments are going to +the test, not to "prove". + +See the perldoc of the individual tests to see what options they support, +or do "perl xt/search.t --help". diff --git a/xt/lib/Bugzilla/Test/Search.pm b/xt/lib/Bugzilla/Test/Search.pm new file mode 100644 index 000000000..80e9e0c52 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search.pm @@ -0,0 +1,941 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This module tests Bugzilla/Search.pm. It uses various constants +# that are in Bugzilla::Test::Search::Constants, in xt/lib/. +# +# It does this by: +# 1) Creating a bunch of field values. Each field value is +# randomly named and fully unique. +# 2) Creating a bunch of bugs that use those unique field +# values. Each bug has different characteristics--see +# the comment above the NUM_BUGS constant for a description +# of each bug. +# 3) Running searches using the combination of every search operator against +# every field. The tests that we run are described by the TESTS constant. +# Some of the operator/field combinations are known to be broken-- +# these are listed in the KNOWN_BROKEN constant. +# 4) For each search, we make sure that certain bugs are contained in +# the search, and certain other bugs are not contained in the search. +# The code for the operator/field tests is mostly in +# Bugzilla::Test::Search::FieldTest. +# 5) After testing each operator/field combination's functionality, we +# do additional tests to make sure that there are no SQL injections +# possible via any operator/field combination. The code for the +# SQL Injection tests is in Bugzilla::Test::Search::InjectionTest. +# +# Generally, the only way that you should modify the behavior of this +# script is by modifying the constants. + +package Bugzilla::Test::Search; + +use strict; +use warnings; +use Bugzilla::Attachment; +use Bugzilla::Bug (); +use Bugzilla::Constants; +use Bugzilla::Field; +use Bugzilla::Field::Choice; +use Bugzilla::FlagType; +use Bugzilla::Group; +use Bugzilla::Install (); +use Bugzilla::Test::Search::Constants; +use Bugzilla::Test::Search::OperatorTest; +use Bugzilla::User (); +use Bugzilla::Util qw(generate_random_password); + +use Carp; +use DateTime; +use Scalar::Util qw(blessed); + +############### +# Constructor # +############### + +sub new { + my ($class, $options) = @_; + return bless { options => $options }, $class; +} + +############# +# Accessors # +############# + +sub options { return $_[0]->{options} } +sub option { return $_[0]->{options}->{$_[1]} } + +sub num_tests { + my ($self) = @_; + my @top_operators = $self->top_level_operators; + my @all_operators = $self->all_operators; + my $top_operator_tests = $self->_total_operator_tests(\@top_operators); + my $all_operator_tests = $self->_total_operator_tests(\@all_operators); + + my @fields = $self->all_fields; + + # Basically, we run TESTS_PER_RUN tests for each field/operator combination. + my $top_combinations = $top_operator_tests * scalar(@fields); + my $all_combinations = $all_operator_tests * scalar(@fields); + # But we also have ORs, for which we run combinations^2 tests. + my $join_tests = $self->option('long') + ? ($top_combinations * $all_combinations) : 0; + # And AND tests, which means we run 2x $join_tests; + $join_tests = $join_tests * 2; + my $operator_field_tests = ($top_combinations + $join_tests) * TESTS_PER_RUN; + + # Then we test each field/operator combination for SQL injection. + my @injection_values = INJECTION_TESTS; + my $sql_injection_tests = scalar(@fields) * scalar(@top_operators) + * scalar(@injection_values) * NUM_SEARCH_TESTS; + + return $operator_field_tests + $sql_injection_tests; +} + +sub _total_operator_tests { + my ($self, $operators) = @_; + + # Some operators have more than one test. Find those ones and add + # them to the total operator tests + my $extra_operator_tests; + foreach my $operator (@$operators) { + my $tests = TESTS->{$operator}; + next if !$tests; + my $extra_num = scalar(@$tests) - 1; + $extra_operator_tests += $extra_num; + } + return scalar(@$operators) + $extra_operator_tests; + +} + +sub all_operators { + my ($self) = @_; + if (not $self->{all_operators}) { + + my @operators; + if (my $limit_operators = $self->option('operators')) { + @operators = split(',', $limit_operators); + } + else { + @operators = sort (keys %{ Bugzilla::Search::OPERATORS() }); + } + # "substr" is just a backwards-compatibility operator, same as "substring". + @operators = grep { $_ ne 'substr' } @operators; + $self->{all_operators} = \@operators; + } + return @{ $self->{all_operators} }; +} + +sub all_fields { + my $self = shift; + if (not $self->{all_fields}) { + $self->_create_custom_fields(); + my @fields = Bugzilla->get_fields; + @fields = sort { $a->name cmp $b->name } @fields; + $self->{all_fields} = \@fields; + } + return @{ $self->{all_fields} }; +} + +sub top_level_operators { + my ($self) = @_; + if (!$self->{top_level_operators}) { + my @operators; + my $limit_top = $self->option('top-operators'); + if ($limit_top) { + @operators = split(',', $limit_top); + } + else { + @operators = $self->all_operators; + } + $self->{top_level_operators} = \@operators; + } + return @{ $self->{top_level_operators} }; +} + +sub text_fields { + my ($self) = @_; + my @text_fields = grep { $_->type == FIELD_TYPE_TEXTAREA + or $_->type == FIELD_TYPE_FREETEXT } $self->all_fields; + @text_fields = map { $_->name } @text_fields; + push(@text_fields, qw(short_desc status_whiteboard bug_file_loc see_also)); + return @text_fields; +} + +sub bugs { + my $self = shift; + $self->{bugs} ||= [map { $self->_create_one_bug($_) } (1..NUM_BUGS)]; + return @{ $self->{bugs} }; +} + +# Get a numbered bug. +sub bug { + my ($self, $number) = @_; + return ($self->bugs)[$number - 1]; +} + +sub admin { + my $self = shift; + if (!$self->{admin_user}) { + my $admin = create_user("admin"); + Bugzilla::Install::make_admin($admin); + $self->{admin_user} = $admin; + } + # We send back a fresh object every time, to make sure that group + # memberships are always up-to-date. + return new Bugzilla::User($self->{admin_user}->id); +} + +sub nobody { + my $self = shift; + $self->{nobody} ||= Bugzilla::Group->create({ name => "nobody-" . random(), + description => "Nobody", isbuggroup => 1 }); + return $self->{nobody}; +} +sub everybody { + my ($self) = @_; + $self->{everybody} ||= create_group('To The Limit'); + return $self->{everybody}; +} + +sub bug_create_value { + my ($self, $number, $field) = @_; + $field = $field->name if blessed($field); + if ($number == 6 and $field ne 'alias') { + $number = 1; + } + my $value = $self->_bug_create_values->{$number}->{$field}; + return $value if defined $value; + return $self->_extra_bug_create_values->{$number}->{$field}; +} +sub bug_update_value { + my ($self, $number, $field) = @_; + $field = $field->name if blessed($field); + if ($number == 6 and $field ne 'alias') { + $number = 1; + } + return $self->_bug_update_values->{$number}->{$field}; +} + +# Values used to create the bugs. +sub _bug_create_values { + my $self = shift; + return $self->{bug_create_values} if $self->{bug_create_values}; + my %values; + foreach my $number (1..NUM_BUGS) { + $values{$number} = $self->_create_field_values($number, 'for create'); + } + $self->{bug_create_values} = \%values; + return $self->{bug_create_values}; +} +# Values as they existed on the bug, at creation time. Used by the +# changedfrom tests. +sub _extra_bug_create_values { + my $self = shift; + $self->{extra_bug_create_values} ||= { map { $_ => {} } (1..NUM_BUGS) }; + return $self->{extra_bug_create_values}; +} + +# Values used to update the bugs after they are created. +sub _bug_update_values { + my $self = shift; + return $self->{bug_update_values} if $self->{bug_update_values}; + my %values; + foreach my $number (1..NUM_BUGS) { + $values{$number} = $self->_create_field_values($number); + } + $self->{bug_update_values} = \%values; + return $self->{bug_update_values}; +} + +############################## +# General Helper Subroutines # +############################## + +sub random { + $_[0] ||= FIELD_SIZE; + generate_random_password(@_); +} + +# We need to use a custom timestamp for each create() and update(), +# because the database returns the same value for LOCALTIMESTAMP(0) +# for the entire transaction, and we need each created bug to have +# its own creation_ts and delta_ts. +sub timestamp { + my ($day, $second) = @_; + return DateTime->new( + year => 2037, + month => 1, + day => $day, + hour => 12, + minute => $second, + second => 0, + # We make it floating because the timezone doesn't matter for our uses, + # and we want totally consistent behavior across all possible machines. + time_zone => 'floating', + ); +} + +sub create_keyword { + my ($number) = @_; + return Bugzilla::Keyword->create({ + name => "$number-keyword-" . random(), + description => "Keyword $number" }); +} + +sub create_user { + my ($prefix) = @_; + my $user_name = $prefix . '-' . random(10) . "@" . random(10) + . "." . random(3); + my $user_realname = $prefix . '-' . random(); + my $user = Bugzilla::User->create({ + login_name => $user_name, + realname => $user_realname, + cryptpassword => '*', + }); + return $user; +} + +sub create_group { + my ($prefix) = @_; + return Bugzilla::Group->create({ + name => "$prefix-group-" . random(), description => "Everybody $prefix", + userregexp => '.*', isbuggroup => 1 }); +} + +sub create_legal_value { + my ($field, $number) = @_; + my $type = Bugzilla::Field::Choice->type($field); + my $field_name = $field->name; + return $type->create({ value => "$number-$field_name-" . random(), + is_open => 0 }); +} + +######################### +# Custom Field Creation # +######################### + +sub _create_custom_fields { + my ($self) = @_; + return if !$self->option('add-custom-fields'); + + while (my ($type, $name) = each %{ CUSTOM_FIELDS() }) { + my $exists = new Bugzilla::Field({ name => $name }); + next if $exists; + Bugzilla::Field->create({ + name => $name, + type => $type, + description => "Search Test Field $name", + enter_bug => 1, + custom => 1, + buglist => 1, + is_mandatory => 0, + }); + } +} + +######################## +# Field Value Creation # +######################## + +sub _create_field_values { + my ($self, $number, $for_create) = @_; + my $dbh = Bugzilla->dbh; + + Bugzilla->set_user($self->admin); + + my @selects = grep { $_->is_select } $self->all_fields; + my %values; + foreach my $field (@selects) { + next if $field->is_abnormal; + $values{$field->name} = create_legal_value($field, $number)->name; + } + + my $group = create_group($number); + $values{groups} = [$group->name]; + + $values{'keywords'} = create_keyword($number)->name; + + foreach my $field qw(assigned_to qa_contact reporter cc) { + $values{$field} = create_user("$number-$field-")->login; + } + + my $classification = Bugzilla::Classification->create( + { name => "$number-classification-" . random() }); + $classification = $classification->name; + + my $version = "$number-version-" . random(); + my $milestone = "$number-tm-" . random(15); + my $product = Bugzilla::Product->create({ + name => "$number-product-" . random(), + description => 'Created by t/search.t', + defaultmilestone => $milestone, + classification => $classification, + version => $version, + allows_unconfirmed => 1, + }); + foreach my $item ($group, $self->nobody) { + $product->set_group_controls($item, + { membercontrol => CONTROLMAPSHOWN, + othercontrol => CONTROLMAPNA }); + } + # $product->update() is called lower down. + my $component = Bugzilla::Component->create({ + product => $product, name => "$number-component-" . random(), + initialowner => create_user("$number-defaultowner")->login, + initialqacontact => create_user("$number-defaultqa")->login, + initial_cc => [create_user("$number-initcc")->login], + description => "Component $number" }); + + $values{'product'} = $product->name; + $values{'component'} = $component->name; + $values{'target_milestone'} = $milestone; + $values{'version'} = $version; + + foreach my $field ($self->text_fields) { + # We don't add a - after $field for the text fields, because + # if we do, fulltext searching for short_desc pulls out + # "short_desc" as a word and matches it in every bug. + my $value = "$number-$field" . random(); + if ($field eq 'bug_file_loc' or $field eq 'see_also') { + $value = "http://$value" . random(3) + . "/show_bug.cgi?id=$number"; + } + $values{$field} = $value; + } + + my @date_fields = grep { $_->type == FIELD_TYPE_DATETIME } $self->all_fields; + foreach my $field (@date_fields) { + # We use 03 as the month because that differs from our creation_ts, + # delta_ts, and deadline. (It's nice to have recognizable values + # for each field when debugging.) + my $second = $for_create ? $number : $number + 1; + $values{$field->name} = "2037-03-0$number 12:34:0$second"; + } + + $values{alias} = "$number-alias-" . random(12); + + # Prefixing the original comment with "description" makes the + # lesserthan and greaterthan tests behave predictably. + my $comm_prefix = $for_create ? "description-" : ''; + $values{comment} = "$comm_prefix$number-comment-" . random() + . ' ' . random(); + + my @flags; + my $setter = create_user("$number-setter"); + my $requestee = create_user("$number-requestee"); + $values{set_flags} = _create_flags($number, $setter, $requestee); + + my $month = $for_create ? "12" : "02"; + $values{'deadline'} = "2037-$month-0$number"; + my $estimate_times = $for_create ? 10 : 1; + $values{estimated_time} = $estimate_times * $number; + + $values{attachment} = _get_attach_values($number, $for_create); + + # Some things only happen on the first bug. + if ($number == 1) { + # We use 6 as the prefix for the extra values, because bug 6's values + # don't otherwise get used (since bug 6 is created as a clone of + # bug 1). This also makes sure that our greaterthan/lessthan + # tests work properly. + my $extra_group = create_group(6); + $product->set_group_controls($extra_group, + { membercontrol => CONTROLMAPSHOWN, + othercontrol => CONTROLMAPNA }); + $values{groups} = [$values{groups}->[0], $extra_group->name]; + my $extra_keyword = create_keyword(6); + $values{keywords} = [$values{keywords}, $extra_keyword->name]; + my $extra_cc = create_user("6-cc"); + $values{cc} = [$values{cc}, $extra_cc->login]; + my @multi_selects = grep { $_->type == FIELD_TYPE_MULTI_SELECT } + $self->all_fields; + foreach my $field (@multi_selects) { + my $new_value = create_legal_value($field, 6); + my $name = $field->name; + $values{$name} = [$values{$name}, $new_value->name]; + } + } + + # On bug 5, any field that *can* be left empty, *is* left empty. + if ($number == 5) { + my @set_fields = grep { $_->type == FIELD_TYPE_SINGLE_SELECT } + $self->all_fields; + @set_fields = map { $_->name } @set_fields; + push(@set_fields, qw(short_desc version reporter)); + foreach my $key (keys %values) { + delete $values{$key} unless grep { $_ eq $key } @set_fields; + } + } + + $product->update(); + + return \%values; +} + +# Flags +sub _create_flags { + my ($number, $setter, $requestee) = @_; + + my $flagtypes = _create_flagtypes($number); + + my %flags; + foreach my $type qw(a b) { + $flags{$type} = _get_flag_values(@_, $flagtypes->{$type}); + } + return \%flags; +} + +sub _create_flagtypes { + my ($number) = @_; + my $dbh = Bugzilla->dbh; + my $name = "$number-flag-" . random(); + my $desc = "FlagType $number"; + + my %flagtypes; + foreach my $target (qw(a b)) { + $dbh->do("INSERT INTO flagtypes + (name, description, target_type, is_requestable, + is_requesteeble, is_multiplicable, cc_list) + VALUES (?,?,?,1,1,1,'')", + undef, $name, $desc, $target); + my $id = $dbh->bz_last_key('flagtypes', 'id'); + $dbh->do('INSERT INTO flaginclusions (type_id) VALUES (?)', + undef, $id); + my $flagtype = new Bugzilla::FlagType($id); + $flagtypes{$target} = $flagtype; + } + return \%flagtypes; +} + +sub _get_flag_values { + my ($number, $setter, $requestee, $flagtype) = @_; + + my @set_flags; + if ($number <= 2) { + foreach my $value (qw(? - + ?)) { + my $flag = { type_id => $flagtype->id, status => $value, + setter => $setter, flagtype => $flagtype }; + push(@set_flags, $flag); + } + $set_flags[0]->{requestee} = $requestee->login; + } + else { + @set_flags = ({ type_id => $flagtype->id, status => '+', + setter => $setter, flagtype => $flagtype }); + } + return \@set_flags; +} + +# Attachments +sub _get_attach_values { + my ($number, $for_create) = @_; + + my $boolean = $number == 1 ? 1 : 0; + if ($for_create) { + $boolean = !$boolean ? 1 : 0; + } + my $ispatch = $for_create ? 'ispatch' : 'is_patch'; + my $isobsolete = $for_create ? 'isobsolete' : 'is_obsolete'; + my $isprivate = $for_create ? 'isprivate' : 'is_private'; + my $mimetype = $for_create ? 'mimetype' : 'content_type'; + + my %values = ( + description => "$number-attach_desc-" . random(), + filename => "$number-filename-" . random(), + $ispatch => $boolean, + $isobsolete => $boolean, + $isprivate => $boolean, + $mimetype => "text/x-$number-" . random(), + ); + if ($for_create) { + $values{data} = "$number-data-" . random() . random(); + } + return \%values; +} + +################ +# Bug Creation # +################ + +sub _create_one_bug { + my ($self, $number) = @_; + my $dbh = Bugzilla->dbh; + + # We need bug 6 to have a unique alias that is not a clone of bug 1's, + # so we get the alias separately from the other parameters. + my $alias = $self->bug_create_value($number, 'alias'); + my $update_alias = $self->bug_update_value($number, 'alias'); + + # Otherwise, make bug 6 a clone of bug 1. + $number = 1 if $number == 6; + + my $reporter = $self->bug_create_value($number, 'reporter'); + Bugzilla->set_user(Bugzilla::User->check($reporter)); + + # We create the bug with one set of values, and then we change it + # to have different values. + my %params = %{ $self->_bug_create_values->{$number} }; + $params{alias} = $alias; + + # There are some things in bug_create_values that shouldn't go into + # create(). + delete @params{qw(attachment set_flags)}; + + my ($status, $resolution, $see_also) = + delete @params{qw(bug_status resolution see_also)}; + # All the bugs are created with everconfirmed = 0. + $params{bug_status} = 'UNCONFIRMED'; + my $bug = Bugzilla::Bug->create(\%params); + + # These are necessary for the changedfrom tests. + my $extra_values = $self->_extra_bug_create_values->{$number}; + foreach my $field qw(comments remaining_time flags percentage_complete + keyword_objects everconfirmed dependson blocked + groups_in) + { + $extra_values->{$field} = $bug->$field; + } + $extra_values->{reporter_accessible} = $number == 1 ? 0 : 1; + $extra_values->{cclist_accessible} = $number == 1 ? 0 : 1; + + if ($number == 5) { + # Bypass Bugzilla::Bug--we don't want any changes in bugs_activity + # for bug 5. + $dbh->do('UPDATE bugs SET qa_contact = NULL, reporter_accessible = 0, + cclist_accessible = 0 WHERE bug_id = ?', + undef, $bug->id); + $dbh->do('DELETE FROM cc WHERE bug_id = ?', undef, $bug->id); + my $ts = '1970-01-01 00:00:00'; + $dbh->do('UPDATE bugs SET creation_ts = ?, delta_ts = ? + WHERE bug_id = ?', undef, $ts, $ts, $bug->id); + $dbh->do('UPDATE longdescs SET bug_when = ? WHERE bug_id = ?', + undef, $ts, $bug->id); + $bug->{creation_ts} = $ts; + } + else { + # Manually set the creation_ts so that each bug has a different one. + # + # Also, manually update the resolution and bug_status, because + # we want to see both of them change in bugs_activity, so we + # have to start with values for both (and as of the time when I'm + # writing this test, Bug->create doesn't support setting resolution). + # + # Same for see_also. + my $timestamp = timestamp($number, $number - 1); + my $creation_ts = $timestamp->ymd . ' ' . $timestamp->hms; + $bug->{creation_ts} = $creation_ts; + $dbh->do('UPDATE longdescs SET bug_when = ? WHERE bug_id = ?', + undef, $creation_ts, $bug->id); + $dbh->do('UPDATE bugs SET creation_ts = ?, bug_status = ?, + resolution = ? WHERE bug_id = ?', + undef, $creation_ts, $status, $resolution, $bug->id); + $dbh->do('INSERT INTO bug_see_also (bug_id, value) VALUES (?,?)', + undef, $bug->id, $see_also); + + if ($number == 1) { + # Bug 1 needs to start off with reporter_accessible and + # cclist_accessible being 0, so that when we change them to 1, + # that change shows up in bugs_activity. + $dbh->do('UPDATE bugs SET reporter_accessible = 0, + cclist_accessible = 0 WHERE bug_id = ?', + undef, $bug->id); + } + + my %update_params = %{ $self->_bug_update_values->{$number} }; + my %reverse_map = reverse %{ Bugzilla::Bug->FIELD_MAP }; + foreach my $db_name (keys %reverse_map) { + next if $db_name eq 'comment'; + next if $db_name eq 'status_whiteboard'; + if (exists $update_params{$db_name}) { + my $update_name = $reverse_map{$db_name}; + $update_params{$update_name} = delete $update_params{$db_name}; + } + } + + my ($new_status, $new_res) = + delete @update_params{qw(status resolution)}; + # Bypass the status workflow. + $bug->{bug_status} = $new_status; + $bug->{resolution} = $new_res; + $bug->{everconfirmed} = 1 if $number == 1; + + # add/remove/set fields. + $update_params{keywords} = { set => $update_params{keywords} }; + $update_params{groups} = { add => $update_params{groups}, + remove => $bug->groups_in }; + my @cc_remove = map { $_->login } @{ $bug->cc_users }; + my $cc_add = $update_params{cc}; + $cc_add = [$cc_add] if !ref $cc_add; + $update_params{cc} = { add => $cc_add, remove => \@cc_remove }; + my $see_also_remove = $bug->see_also; + my $see_also_add = [$update_params{see_also}]; + $update_params{see_also} = { add => $see_also_add, + remove => $see_also_remove }; + $update_params{comment} = { body => $update_params{comment} }; + $update_params{work_time} = $number; + # Setting work_time kills the remaining_time, so we need to + # preserve that. We add 8 because that produces an integer + # percentage_complete for bug 1, which is necessary for + # accurate "equals"-type searching. + $update_params{remaining_time} = $number + 8; + $update_params{reporter_accessible} = $number == 1 ? 1 : 0; + $update_params{cclist_accessible} = $number == 1 ? 1 : 0; + $update_params{alias} = $update_alias; + + $bug->set_all(\%update_params); + my $flags = $self->bug_create_value($number, 'set_flags')->{b}; + $bug->set_flags([], $flags); + $timestamp->set(second => $number); + $bug->update($timestamp->ymd . ' ' . $timestamp->hms); + + # It's not generally safe to do update() multiple times on + # the same Bug object. + $bug = new Bugzilla::Bug($bug->id); + my $update_flags = $self->bug_update_value($number, 'set_flags')->{b}; + $_->{status} = 'X' foreach @{ $bug->flags }; + $bug->set_flags($bug->flags, $update_flags); + if ($number == 1) { + my $comment_id = $bug->comments->[-1]->id; + $bug->set_comment_is_private({ $comment_id => 1 }); + } + $bug->update($bug->delta_ts); + + my $attach_create = $self->bug_create_value($number, 'attachment'); + my $attachment = Bugzilla::Attachment->create({ + bug => $bug, + creation_ts => $creation_ts, + %$attach_create }); + # Store for the changedfrom tests. + $extra_values->{attachments} = + [new Bugzilla::Attachment($attachment->id)]; + + my $attach_update = $self->bug_update_value($number, 'attachment'); + $attachment->set_all($attach_update); + # In order to keep the mimetype on the ispatch attachment, + # we need to bypass the validator. + $attachment->{mimetype} = $attach_update->{content_type}; + my $attach_flags = $self->bug_update_value($number, 'set_flags')->{a}; + $attachment->set_flags([], $attach_flags); + $attachment->update($bug->delta_ts); + } + + # Values for changedfrom. + $extra_values->{creation_ts} = $bug->creation_ts; + $extra_values->{delta_ts} = $bug->creation_ts; + + return new Bugzilla::Bug($bug->id); +} + +################################### +# Test::Builder Memory Efficiency # +################################### + +# Test::Builder stores information for each test run, but Test::Harness +# and TAP::Harness don't actually need this information. When we run 60 +# million tests, the history eats up all our memory. (After about +# 1 million tests, memory usage is around 1 GB.) +# +# The only part of the history that Test::More actually *uses* is the "ok" +# field, which we store more efficiently, in an array, and then we re-populate +# the Test_Results in Test::Builder at the end of the test. +sub clean_test_history { + my ($self) = @_; + return if !$self->option('long'); + my $builder = Test::More->builder; + my $current_test = $builder->current_test; + + # I don't use details() because I don't want to copy the array. + my $results = $builder->{Test_Results}; + my $check_test = $current_test - 1; + while (my $result = $results->[$check_test]) { + last if !$result; + $self->test_success($check_test, $result->{ok}); + $check_test--; + } + + # Truncate the test history array, but retain the current test number. + $builder->{Test_Results} = []; + $builder->{Curr_Test} = $current_test; +} + +sub test_success { + my ($self, $index, $status) = @_; + $self->{test_success}->[$index] = $status; + return $self->{test_success}; +} + +sub repopulate_test_results { + my ($self) = @_; + return if !$self->option('long'); + $self->clean_test_history(); + # We create only two hashes, for memory efficiency. + my %ok = ( ok => 1 ); + my %not_ok = ( ok => 0 ); + my @results; + foreach my $success (@{ $self->{test_success} }) { + push(@results, $success ? \%ok : \%not_ok); + } + my $builder = Test::More->builder; + $builder->{Test_Results} = \@results; +} + +########## +# Caches # +########## + +# When doing AND and OR tests, we essentially test the same field/operator +# combinations over and over. So, if we're going to be running those tests, +# we cache the translated_value of the FieldTests globally so that we don't +# have to re-run the value-translation code every time (which can be pretty +# slow). +sub value_translation_cache { + my ($self, $field_test, $value) = @_; + return if !$self->option('long'); + my $test_name = $field_test->name; + if (@_ == 3) { + $self->{value_translation_cache}->{$test_name} = $value; + } + return $self->{value_translation_cache}->{$test_name}; +} + +############# +# Main Test # +############# + +sub run { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + # We want backtraces on any "die" message or any warning. + # Otherwise it's hard to trace errors inside of Bugzilla::Search from + # reading automated test run results. + local $SIG{__WARN__} = \&Carp::cluck; + local $SIG{__DIE__} = \&Carp::confess; + + $dbh->bz_start_transaction(); + + # Some parameters need to be set in order for the tests to function + # properly. + my $everybody = $self->everybody; + my $params = Bugzilla->params; + local $params->{'useclassification'} = 1; + local $params->{'useqacontact'} = 1; + local $params->{'usebugaliases'} = 1; + local $params->{'usetargetmilestone'} = 1; + local $params->{'mail_delivery_method'} = 'None'; + local $params->{'timetrackinggroup'} = $everybody->name; + local $params->{'insidergroup'} = $everybody->name; + + $self->_setup_bugs(); + + # Even though _setup_bugs set us as an admin, we want to be sure at + # this point that we have an admin with refreshed group memberships. + Bugzilla->set_user($self->admin); + foreach my $operator ($self->top_level_operators) { + my $operator_test = + new Bugzilla::Test::Search::OperatorTest($operator, $self); + $operator_test->run(); + } + + # Rollbacks won't get rid of bugs_fulltext entries, so we do that ourselves. + my @bug_ids = map { $_->id } $self->bugs; + my $bug_id_string = join(',', @bug_ids); + $dbh->do("DELETE FROM bugs_fulltext WHERE bug_id IN ($bug_id_string)"); + $dbh->bz_rollback_transaction(); + $self->repopulate_test_results(); +} + +# This makes a few changes to the bugs after they're created--changes +# that can only be done after all the bugs have been created. +sub _setup_bugs { + my ($self) = @_; + $self->_setup_dependencies(); + $self->_set_bug_id_fields(); + $self->_protect_bug_6(); +} +sub _setup_dependencies { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + # Set up depedency relationships between the bugs. + # Bug 1 + 6 depend on bug 2 and block bug 3. + my $bug2 = $self->bug(2); + my $bug3 = $self->bug(3); + foreach my $number (1,6) { + my $bug = $self->bug($number); + my @original_delta = ($bug2->delta_ts, $bug3->delta_ts); + Bugzilla->set_user($bug->reporter); + $bug->set_dependencies([$bug2->id], [$bug3->id]); + $bug->update($bug->delta_ts); + # Setting dependencies changed the delta_ts on bug2 and bug3, so + # re-set them back to what they were before. However, we leave + # the correct update times in bugs_activity, so that the changed* + # searches still work right. + my $set_delta = $dbh->prepare( + 'UPDATE bugs SET delta_ts = ? WHERE bug_id = ?'); + foreach my $row ([$original_delta[0], $bug2->id], + [$original_delta[1], $bug3->id]) + { + $set_delta->execute(@$row); + } + } +} + +sub _set_bug_id_fields { + my ($self) = @_; + # BUG_ID fields couldn't be set before, because before we create bug 1, + # we don't necessarily have any valid bug ids.) + my @bug_id_fields = grep { $_->type == FIELD_TYPE_BUG_ID } + $self->all_fields; + foreach my $number (1..NUM_BUGS) { + my $bug = $self->bug($number); + $number = 1 if $number == 6; + next if $number == 5; + my $other_bug = $self->bug($number + 1); + Bugzilla->set_user($bug->reporter); + foreach my $field (@bug_id_fields) { + $bug->set_custom_field($field, $other_bug->id); + $bug->update($bug->delta_ts); + } + } +} + +sub _protect_bug_6 { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + Bugzilla->set_user($self->admin); + + # Put bug6 in the nobody group. + my $nobody = $self->nobody; + # We pull it newly from the DB to be sure it's safe to call update() + # on. + my $bug6 = new Bugzilla::Bug($self->bug(6)->id); + $bug6->add_group($nobody); + $bug6->update($bug6->delta_ts); + + # Remove the admin (and everybody else) from the $nobody group. + $dbh->do('DELETE FROM group_group_map + WHERE grantor_id = ? OR member_id = ?', undef, + $nobody->id, $nobody->id); +} + +1; diff --git a/xt/lib/Bugzilla/Test/Search/AndTest.pm b/xt/lib/Bugzilla/Test/Search/AndTest.pm new file mode 100644 index 000000000..d7b21af48 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/AndTest.pm @@ -0,0 +1,69 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This test combines two field/operator combinations using AND in +# a single boolean chart. +package Bugzilla::Test::Search::AndTest; +use base qw(Bugzilla::Test::Search::OrTest); + +use Bugzilla::Test::Search::Constants; +use Bugzilla::Test::Search::FakeCGI; +use List::MoreUtils qw(all); + +use constant type => 'AND'; + +############# +# Accessors # +############# + +# In an AND test, bugs ARE supposed to be contained only if they are contained +# by ALL tests. +sub bug_is_contained { + my ($self, $number) = @_; + return all { $_->bug_is_contained($number) } $self->field_tests; +} + +######################## +# SKIP & TODO Messages # +######################## + +sub _join_skip { () } +sub _join_broken_constant { {} } + +############################## +# Bugzilla::Search arguments # +############################## + +sub search_params { + my ($self) = @_; + my @all_params = map { $_->search_params } $self->field_tests; + my $params = new Bugzilla::Test::Search::FakeCGI; + my $chart = 0; + foreach my $item (@all_params) { + $params->param("field0-$chart-0", $item->param('field0-0-0')); + $params->param("type0-$chart-0", $item->param('type0-0-0')); + $params->param("value0-$chart-0", $item->param('value0-0-0')); + $chart++; + } + return $params; +} + +1; \ No newline at end of file diff --git a/xt/lib/Bugzilla/Test/Search/Constants.pm b/xt/lib/Bugzilla/Test/Search/Constants.pm new file mode 100644 index 000000000..95bba8ed4 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/Constants.pm @@ -0,0 +1,1011 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + + +# These are constants used by Bugzilla::Test::Search. +# See the comment at the top of that package for a general overview +# of how the search test works, and how the constants are used. +# More detailed information on each constant is available in the comments +# in this file. +package Bugzilla::Test::Search::Constants; +use base qw(Exporter); +use Bugzilla::Constants; + +our @EXPORT = qw( + ATTACHMENT_FIELDS + COLUMN_TRANSLATION + COMMENT_FIELDS + CUSTOM_FIELDS + FIELD_SIZE + FIELD_SUBSTR_SIZE + FLAG_FIELDS + INJECTION_BROKEN_FIELD + INJECTION_BROKEN_OPERATOR + INJECTION_TESTS + KNOWN_BROKEN + NUM_BUGS + NUM_SEARCH_TESTS + OR_BROKEN + OR_SKIP + SKIP_FIELDS + SUBSTR_SIZE + TESTS + TESTS_PER_RUN + USER_FIELDS +); + +# Bug 1 is designed to be found by all the "equals" tests. It has +# multiple values for several fields where other fields only have +# one value. +# +# Bug 2 and 3 have a dependency relationship with Bug 1, +# but show up in "not equals" tests. We do use bug 2 in multiple-value +# tests. +# +# Bug 4 should never show up in any equals test, and has no relationship +# with any other bug. However, it does have all its fields set. +# +# Bug 5 only has values set for mandatory fields, to expose problems +# that happen with "not equals" tests failing to catch bugs that don't +# have a value set at all. +# +# Bug 6 is a clone of Bug 1, but is in a group that the searcher isn't +# in. +use constant NUM_BUGS => 6; + +# How many tests there are for each operator/field combination other +# than the "contains" tests. +use constant NUM_SEARCH_TESTS => 3; +# This is how many tests get run for each field/operator. +use constant TESTS_PER_RUN => NUM_SEARCH_TESTS + NUM_BUGS; + +# This is how many random characters we generate for most fields' names. +# (Some fields can't be this long, though, so they have custom lengths +# in Bugzilla::Test::Search). +use constant FIELD_SIZE => 30; + +# These are the custom fields that are created if the BZ_MODIFY_DATABASE_TESTS +# environment variable is set. +use constant CUSTOM_FIELDS => { + FIELD_TYPE_FREETEXT, 'cf_freetext', + FIELD_TYPE_SINGLE_SELECT, 'cf_single_select', + FIELD_TYPE_MULTI_SELECT, 'cf_multi_select', + FIELD_TYPE_TEXTAREA, 'cf_textarea', + FIELD_TYPE_DATETIME, 'cf_datetime', + FIELD_TYPE_BUG_ID, 'cf_bugid', +}; + +# This translates fielddefs names into Search column names. +use constant COLUMN_TRANSLATION => { + creation_ts => 'opendate', + delta_ts => 'changeddate', + work_time => 'actual_time', +}; + +# Make comment field names to their Bugzilla::Comment accessor. +use constant COMMENT_FIELDS => { + longdesc => 'body', + work_time => 'work_time', + commenter => 'author', + 'longdescs.isprivate' => 'is_private', +}; + +# Same as above, for Bugzilla::Attachment. +use constant ATTACHMENT_FIELDS => { + mimetype => 'contenttype', + submitter => 'attacher', + thedata => 'data', +}; + +# Same, for Bugzilla::Flag. +use constant FLAG_FIELDS => { + 'flagtypes.name' => 'name', + 'setters.login_name' => 'setter', + 'requestees.login_name' => 'requestee', +}; + +# These are fields that we don't test. Test::More will mark these +# "TODO & SKIP", and not run tests for them at all. +# +# attachments.isurl can't easily be supported by us, but it's basically +# identical to isprivate and isobsolete for searching, so that's not a big +# loss. +# +# We don't support days_elapsed or owner_idle_time yet. +use constant SKIP_FIELDS => qw( + attachments.isurl + owner_idle_time + days_elapsed +); + +# During OR tests, we skip these fields. They basically just don't work +# right in OR tests, and it's too much work to document the exact tests +# that they cause to fail. +use constant OR_SKIP => qw( + percentage_complete + flagtypes.name +); + +# All the fields that represent users. +use constant USER_FIELDS => qw( + assigned_to + reporter + qa_contact + commenter + attachments.submitter + setters.login_name + requestees.login_name cc +); + +# For the "substr"-type searches, how short of a substring should +# we use? +use constant SUBSTR_SIZE => 20; +# However, for some fields, we use a different size. +use constant FIELD_SUBSTR_SIZE => { + alias => 12, + bug_file_loc => 30, + # Just the month and day. + deadline => -5, + creation_ts => -8, + delta_ts => -8, + work_time => 3, + remaining_time => 3, + see_also => 30, + target_milestone => 12, +}; + +################ +# Known Broken # +################ + +# See the KNOWN_BROKEN constant for a general description of these +# "_BROKEN" constants. + +# Search.pm currently enforces "this must be a 0 or 1" in situations +# where it should not, with two of the attachment booleans. +use constant ATTACHMENT_BOOLEANS_SEARCH_BROKEN => ( + 'attachments.ispatch' => { search => 1 }, + 'attachments.isobsolete' => { search => 1 }, +); + +# Sometimes the search for attachment booleans works, but then contains +# the wrong results, because it does not contain bugs that fully lack +# attachments. +use constant ATTACHMENT_BOOLEANS_CONTAINS_BROKEN => ( + 'attachments.isobsolete' => { contains => [5] }, + 'attachments.ispatch' => { contains => [5] }, + 'attachments.isprivate' => { contains => [5] }, +); + +# Certain fields fail all the "negative" search tests: +# +# Blocked and Dependson "notequals" only finds bugs that have +# values for the field, but where the dependency list doesn't contain +# the bug you listed. It doesn't find bugs that fully lack values for +# the fields, as it should. +# +# cc "not" matches if any CC'ed user matches, and it fails to match +# if there are no CCs on the bug. +# +# bug_group notequals doesn't find bugs that fully lack groups, +# and matches if there is one group that isn't equal. +# +# bug_file_loc can be NULL, so it gets missed by the normal +# notequals search. +# +# keywords & longdescs "notequals" match if *any* of the values +# are not equal to the string provided. Also, keywords fails to match +# if there are no keywords on the bug. +# +# attachments.* notequals doesn't find bugs that lack attachments. +# +# deadline notequals does not find bugs that lack deadlines +# +# setters notequal doesn't find bugs that fully lack flags. +# (maybe this is OK?) +# +# requestees.login_name doesn't find bugs that fully lack requestees. +use constant NEGATIVE_BROKEN => ( + ATTACHMENT_BOOLEANS_CONTAINS_BROKEN, + 'attach_data.thedata' => { contains => [5] }, + 'attachments.description' => { contains => [5] }, + 'attachments.filename' => { contains => [5] }, + 'attachments.mimetype' => { contains => [5] }, + 'attachments.submitter' => { contains => [5] }, + blocked => { contains => [3,4,5] }, + bug_file_loc => { contains => [5] }, + bug_group => { contains => [1,5] }, + cc => { contains => [1,5] }, + deadline => { contains => [5] }, + dependson => { contains => [2,4,5] }, + keywords => { contains => [1,5] }, + longdesc => { contains => [1] }, + 'longdescs.isprivate' => { contains => [1] }, + percentage_complete => { contains => [1] }, + 'requestees.login_name' => { contains => [3,4,5] }, + 'setters.login_name' => { contains => [5] }, + work_time => { contains => [1] }, + # Custom fields are busted because they can be NULL. + FIELD_TYPE_FREETEXT, { contains => [5] }, + FIELD_TYPE_BUG_ID, { contains => [5] }, + FIELD_TYPE_DATETIME, { contains => [5] }, + FIELD_TYPE_TEXTAREA, { contains => [5] }, +); + +# Shared between greaterthan and greaterthaneq. +# +# As with other fields, longdescs greaterthan matches if any comment +# matches (which might be OK). +# +# Same for keywords, bug_group, and cc. Logically, all of these might +# be OK, but it makes the operation not the logical reverse of +# lessthaneq. What we're really saying here by marking these broken +# is that there ought to be some way of searching "all ccs" vs "any cc" +# (and same for the other fields). +use constant GREATERTHAN_BROKEN => ( + bug_group => { contains => [1] }, + cc => { contains => [1] }, + keywords => { contains => [1] }, + longdesc => { contains => [1] }, + FIELD_TYPE_MULTI_SELECT, { contains => [1] }, +); + +# allwords and allwordssubstr have these broken tests in common. +# +# allwordssubstr work_time only matches against a single comment, +# instead of matching against all comments on a bug. Same is true +# for the other longdesc fields, cc, keywords, and bug_group. +# +# percentage_complete just drops in 0=0 for the term. +use constant ALLWORDS_BROKEN => ( + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + bug_group => { contains => [1] }, + cc => { contains => [1] }, + keywords => { contains => [1] }, + longdesc => { contains => [1] }, + work_time => { contains => [1] }, + percentage_complete => { contains => [2,3,4,5] }, +); + +# nowords and nowordssubstr have these broken tests in common. +# +# flagtypes.name doesn't match bugs without flags. +# cc, keywords, longdescs.isprivate, and bug_group actually work properly in +# terms of excluding bug 1 (since we exclude all values in the search, +# on our test), but still fail at including bug 5. +# The longdesc* and work_time fields, coincidentally, work completely +# correctly, possibly because there's only one comment on bug 5. +use constant NOWORDS_BROKEN => ( + NEGATIVE_BROKEN, + 'flagtypes.name' => { contains => [5] }, + bug_group => { contains => [5] }, + cc => { contains => [5] }, + keywords => { contains => [5] }, + longdesc => {}, + work_time => {}, + 'longdescs.isprivate' => {}, +); + +# Fields that don't generally work at all with changed* searches, but +# probably should. +use constant CHANGED_BROKEN => ( + classification => { contains => [1] }, + commenter => { contains => [1] }, + percentage_complete => { contains => [2,3,4,5] }, + 'requestees.login_name' => { contains => [1] }, + 'setters.login_name' => { contains => [1] }, + delta_ts => { contains => [1] }, +); + +# These are additional broken tests that changedfrom and changedto +# have in common. +use constant CHANGED_VALUE_BROKEN => ( + bug_group => { contains => [1] }, + cc => { contains => [1] }, + estimated_time => { contains => [1] }, + 'flagtypes.name' => { contains => [1] }, + keywords => { contains => [1] }, + work_time => { contains => [1] }, + FIELD_TYPE_MULTI_SELECT, { contains => [1] }, +); + + +# Any test listed in KNOWN_BROKEN gets marked TODO by Test::More +# (using some complex code in Bugzilla::Test::Seach::FieldTest). +# This means that if you run the test under "prove -v", these tests will +# still show up as "not ok", but the test suite results won't show them +# as a failure. +# +# This constant contains operators as keys, which point to hashes. The hashes +# have field names as keys. Each field name points to a hash describing +# how that field/operator combination is broken. The "contains" +# array specifies that that particular "contains" test is expected +# to fail. If "search" is set to 1, then we expect the creation of the +# Bugzilla::Search object to fail. +# +# To allow handling custom fields, you can also use the field type as a key +# instead of the field name. Specifying explicit field names always overrides +# specifying a field type. +# +# Sometimes the operators have multiple tests, and one of them works +# while the other fails. In this case, we have a special override for +# "operator-value", which uniquely identifies tests. +use constant KNOWN_BROKEN => { + notequals => { NEGATIVE_BROKEN }, + # percentage_complete substring matches every bug, regardless of + # its percentage_complete value. + substring => { + percentage_complete => { contains => [2,3,4,5] }, + }, + casesubstring => { + percentage_complete => { contains => [2,3,4,5] }, + }, + notsubstring => { NEGATIVE_BROKEN }, + + # Attachment noolean fields don't work with regexes, right now, + # because they throw an error that regexes are not valid booleans. + 'regexp-^1-' => { ATTACHMENT_BOOLEANS_SEARCH_BROKEN }, + + # percentage_complete notregexp fails to match bugs that + # fully lack hours worked. + notregexp => { + NEGATIVE_BROKEN, + percentage_complete => { contains => [5] }, + }, + 'notregexp-^1-' => { ATTACHMENT_BOOLEANS_SEARCH_BROKEN }, + + # percentage_complete doesn't match bugs with 0 hours worked or remaining. + # + # longdescs.isprivate matches if any comment matches, instead of if all + # comments match. Same for longdescs and work_time. (Commenter is probably + # also broken in this way, but all our comments come from the same user.) + # Also, the attachments ones don't find bugs that have no attachments + # at all (which might be OK?). + # + # attachments.isprivate lessthan doesn't find bugs without attachments. + lessthan => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + 'attachments.isprivate' => { contains => [5] }, + 'longdescs.isprivate' => { contains => [1] }, + percentage_complete => { contains => [5] }, + work_time => { contains => [1,2,3,4] }, + }, + # The lessthaneq tests are broken for the same reasons, but they work + # slightly differently so they have a different set of broken tests. + lessthaneq => { + ATTACHMENT_BOOLEANS_CONTAINS_BROKEN, + 'longdescs.isprivate' => { contains => [1] }, + work_time => { contains => [2,3,4] }, + }, + + greaterthan => { GREATERTHAN_BROKEN }, + + # percentage_complete is broken -- it won't match equal values. + greaterthaneq => { + GREATERTHAN_BROKEN, + percentage_complete => { contains => [2] }, + }, + + # percentage_complete just throws 0=0 into the search term, returning + # all bugs. + anyexact => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + percentage_complete => { contains => [3,4,5] }, + }, + # bug_group anywordssubstr returns all our bugs. Not sure why. + anywordssubstr => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + percentage_complete => { contains => [3,4,5] }, + bug_group => { contains => [3,4,5] }, + }, + + 'allwordssubstr-<1>' => { ALLWORDS_BROKEN }, + 'allwordssubstr-<1>,<2>' => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + percentage_complete => { contains => [1,2,3,4,5] }, + }, + # flagtypes.name does not work here, probably because they all try to + # match against a single flag. + # Same for attach_data.thedata. + 'allwords-<1>' => { + ALLWORDS_BROKEN, + 'attach_data.thedata' => { contains => [1] }, + 'flagtypes.name' => { contains => [1] }, + }, + 'allwords-<1> <2>' => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + percentage_complete => { contains => [1,2,3,4,5] }, + }, + + nowordssubstr => { NOWORDS_BROKEN }, + # attach_data.thedata doesn't match properly with any of the plain + # "words" searches. Also, bug 5 doesn't match because it lacks + # attachments. + nowords => { + NOWORDS_BROKEN, + 'attach_data.thedata' => { contains => [1,5] }, + }, + + # anywords searches don't work on decimal values. + # bug_group anywords returns all bugs. + # attach_data doesn't work (perhaps because it's the entire + # data, or some problem with the regex?). + anywords => { + ATTACHMENT_BOOLEANS_SEARCH_BROKEN, + 'attach_data.thedata' => { contains => [1] }, + bug_group => { contains => [2,3,4,5] }, + percentage_complete => { contains => [2,3,4,5] }, + work_time => { contains => [1] }, + }, + 'anywords-<1> <2>' => { + bug_group => { contains => [3,4,5] }, + percentage_complete => { contains => [3,4,5] }, + 'attach_data.thedata' => { contains => [1,2] }, + work_time => { contains => [1,2] }, + }, + + # setters.login_name and requestees.login name aren't tracked individually + # in bugs_activity, so can't be searched using this method. + # + # percentage_complete isn't tracked in bugs_activity (and it would be + # really hard to track). However, it adds a 0=0 term instead of using + # the changed* charts or simply denying them. + # + # delta_ts changedbefore/after should probably search for bugs based + # on their delta_ts. + # + # creation_ts changedbefore/after should search for bug creation dates. + # + # The commenter field changedbefore/after should search for comment + # creation dates. + # + # classification isn't being tracked properly in bugs_activity, I think. + # + # attach_data.thedata should search when attachments were created and + # who they were created by. + 'changedbefore' => { + CHANGED_BROKEN, + 'attach_data.thedata' => { contains => [1] }, + creation_ts => { contains => [1,5] }, + # attachments.* finds values where the date matches exactly. + 'attachments.description' => { contains => [2] }, + 'attachments.filename' => { contains => [2] }, + 'attachments.isobsolete' => { contains => [2] }, + 'attachments.ispatch' => { contains => [2] }, + 'attachments.isprivate' => { contains => [2] }, + 'attachments.mimetype' => { contains => [2] }, + }, + 'changedafter' => { + 'attach_data.thedata' => { contains => [2,3,4] }, + classification => { contains => [2,3,4] }, + commenter => { contains => [2,3,4] }, + creation_ts => { contains => [2,3,4] }, + delta_ts => { contains => [2,3,4] }, + percentage_complete => { contains => [1,5] }, + 'requestees.login_name' => { contains => [2,3,4] }, + 'setters.login_name' => { contains => [2,3,4] }, + }, + changedfrom => { + CHANGED_BROKEN, + CHANGED_VALUE_BROKEN, + # All fields should have a way to search for "changing + # from a blank value" probably. + blocked => { contains => [1] }, + dependson => { contains => [1] }, + FIELD_TYPE_BUG_ID, { contains => [1] }, + }, + # changeto doesn't find work_time changes (probably due to decimal/string + # stuff). Same for remaining_time and estimated_time. + # + # multi-valued fields are stored as comma-separated strings, so you + # can't do changedfrom/to on them. + # + # Perhaps commenter can either tell you who the last commenter was, + # or if somebody commented at a given time (combined with other + # charts). + # + # longdesc changedto/from doesn't do anything; maybe it should. + # Same for attach_data.thedata. + changedto => { + CHANGED_BROKEN, + CHANGED_VALUE_BROKEN, + 'attach_data.thedata' => { contains => [1] }, + longdesc => { contains => [1] }, + remaining_time => { contains => [1] }, + }, + changedby => { + CHANGED_BROKEN, + # This should probably search the attacher or anybody who changed + # anything about an attachment at all. + 'attach_data.thedata' => { contains => [1] }, + # This should probably search the reporter. + creation_ts => { contains => [1] }, + }, +}; + +############# +# Overrides # +############# + +# These overrides are used in the TESTS constant, below. + +# Regex tests need unique test values for certain fields. +use constant REGEX_OVERRIDE => { + 'attachments.mimetype' => { value => '^text/x-1-' }, + bug_file_loc => { value => '^http://1-' }, + see_also => { value => '^http://1-' }, + blocked => { value => '^<1>$' }, + dependson => { value => '^<1>$' }, + bug_id => { value => '^<1>$' }, + 'attachments.isprivate' => { value => '^1' }, + cclist_accessible => { value => '^1' }, + reporter_accessible => { value => '^1' }, + everconfirmed => { value => '^1' }, + 'longdescs.isprivate' => { value => '^1' }, + creation_ts => { value => '^2037-01-01' }, + delta_ts => { value => '^2037-01-01' }, + deadline => { value => '^2037-02-01' }, + estimated_time => { value => '^1.0' }, + remaining_time => { value => '^9.0' }, + work_time => { value => '^1.0' }, + longdesc => { value => '^1-' }, + percentage_complete => { value => '^10.0' }, + FIELD_TYPE_BUG_ID, { value => '^<1>$' }, + FIELD_TYPE_DATETIME, { value => '^2037-03-01' } +}; + +# Common overrides between lessthan and lessthaneq. +use constant LESSTHAN_OVERRIDE => ( + alias => { contains => [1,5] }, + estimated_time => { contains => [1,5] }, + qa_contact => { contains => [1,5] }, + resolution => { contains => [1,5] }, + status_whiteboard => { contains => [1,5] }, + target_milestone => { contains => [1,5] }, +); + +# The mandatorily-set fields have values higher than <1>, +# so bug 5 shows up. +use constant GREATERTHAN_OVERRIDE => ( + classification => { contains => [2,3,4,5] }, + assigned_to => { contains => [2,3,4,5] }, + bug_id => { contains => [2,3,4,5] }, + bug_severity => { contains => [2,3,4,5] }, + bug_status => { contains => [2,3,4,5] }, + component => { contains => [2,3,4,5] }, + commenter => { contains => [2,3,4,5] }, + op_sys => { contains => [2,3,4,5] }, + priority => { contains => [2,3,4,5] }, + product => { contains => [2,3,4,5] }, + reporter => { contains => [2,3,4,5] }, + rep_platform => { contains => [2,3,4,5] }, + short_desc => { contains => [2,3,4,5] }, + version => { contains => [2,3,4,5] }, + # Bug 2 is the only bug besides 1 that has a Requestee set. + 'requestees.login_name' => { contains => [2] }, + FIELD_TYPE_SINGLE_SELECT, { contains => [2,3,4,5] }, + # Override SINGLE_SELECT for resolution. + resolution => { contains => [2,3,4] }, +); + +# For all positive multi-value types. +use constant MULTI_BOOLEAN_OVERRIDE => ( + 'attachments.ispatch' => { value => '1,1', contains => [1] }, + 'attachments.isobsolete' => { value => '1,1', contains => [1] }, + 'attachments.isprivate' => { value => '1,1', contains => [1] }, + cclist_accessible => { value => '1,1', contains => [1] }, + reporter_accessible => { value => '1,1', contains => [1] }, + 'longdescs.isprivate' => { value => '1,1', contains => [1] }, + everconfirmed => { value => '1,1', contains => [1] }, +); + +# Same as above, for negative multi-value types. +use constant NEGATIVE_MULTI_BOOLEAN_OVERRIDE => ( + 'attachments.ispatch' => { value => '1,1', contains => [2,3,4,5] }, + 'attachments.isobsolete' => { value => '1,1', contains => [2,3,4,5] }, + 'attachments.isprivate' => { value => '1,1', contains => [2,3,4,5] }, + cclist_accessible => { value => '1,1', contains => [2,3,4,5] }, + reporter_accessible => { value => '1,1', contains => [2,3,4,5] }, + 'longdescs.isprivate' => { value => '1,1', contains => [2,3,4,5] }, + everconfirmed => { value => '1,1', contains => [2,3,4,5] }, +); + +# For anyexact and anywordssubstr +use constant ANY_OVERRIDE => ( + 'work_time' => { value => '1.0,2.0' }, + dependson => { value => '<1>,<3>', contains => [1,3] }, + MULTI_BOOLEAN_OVERRIDE, +); + +# For all the changed* searches. The ones that have empty contains +# are fields that never change in value, or will never be rationally +# tracked in bugs_activity. +use constant CHANGED_OVERRIDE => ( + 'attachments.submitter' => { contains => [] }, + bug_id => { contains => [] }, + reporter => { contains => [] }, +); + +######### +# Tests # +######### + +# The basic format of this is a hashref, where the keys are operators, +# and each operator has an arrayref of tests that it runs. The tests +# are hashrefs, with the following possible keys: +# +# contains: This is a list of bug numbers that the search is expected +# to contain. (This is bug numbers, like 1,2,3, not the bug +# ids. For a description of each bug number, see NUM_BUGS.) +# Any bug not listed in "contains" must *not* show up in the +# search result. +# value: The value that you're searching for. There are certain special +# codes that will be replaced with bug values when the tests are +# run. In these examples below, "#" indicates a bug number: +# +# <#> - The field value for this bug. +# +# For any operator that has the string "word" in it, this is +# *all* the values for the current field from the numbered bug, +# joined by a space. +# +# If the operator has the string "substr" in it, then we +# take a substring of the value (for single-value searches) +# or we take a substring of each value and join them (for +# multi-value "word" searches). The length of the substring +# is determined by the SUBSTR_SIZE constants above.) +# +# For other operators, this just becomes the first value from +# the field for the numbered bug. +# +# So, if we were running the "equals" test and checking the +# cc field, <1> would become the login name of the first cc on +# Bug 1. If we did an "anywords" search test, it would become +# a space-separated string of the login names of all the ccs +# on Bug 1. If we did an "anywordssubstr" search test, it would +# become a space-separated string of the first few characters +# of each CC's login name on Bug 1. +# +# <#-id> - The bug id of the numbered bug. +# <#-reporter> - The login name of the numbered bug's reporter. +# <#-delta> - The delta_ts of the numbered bug. +# +# escape: If true, we will call quotemeta() on the value immediately +# before passing it to Search.pm. +# +# transform: A function to call on any field value before inserting +# it for a <#> replacement. The transformation function +# gets all of the bug's values for the field as its arguments. +# if_equal: This allows you to override "contains" for the case where +# the transformed value (from calling the "transform" function) +# is equal to the original value. +# +# override: This allows you to override "contains" and "values" for +# certain fields. +use constant TESTS => { + equals => [ + { contains => [1], value => '<1>' }, + ], + notequals => [ + { contains => [2,3,4,5], value => '<1>' }, + ], + substring => [ + { contains => [1], value => '<1>' }, + ], + casesubstring => [ + { contains => [1], value => '<1>' }, + { contains => [], value => '<1>', transform => sub { lc($_[0]) }, + extra_name => 'lc', if_equal => { contains => [1] } }, + ], + notsubstring => [ + { contains => [2,3,4,5], value => '<1>' }, + ], + regexp => [ + { contains => [1], value => '<1>', escape => 1 }, + { contains => [1], value => '^1-', override => REGEX_OVERRIDE }, + ], + notregexp => [ + { contains => [2,3,4,5], value => '<1>', escape => 1 }, + { contains => [2,3,4,5], value => '^1-', override => REGEX_OVERRIDE }, + ], + lessthan => [ + { contains => [1], value => 2, + override => { + # A lot of these contain bug 5 because an empty value is validly + # less than the specified value. + bug_file_loc => { value => 'http://2-' }, + see_also => { value => 'http://2-' }, + 'attachments.mimetype' => { value => 'text/x-2-' }, + blocked => { value => '<4-id>', contains => [1,2] }, + dependson => { value => '<3-id>', contains => [1,3] }, + bug_id => { value => '<2-id>' }, + 'attachments.isprivate' => { value => 1, contains => [2,3,4,5] }, + cclist_accessible => { value => 1, contains => [2,3,4,5] }, + reporter_accessible => { value => 1, contains => [2,3,4,5] }, + 'longdescs.isprivate' => { value => 1, contains => [2,3,4,5] }, + everconfirmed => { value => 1, contains => [2,3,4,5] }, + creation_ts => { value => '2037-01-02', contains => [1,5] }, + delta_ts => { value => '2037-01-02', contains => [1,5] }, + deadline => { value => '2037-02-02' }, + remaining_time => { value => 10, contains => [1,5] }, + percentage_complete => { value => 11, contains => [1,5] }, + longdesc => { value => '2-', contains => [1,5] }, + work_time => { value => 1, contains => [5] }, + FIELD_TYPE_BUG_ID, { value => '<2>' }, + FIELD_TYPE_DATETIME, { value => '2037-03-02' }, + LESSTHAN_OVERRIDE, + } + }, + ], + lessthaneq => [ + { contains => [1], value => '<1>', + override => { + 'attachments.ispatch' => { value => 0, contains => [2,3,4,5] }, + 'attachments.isobsolete' => { value => 0, contains => [2,3,4,5] }, + 'attachments.isprivate' => { value => 0, contains => [2,3,4,5] }, + cclist_accessible => { value => 0, contains => [2,3,4,5] }, + reporter_accessible => { value => 0, contains => [2,3,4,5] }, + 'longdescs.isprivate' => { value => 0, contains => [2,3,4,5] }, + everconfirmed => { value => 0, contains => [2,3,4,5] }, + blocked => { contains => [1,2] }, + dependson => { contains => [1,3] }, + creation_ts => { contains => [1,5] }, + delta_ts => { contains => [1,5] }, + remaining_time => { contains => [1,5] }, + longdesc => { contains => [1,5] }, + work_time => { value => 1, contains => [1,5] }, + LESSTHAN_OVERRIDE, + }, + }, + ], + greaterthan => [ + { contains => [2,3,4], value => '<1>', + override => { + dependson => { contains => [3] }, + blocked => { contains => [2] }, + 'attachments.ispatch' => { value => 0, contains => [1] }, + 'attachments.isobsolete' => { value => 0, contains => [1] }, + 'attachments.isprivate' => { value => 0, contains => [1] }, + cclist_accessible => { value => 0, contains => [1] }, + reporter_accessible => { value => 0, contains => [1] }, + 'longdescs.isprivate' => { value => 0, contains => [1] }, + everconfirmed => { value => 0, contains => [1] }, + GREATERTHAN_OVERRIDE, + }, + }, + ], + greaterthaneq => [ + { contains => [2,3,4], value => '<2>', + override => { + 'attachments.ispatch' => { value => 1, contains => [1] }, + 'attachments.isobsolete' => { value => 1, contains => [1] }, + 'attachments.isprivate' => { value => 1, contains => [1] }, + cclist_accessible => { value => 1, contains => [1] }, + reporter_accessible => { value => 1, contains => [1] }, + 'longdescs.isprivate' => { value => 1, contains => [1] }, + everconfirmed => { value => 1, contains => [1] }, + dependson => { contains => [1,3] }, + blocked => { contains => [1,2] }, + GREATERTHAN_OVERRIDE, + } + }, + ], + matches => [ + { contains => [1], value => '<1>' }, + ], + notmatches => [ + { contains => [2,3,4,5], value => '<1>' }, + ], + anyexact => [ + { contains => [1,2], value => '<1>,<2>', + override => { ANY_OVERRIDE } }, + ], + anywordssubstr => [ + { contains => [1,2], value => '<1> <2>', + override => { ANY_OVERRIDE } }, + ], + allwordssubstr => [ + { contains => [1], value => '<1>', + override => { MULTI_BOOLEAN_OVERRIDE } }, + { contains => [], value => '<1>,<2>' }, + ], + nowordssubstr => [ + { contains => [2,3,4,5], value => '<1>', + override => { + # longdescs.isprivate translates to "1 0", so no bugs should + # show up. + 'longdescs.isprivate' => { contains => [] }, + # 1.0 0.0 exludes bug 5. + # XXX However, it also shouldn't match 2, 3, or 4, because + # they contain at least one comment with 0.0 work_time. + work_time => { contains => [2,3,4] }, + } + }, + ], + anywords => [ + { contains => [1], value => '<1>', + override => { + MULTI_BOOLEAN_OVERRIDE, + work_time => { value => '1.0', contains => [1] }, + } + }, + { contains => [1,2], value => '<1> <2>', + override => { + MULTI_BOOLEAN_OVERRIDE, + dependson => { value => '<1> <3>', contains => [1,3] }, + work_time => { value => '1.0 2.0' }, + }, + }, + ], + allwords => [ + { contains => [1], value => '<1>', + override => { MULTI_BOOLEAN_OVERRIDE } }, + { contains => [], value => '<1> <2>' }, + ], + nowords => [ + { contains => [2,3,4,5], value => '<1>', + override => { + # longdescs.isprivate translates to "1 0", so no bugs should + # show up. + 'longdescs.isprivate' => { contains => [] }, + # 1.0 0.0 exludes bug 5. + # XXX However, it also shouldn't match 2, 3, or 4, because + # they contain at least one comment with 0.0 work_time. + work_time => { contains => [2,3,4] }, + } + }, + ], + + changedbefore => [ + { contains => [1], value => '<2-delta>', + override => { + CHANGED_OVERRIDE, + creation_ts => { contains => [1,5] }, + blocked => { contains => [1,2] }, + dependson => { contains => [1,3] }, + longdesc => { contains => [1,2,5] }, + } + }, + ], + changedafter => [ + { contains => [2,3,4], value => '<1-delta>', + override => { + CHANGED_OVERRIDE, + creation_ts => { contains => [2,3,4] }, + # We only change this for one bug, and it doesn't match. + 'longdescs.isprivate' => { contains => [] }, + # Same for everconfirmed. + 'everconfirmed' => { contains => [] }, + # For blocked and dependson, they have the delta_ts of bug1 + # in the bugs_activity table, so they won't ever match. + blocked => { contains => [] }, + dependson => { contains => [] }, + } + }, + ], + changedfrom => [ + { contains => [1], value => '<1>', + override => { + CHANGED_OVERRIDE, + # longdesc changedfrom doesn't make any sense. + longdesc => { contains => [] }, + # Nor does creation_ts changedfrom. + creation_ts => { contains => [] }, + 'attach_data.thedata' => { contains => [] }, + }, + }, + ], + changedto => [ + { contains => [1], value => '<1>', + override => { + CHANGED_OVERRIDE, + # I can't imagine any use for creation_ts changedto. + creation_ts => { contains => [] }, + } + }, + ], + changedby => [ + { contains => [1], value => '<1-reporter>', + override => { + CHANGED_OVERRIDE, + blocked => { contains => [1,2] }, + dependson => { contains => [1,3] }, + }, + }, + ], +}; + +# Fields that do not behave as we expect, for InjectionTest. +# search => 1 means the Bugzilla::Search creation fails. +# sql_error is a regex that specifies a SQL error that's OK for us to throw. +# operator_ok overrides the "brokenness" of certain operators, so that they +# are always OK for that field/operator combination. +use constant INJECTION_BROKEN_FIELD => { + 'attachments.isobsolete' => { search => 1 }, + 'attachments.ispatch' => { search => 1 }, + owner_idle_time => { + sql_error => qr/bugs\.owner_idle_time.+where clause/, + operator_ok => [qw(changedfrom changedto greaterthan greaterthaneq + lessthan lessthaneq)] + }, + keywords => { + search => 1, + operator_ok => [qw(allwordssubstr anywordssubstr casesubstring + changedfrom changedto greaterthan greaterthaneq + lessthan lessthaneq notregexp notsubstring + nowordssubstr regexp substring)] + }, +}; + +# Operators that do not behave as we expect, for InjectionTest. +# search => 1 means the Bugzilla::Search creation fails, but +# field_ok contains fields that it does actually succeed for. +use constant INJECTION_BROKEN_OPERATOR => { + changedafter => { search => 1, field_ok => ['percentage_complete'] }, + changedbefore => { search => 1, field_ok => ['percentage_complete'] }, + changedby => { search => 1, field_ok => ['percentage_complete'] }, +}; + +# Tests run by Bugzilla::Test::Search::InjectionTest. +# We have to make sure the values are all one word or they'll be split +# up by the multi-word tests. +use constant INJECTION_TESTS => ( + { value => ';SEMICOLON_TEST' }, + { value => '--COMMENT_TEST' }, + { value => "'QUOTE_TEST" }, + { value => "';QUOTE_SEMICOLON_TEST" }, + { value => '/*STAR_COMMENT_TEST' } +); + +# This overrides KNOWN_BROKEN for OR configurations. +# It indicates that these combinations are broken in some way that they +# aren't broken when alone, because they don't return what they logically +# should when put into an OR. +use constant OR_BROKEN => { + # Multi-value fields search on individual values, so "equals" OR "notequals" + # returns nothing, when it should instead logically return everything. + 'blocked-equals' => { + 'blocked-notequals' => { contains => [1,2,3,4,5] }, + }, + 'dependson-equals' => { + 'dependson-notequals' => { contains => [1,2,3,4,5] }, + }, + 'bug_group-equals' => { + 'bug_group-notequals' => { contains => [1,2,3,4,5] }, + }, + 'cc-equals' => { + 'cc-notequals' => { contains => [1,2,3,4,5] }, + }, + 'commenter-equals' => { + 'commenter-notequals' => { contains => [1,2,3,4,5] }, + 'longdesc-notequals' => { contains => [2,3,4,5] }, + 'longdescs.isprivate-notequals' => { contains => [2,3,4,5] }, + 'work_time-notequals' => { contains => [2,3,4,5] }, + }, + 'commenter-notequals' => { + 'commenter-equals' => { contains => [1,2,3,4,5] }, + 'longdesc-equals' => { contains => [1] }, + 'longdescs.isprivate-equals' => { contains => [1] }, + 'work_time-equals' => { contains => [1] }, + }, +}; + +1; diff --git a/xt/lib/Bugzilla/Test/Search/FakeCGI.pm b/xt/lib/Bugzilla/Test/Search/FakeCGI.pm new file mode 100644 index 000000000..e20a57daf --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/FakeCGI.pm @@ -0,0 +1,61 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# Calling CGI::param over and over turned out to be one of the slowest +# parts of search.t. So we create a simpler thing here that just supports +# "param" in a fast way. +package Bugzilla::Test::Search::FakeCGI; + +sub new { + my ($class) = @_; + return bless {}, $class; +} + +sub param { + my ($self, $name, @values) = @_; + if (!defined $name) { + return keys %$self; + } + + if (@values) { + if (ref $values[0] eq 'ARRAY') { + $self->{$name} = $values[0]; + } + else { + $self->{$name} = \@values; + } + } + + return () if !exists $self->{$name}; + + my $item = $self->{$name}; + return wantarray ? @{ $item || [] } : $item->[0]; +} + +sub delete { + my ($self, $name) = @_; + delete $self->{$name}; +} + +# We don't need to do this, because we don't use old params in search.t. +sub convert_old_params {} + +1; \ No newline at end of file diff --git a/xt/lib/Bugzilla/Test/Search/FieldTest.pm b/xt/lib/Bugzilla/Test/Search/FieldTest.pm new file mode 100644 index 000000000..4c43e34ed --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/FieldTest.pm @@ -0,0 +1,564 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This module represents the tests that get run on a single +# operator/field combination for Bugzilla::Test::Search. +# This is where all the actual testing happens. +package Bugzilla::Test::Search::FieldTest; + +use strict; +use warnings; +use Bugzilla::Test::Search::FakeCGI; +use Bugzilla::Search; +use Bugzilla::Test::Search::Constants; + +use Data::Dumper; +use Scalar::Util qw(blessed); +use Test::More; +use Test::Exception; + +############### +# Constructor # +############### + +sub new { + my ($class, $operator_test, $field, $test) = @_; + return bless { operator_test => $operator_test, + field_object => $field, + raw_test => $test }, $class; +} + +############# +# Accessors # +############# + +sub num_tests { return TESTS_PER_RUN } + +# The Bugzilla::Test::Search::OperatorTest that this is a child of. +sub operator_test { return $_[0]->{operator_test} } +# The Bugzilla::Field being tested. +sub field_object { return $_[0]->{field_object} } +# The name of the field being tested, which we need much more often +# than we need the object. +sub field { + my ($self) = @_; + return $self->{field_name} ||= $self->field_object->name; + return $self->{field_name}; +} +# The Bugzilla::Test::Search object that this is a child of. +sub search_test { return $_[0]->operator_test->search_test } +# The operator being tested +sub operator { return $_[0]->operator_test->operator } +# The bugs currently being tested by Bugzilla::Test::Search. +sub bugs { return $_[0]->search_test->bugs } +sub bug { + my $self = shift; + return $self->search_test->bug(@_); +} + +# The name displayed for this test by Test::More. Used in test descriptions. +sub name { + my ($self) = @_; + my $field = $self->field; + my $operator = $self->operator; + my $value = $self->main_value; + + my $name = "$field-$operator-$value"; + if (my $extra_name = $self->test->{extra_name}) { + $name .= "-$extra_name"; + } + return $name; +} + +# The appropriate value from the TESTS constant for this test, taking +# into account overrides. +sub test { + my $self = shift; + return $self->{test} if $self->{test}; + + my %test = %{ $self->{raw_test} }; + + # We have field name overrides... + my $override = $test{override}->{$self->field}; + # And also field type overrides. + if (!$override) { + $override = $test{override}->{$self->field_object->type} || {}; + } + + foreach my $key (%$override) { + $test{$key} = $override->{$key}; + } + + $self->{test} = \%test; + return $self->{test}; +} + +# All the values for all the bugs for this field. +sub _field_values { + my ($self) = @_; + return $self->{field_values} if $self->{field_values}; + + my %field_values; + foreach my $number (1..NUM_BUGS) { + $field_values{$number} = $self->_field_values_for_bug($number); + } + $self->{field_values} = \%field_values; + return $self->{field_values}; +} +# The values for this field for the numbered bug. +sub bug_values { + my ($self, $number) = @_; + return @{ $self->_field_values->{$number} }; +} + +# The untranslated, non-overriden value--used in the name of the test +# and other places. +sub main_value { return $_[0]->{raw_test}->{value} } +# The untranslated test value, taking into account overrides. +sub test_value { return $_[0]->test->{value} }; +# The value translated appropriately for passing to Bugzilla::Search. +sub translated_value { + my $self = shift; + if (!exists $self->{translated_value}) { + my $value = $self->search_test->value_translation_cache($self); + if (!defined $value) { + $value = $self->_translate_value(); + $self->search_test->value_translation_cache($self, $value); + } + $self->{translated_value} = $value; + } + return $self->{translated_value}; +} +# Used in failure diagnostic messages. +sub debug_value { + my ($self) = @_; + return "Value: '" . $self->translated_value . "'"; +} + +# True for a bug if we ran the "transform" function on it and the +# result was equal to its first value. +sub transformed_value_was_equal { + my ($self, $number, $value) = @_; + if (defined $value) { + $self->{transformed_value_was_equal}->{$number} = $value; + } + return $self->{transformed_value_was_equal}->{$number}; +} + +# True if this test is supposed to contain the numbered bug. +sub bug_is_contained { + my ($self, $number) = @_; + my $contains = $self->test->{contains}; + if ($self->transformed_value_was_equal($number)) { + $contains = $self->test->{if_equal}->{contains}; + } + return grep($_ == $number, @$contains) ? 1 : 0; +} + +################################################### +# Accessors: Ways of doing SKIP and TODO on tests # +################################################### + +# The tests we know are broken for this operator/field combination. +sub _known_broken { + my $self = shift; + my $field = $self->field; + my $type = $self->field_object->type; + my $operator = $self->operator; + my $value = $self->main_value; + + my $value_name = "$operator-$value"; + my $value_broken = KNOWN_BROKEN->{$value_name}->{$field}; + $value_broken ||= KNOWN_BROKEN->{$value_name}->{$type}; + return $value_broken if $value_broken; + my $operator_broken = KNOWN_BROKEN->{$operator}->{$field}; + $operator_broken ||= KNOWN_BROKEN->{$operator}->{$type}; + return $operator_broken if $operator_broken; + return {}; +} + +# True if the "contains" search for the numbered bug is broken. +# That is, either the result is supposed to contain it and doesn't, +# or the result is not supposed to contain it and does. +sub contains_known_broken { + my ($self, $number) = @_; + my $field = $self->field; + my $operator = $self->operator; + + my $contains_broken = $self->_known_broken->{contains} || []; + if (grep($_ == $number, @$contains_broken)) { + return "$field $operator contains $number is known to be broken"; + } + return undef; +} + +# Returns a string if creating a Bugzilla::Search object throws an error, +# with this field/operator/value combination. +sub search_known_broken { + my ($self) = @_; + my $field = $self->field; + my $operator = $self->operator; + if ($self->_known_broken->{search}) { + return "Bugzilla::Search for $field $operator is known to be broken"; + } + return undef; +} + +# Returns a string if we haven't yet implemented the tests for this field, +# but we plan to in the future. +sub field_not_yet_implemented { + my ($self) = @_; + my $skip_this_field = grep { $_ eq $self->field } SKIP_FIELDS; + if ($skip_this_field) { + my $field = $self->field; + return "$field testing not yet implemented"; + } + return undef; +} + +# Returns a message if this field/operator combination can't ever be run. +# At no time in the future will this field/operator combination ever work. +sub invalid_field_operator_combination { + my ($self) = @_; + my $field = $self->field; + my $operator = $self->operator; + + if ($field eq 'content' && $operator !~ /matches/) { + return "content field does not support $operator"; + } + elsif ($operator =~ /matches/ && $field ne 'content') { + return "matches operator does not support fields other than content"; + } + return undef; +} + +# True if this field is broken in an OR combination. +sub join_broken { + my ($self, $or_broken_map) = @_; + my $or_broken = $or_broken_map->{$self->field . '-' . $self->operator}; + if (!$or_broken) { + # See if this is a comment field, and in that case, if there's + # a generic entry for all comment fields. + my $is_comment_field = COMMENT_FIELDS->{$self->field}; + if ($is_comment_field) { + $or_broken = $or_broken_map->{'longdescs.-' . $self->operator}; + } + } + return $or_broken; +} + +######################################### +# Accessors: Bugzilla::Search Arguments # +######################################### + +# The CGI object that will get passed to Bugzilla::Search as its arguments. +sub search_params { + my $self = shift; + return $self->{search_params} if $self->{search_params}; + + my $field = $self->field; + my $operator = $self->operator; + my $value = $self->translated_value; + + my $cgi = new Bugzilla::Test::Search::FakeCGI; + $cgi->param("field0-0-0", $field); + $cgi->param('type0-0-0', $operator); + $cgi->param('value0-0-0', $value); + + $self->{search_params} = $cgi; + return $self->{search_params}; +} + +sub search_columns { + my ($self) = @_; + my $field = $self->field; + my @search_fields = qw(bug_id); + if ($self->field_object->buglist) { + my $col_name = COLUMN_TRANSLATION->{$field} || $field; + push(@search_fields, $col_name); + } + return \@search_fields; +} + + +################ +# Field Values # +################ + +sub _field_values_for_bug { + my ($self, $number) = @_; + my $field = $self->field; + + my @values; + + if ($field =~ /^attach.+\.(.+)$/ ) { + my $attach_field = $1; + $attach_field = ATTACHMENT_FIELDS->{$attach_field} || $attach_field; + @values = $self->_values_for($number, 'attachments', $attach_field); + } + elsif (my $flag_field = FLAG_FIELDS->{$field}) { + @values = $self->_values_for($number, 'flags', $flag_field); + } + elsif (my $translation = COMMENT_FIELDS->{$field}) { + @values = $self->_values_for($number, 'comments', $translation); + # We want the last value to come first, so that single-value + # searches use the last comment. + @values = reverse @values; + } + elsif ($field eq 'bug_group') { + @values = $self->_values_for($number, 'groups_in', 'name'); + } + elsif ($field eq 'keywords') { + @values = $self->_values_for($number, 'keyword_objects', 'name'); + } + elsif ($field eq 'content') { + @values = $self->_values_for($number, 'short_desc'); + } + # Bugzilla::Bug truncates creation_ts, but we need the full value + # from the database. This has no special value for changedfrom, + # because it never changes. + elsif ($field eq 'creation_ts') { + my $bug = $self->bug($number); + my $creation_ts = Bugzilla->dbh->selectrow_array( + 'SELECT creation_ts FROM bugs WHERE bug_id = ?', + undef, $bug->id); + @values = ($creation_ts); + } + else { + @values = $self->_values_for($number, $field); + } + + # We convert user objects to their login name, here, all in one + # block for simplicity. + if (grep { $_ eq $field } USER_FIELDS) { + # requestees.login_name is empty for most bugs (but checking + # blessed(undef) handles that. + # Values that come from %original_values aren't User objects. + @values = map { blessed($_) ? $_->login : $_ } @values; + @values = grep { defined $_ } @values; + } + + return \@values; +} + +sub _values_for { + my ($self, $number, $bug_field, $item_field) = @_; + + my $item; + if ($self->operator eq 'changedfrom') { + $item = $self->search_test->bug_create_value($number, $bug_field); + } + else { + my $bug = $self->bug($number); + $item = $bug->$bug_field; + } + + if ($item_field) { + if ($bug_field eq 'flags' and $item_field eq 'name') { + return (map { $_->name . $_->status } @$item); + } + return (map { $self->_get_item($_, $item_field) } @$item); + } + + return @$item if ref($item) eq 'ARRAY'; + return $item if defined $item; + return (); +} + +sub _get_item { + my ($self, $from, $field) = @_; + if (blessed($from)) { + return $from->$field; + } + return $from->{$field}; +} + +##################### +# Value Translation # +##################### + +# This function translates the "value" specified in TESTS into an actual +# search value to pass to Search.pm. This means that we get the value +# from the current bug (or, in the case of changedfrom, from %original_values) +# and then we insert it as required into the "value" from TESTS. (For example, +# <1> becomes the value for the field from bug 1.) +sub _translate_value { + my $self = shift; + my $value = $self->test_value; + foreach my $number (1..NUM_BUGS) { + $value = $self->_translate_value_for_bug($number, $value); + } + return $value; +} + +sub _translate_value_for_bug { + my ($self, $number, $value) = @_; + + my $bug = $self->bug($number); + + my $bug_id = $bug->id; + $value =~ s/<$number-id>/$bug_id/g; + my $bug_delta = $bug->delta_ts; + $value =~ s/<$number-delta>/$bug_delta/g; + my $reporter = $bug->reporter->login; + $value =~ s/<$number-reporter>/$reporter/g; + + my @bug_values = $self->bug_values($number); + return $value if !@bug_values; + + if ($self->operator =~ /substr/) { + @bug_values = map { $self->_substr_value($_) } @bug_values; + } + + my $string_value = $bug_values[0]; + if ($self->operator =~ /word/) { + $string_value = join(' ', @bug_values); + } + if (my $func = $self->test->{transform}) { + my $transformed = $func->(@bug_values); + my $is_equal = $transformed eq $bug_values[0] ? 1 : 0; + $self->transformed_value_was_equal($number, $is_equal); + $string_value = $transformed; + } + + if ($self->test->{escape}) { + $string_value = quotemeta($string_value); + } + $value =~ s/<$number>/$string_value/g; + + return $value; +} + +sub _substr_value { + my ($self, $value) = @_; + my $field = $self->field; + my $substr_size = SUBSTR_SIZE; + if (exists FIELD_SUBSTR_SIZE->{$field}) { + $substr_size = FIELD_SUBSTR_SIZE->{$field}; + } + + if ($substr_size > 0) { + return substr($value, 0, $substr_size); + } + return substr($value, $substr_size); +} + +##################### +# Main Test Methods # +##################### + +sub run { + my ($self) = @_; + + my $invalid_combination = $self->invalid_field_operator_combination; + my $field_not_implemented = $self->field_not_yet_implemented; + + SKIP: { + skip($invalid_combination, $self->num_tests) if $invalid_combination; + TODO: { + todo_skip ($field_not_implemented, $self->num_tests) if $field_not_implemented; + $self->do_tests(); + } + } +} + +sub do_tests { + my ($self) = @_; + my $name = $self->name; + + my $search_broken = $self->search_known_broken; + + my $search; + TODO: { + local $TODO = $search_broken if $search_broken; + $search = $self->_test_search_object_creation(); + } + + my ($results, $sql); + SKIP: { + skip "Can't run SQL without Search object", 2 if !$search; + lives_ok { $sql = $search->getSQL() } "$name: get SQL"; + + # This prevents warnings from DBD::mysql if we pass undef $sql, + # which happens if "new Bugzilla::Search" fails. + $sql ||= ''; + $results = $self->_test_sql($sql); + } + + $self->_test_content($results, $sql); +} + +sub _test_search_object_creation { + my ($self) = @_; + my $name = $self->name; + my @args = (fields => $self->search_columns, params => $self->search_params); + my $search; + lives_ok { $search = new Bugzilla::Search(@args) } + "$name: create search object"; + return $search; +} + +sub _test_sql { + my ($self, $sql) = @_; + my $dbh = Bugzilla->dbh; + my $name = $self->name; + my $results; + lives_ok { $results = $dbh->selectall_arrayref($sql) } "$name: Run SQL Query" + or diag($sql); + return $results; +} + +sub _test_content { + my ($self, $results, $sql) = @_; + + SKIP: { + skip "Without results we can't test them", NUM_BUGS if !$results; + foreach my $number (1..NUM_BUGS) { + $self->_test_content_for_bug($number, $results, $sql); + } + } +} + +sub _test_content_for_bug { + my ($self, $number, $results, $sql) = @_; + my $name = $self->name; + + my $contains_known_broken = $self->contains_known_broken($number); + + my %result_ids = map { $_->[0] => 1 } @$results; + my $bug_id = $self->bug($number)->id; + + TODO: { + local $TODO = $contains_known_broken if $contains_known_broken; + if ($self->bug_is_contained($number)) { + ok($result_ids{$bug_id}, + "$name: contains bug $number ($bug_id)") + or diag Dumper($results) . $self->debug_value . "\n\nSQL: $sql"; + } + else { + ok(!$result_ids{$bug_id}, + "$name: does not contain bug $number ($bug_id)") + or diag Dumper($results) . $self->debug_value . "\n\nSQL: $sql"; + } + } +} + +1; \ No newline at end of file diff --git a/xt/lib/Bugzilla/Test/Search/InjectionTest.pm b/xt/lib/Bugzilla/Test/Search/InjectionTest.pm new file mode 100644 index 000000000..211026232 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/InjectionTest.pm @@ -0,0 +1,77 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This module represents the SQL Injection tests that get run on a single +# operator/field combination for Bugzilla::Test::Search. +package Bugzilla::Test::Search::InjectionTest; +use base qw(Bugzilla::Test::Search::FieldTest); + +use strict; +use warnings; +use Bugzilla::Test::Search::Constants; +use Test::Exception; + +sub num_tests { return NUM_SEARCH_TESTS } + +sub _known_broken { + my ($self) = @_; + my $operator_broken = INJECTION_BROKEN_OPERATOR->{$self->operator}; + # We don't want to auto-vivify $operator_broken and thus make it true. + my @field_ok = $operator_broken ? @{ $operator_broken->{field_ok} || [] } + : (); + + return {} if grep { $_ eq $self->field } @field_ok; + + my $field_broken = INJECTION_BROKEN_FIELD->{$self->field}; + # We don't want to auto-vivify $field_broken and thus make it true. + my @operator_ok = $field_broken ? @{ $field_broken->{operator_ok} || [] } + : (); + return {} if grep { $_ eq $self->operator } @operator_ok; + + return $operator_broken || $field_broken || {}; +} + +sub sql_error_ok { return $_[0]->_known_broken->{sql_error} } + +# Injection tests don't have to skip any fields. +sub field_not_yet_implemented { undef } +# Injection tests don't do translation. +sub translated_value { $_[0]->test_value } + +sub name { return "injection-" . $_[0]->SUPER::name; } + +# Injection tests don't check content. +sub _test_content {} + +sub _test_sql { + my $self = shift; + my ($sql) = @_; + my $dbh = Bugzilla->dbh; + my $name = $self->name; + if (my $error_ok = $self->sql_error_ok) { + throws_ok { $dbh->selectall_arrayref($sql) } $error_ok, + "$name: SQL query dies, as we expect"; + return; + } + return $self->SUPER::_test_sql(@_); +} + +1; \ No newline at end of file diff --git a/xt/lib/Bugzilla/Test/Search/OperatorTest.pm b/xt/lib/Bugzilla/Test/Search/OperatorTest.pm new file mode 100644 index 000000000..6291fbac1 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/OperatorTest.pm @@ -0,0 +1,110 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This module represents the tests that get run on a single operator +# from the TESTS constant in Bugzilla::Search::Test::Constants. +package Bugzilla::Test::Search::OperatorTest; + +use strict; +use warnings; +use Bugzilla::Test::Search::Constants; +use Bugzilla::Test::Search::FieldTest; +use Bugzilla::Test::Search::InjectionTest; +use Bugzilla::Test::Search::OrTest; +use Bugzilla::Test::Search::AndTest; + +############### +# Constructor # +############### + +sub new { + my ($invocant, $operator, $search_test) = @_; + $search_test ||= $invocant->search_test; + my $class = ref($invocant) || $invocant; + return bless { search_test => $search_test, operator => $operator }, $class; +} + +############# +# Accessors # +############# + +# The Bugzilla::Test::Search object that this is a child of. +sub search_test { return $_[0]->{search_test} } +# The operator being tested +sub operator { return $_[0]->{operator} } +# The tests that we're going to run on this operator. +sub tests { return @{ TESTS->{$_[0]->operator } } } +# The fields we're going to test for this operator. +sub test_fields { return $_[0]->search_test->all_fields } + +sub run { + my ($self) = @_; + + foreach my $field ($self->test_fields) { + foreach my $test ($self->tests) { + my $field_test = + new Bugzilla::Test::Search::FieldTest($self, $field, $test); + $field_test->run(); + + next if !$self->search_test->option('long'); + + # Run the OR tests. This tests every other operator (including + # this operator itself) in combination with every other field, + # in an OR with this operator and field. + foreach my $other_operator ($self->search_test->all_operators) { + $self->run_join_tests($field_test, $other_operator); + } + } + foreach my $test (INJECTION_TESTS) { + my $injection_test = + new Bugzilla::Test::Search::InjectionTest($self, $field, $test); + $injection_test->run(); + } + } +} + +sub run_join_tests { + my ($self, $field_test, $other_operator) = @_; + + my $other_operator_test = $self->new($other_operator); + foreach my $other_test ($other_operator_test->tests) { + foreach my $other_field ($self->test_fields) { + $self->_run_one_join_test($field_test, $other_operator_test, + $other_field, $other_test); + $self->search_test->clean_test_history(); + } + } +} + +sub _run_one_join_test { + my ($self, $field_test, $other_operator_test, $other_field, $other_test) = @_; + my $other_field_test = + new Bugzilla::Test::Search::FieldTest($other_operator_test, + $other_field, $other_test); + my $or_test = new Bugzilla::Test::Search::OrTest($field_test, + $other_field_test); + $or_test->run(); + my $and_test = new Bugzilla::Test::Search::AndTest($field_test, + $other_field_test); + $and_test->run(); +} + +1; \ No newline at end of file diff --git a/xt/lib/Bugzilla/Test/Search/OrTest.pm b/xt/lib/Bugzilla/Test/Search/OrTest.pm new file mode 100644 index 000000000..101e19fd5 --- /dev/null +++ b/xt/lib/Bugzilla/Test/Search/OrTest.pm @@ -0,0 +1,186 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# This test combines two field/operator combinations using OR in +# a single boolean chart. +package Bugzilla::Test::Search::OrTest; +use base qw(Bugzilla::Test::Search::FieldTest); + +use Bugzilla::Test::Search::Constants; +use Bugzilla::Test::Search::FakeCGI; +use List::MoreUtils qw(any uniq); + +use constant type => 'OR'; + +############### +# Constructor # +############### + +sub new { + my $class = shift; + my $self = { field_tests => [@_] }; + return bless $self, $class; +} + +############# +# Accessors # +############# + +sub field_tests { return @{ $_[0]->{field_tests} } } +sub search_test { ($_[0]->field_tests)[0]->search_test } + +sub name { + my ($self) = @_; + my @names = map { $_->name } $self->field_tests; + return join('-' . $self->type . '-', @names); +} + +# In an OR test, bugs ARE supposed to be contained if they are contained +# by ANY test. +sub bug_is_contained { + my ($self, $number) = @_; + return any { $_->bug_is_contained($number) } $self->field_tests; +} + +# Needed only for failure messages +sub debug_value { + my ($self) = @_; + my @values = map { $_->field . ' ' . $_->debug_value } $self->field_tests; + return join(' ' . $self->type . ' ', @values); +} + +######################## +# SKIP & TODO Messages # +######################## + +sub _join_skip { OR_SKIP } +sub _join_broken_constant { OR_BROKEN } + +sub field_not_yet_implemented { + my ($self) = @_; + foreach my $test ($self->field_tests) { + if (grep { $_ eq $test->field } $self->_join_skip) { + return $test->field . " is not yet supported in OR tests"; + } + } + return $self->_join_messages('field_not_yet_implemented'); +} +sub invalid_field_operator_combination { + my ($self) = @_; + return $self->_join_messages('invalid_field_operator_combination'); +} +sub search_known_broken { + my ($self) = @_; + return $self->_join_messages('search_known_broken'); +} + +sub _join_messages { + my ($self, $message_method) = @_; + my @messages = map { $_->$message_method } $self->field_tests; + @messages = grep { $_ } @messages; + return join(' AND ', @messages); +} + +sub _bug_will_actually_be_contained { + my ($self, $number) = @_; + my @results; + foreach my $test ($self->field_tests) { + if ($test->bug_is_contained($number) + and !$test->contains_known_broken($number)) + { + return 1; + } + elsif (!$test->bug_is_contained($number) + and $test->contains_known_broken($number)) { + return 1; + } + } + return 0; +} + +sub contains_known_broken { + my ($self, $number) = @_; + + my $join_broken = $self->_join_known_broken; + if (my $contains = $join_broken->{contains}) { + my $contains_is_broken = grep { $_ == $number } @$contains; + if ($contains_is_broken) { + my $name = $self->name; + return "$name contains $number is broken"; + } + return undef; + } + + return $self->_join_contains_known_broken($number); +} + +sub _join_contains_known_broken { + my ($self, $number) = @_; + + if ( ( $self->bug_is_contained($number) + and !$self->_bug_will_actually_be_contained($number) ) + or ( !$self->bug_is_contained($number) + and $self->_bug_will_actually_be_contained($number) ) ) + { + my @messages = map { $_->contains_known_broken($number) } $self->field_tests; + @messages = grep { $_ } @messages; + return join(' AND ', @messages); + } + return undef; +} + +sub _join_known_broken { + my ($self) = @_; + my $or_broken = $self->_join_broken_constant; + foreach my $test ($self->field_tests) { + @or_broken_for = map { $_->join_broken($or_broken) } $self->field_tests; + @or_broken_for = grep { defined $_ } @or_broken_for; + last if !@or_broken_for; + $or_broken = $or_broken_for[0]; + } + return $or_broken; +} + +############################## +# Bugzilla::Search arguments # +############################## + +sub search_columns { + my ($self) = @_; + my @columns = map { @{ $_->search_columns } } $self->field_tests; + return [uniq @columns]; +} + +sub search_params { + my ($self) = @_; + my @all_params = map { $_->search_params } $self->field_tests; + my $params = new Bugzilla::Test::Search::FakeCGI; + my $chart = 0; + foreach my $item (@all_params) { + $params->param("field0-0-$chart", $item->param('field0-0-0')); + $params->param("type0-0-$chart", $item->param('type0-0-0')); + $params->param("value0-0-$chart", $item->param('value0-0-0')); + $chart++; + } + return $params; +} + +1; \ No newline at end of file diff --git a/xt/search.t b/xt/search.t new file mode 100644 index 000000000..bd77f5b20 --- /dev/null +++ b/xt/search.t @@ -0,0 +1,96 @@ +#!/usr/bin/perl -w +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander + +# For a description of this test, see Bugzilla::Test::Search +# in xt/lib/. + +use strict; +use warnings; +use lib qw(. xt/lib lib); +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Test::Search; +use Getopt::Long; +use Pod::Usage; + +use Test::More; + +my %switches; +GetOptions(\%switches, 'operators=s', 'top-operators=s', 'long', + 'add-custom-fields', 'help|h') || die $@; + +pod2usage(verbose => 1) if $switches{'help'}; + +plan skip_all => "BZ_WRITE_TESTS environment variable not set" + if !$ENV{BZ_WRITE_TESTS}; + +Bugzilla->usage_mode(USAGE_MODE_TEST); + +my $test = new Bugzilla::Test::Search(\%switches); +plan tests => $test->num_tests; +$test->run(); + +__END__ + +=head1 NAME + +search.t - Test L + +=head1 DESCRIPTION + +This test tests L. + +Note that users may be prevented from writing new bugs, products, components, +etc. to your database while this test is running. + +=head1 OPTIONS + +=over + +=item --long + +Run AND and OR tests in addition to normal tests. Specifying +--long without also specifying L is likely to +run your system out of memory. + +=item --add-custom-fields + +This adds every type of custom field to the database, so that they can +all be tested. Note that this B, so do not use this +switch on a production installation. + +=item --operators=a,b,c + +Limit the test to testing only the listed operators. + +=item --top-operators=a,b,c + +Limit the top-level tested operators to the following list. This +means that for normal tests, only the listed operators will be tested. +However, for OR and AND tests, all other operators will be tested +along with the operators you listed. + +=item --help + +Display this help. + +=back -- cgit v1.2.3-24-g4f1b