]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 574879: Create a test that assures the correctness of Search.pm's
authorMax Kanat-Alexander <mkanat@bugzilla.org>
Wed, 7 Jul 2010 21:34:25 +0000 (14:34 -0700)
committerMax Kanat-Alexander <mkanat@bugzilla.org>
Wed, 7 Jul 2010 21:34:25 +0000 (14:34 -0700)
boolean charts
r=glob, a=mkanat

17 files changed:
Bugzilla.pm
Bugzilla/Bug.pm
Bugzilla/Constants.pm
Bugzilla/Error.pm
Bugzilla/Install.pm
Bugzilla/Install/Filesystem.pm
Bugzilla/Search.pm
xt/README [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/AndTest.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/Constants.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/FakeCGI.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/FieldTest.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/InjectionTest.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/OperatorTest.pm [new file with mode: 0644]
xt/lib/Bugzilla/Test/Search/OrTest.pm [new file with mode: 0644]
xt/search.t [new file with mode: 0644]

index eedcb85eecf3c35d400c10f3fb4d6417677ee5a1..33df05efb792fdeaddc2c5f092615c02ce5e1062 100644 (file)
@@ -463,6 +463,9 @@ sub usage_mode {
         elsif ($newval == USAGE_MODE_EMAIL) {
             $class->error_mode(ERROR_MODE_DIE);
         }
+        elsif ($newval == USAGE_MODE_TEST) {
+            $class->error_mode(ERROR_MODE_TEST);
+        }
         else {
             ThrowCodeError('usage_mode_invalid',
                            {'invalid_usage_mode', $newval});
index ed302a05312a20adf4dac4e258c32682ef6f08a6..e32add2e1013c5146d3a8125a676fc42afb3a7cd 100644 (file)
@@ -3203,6 +3203,17 @@ sub comments {
     return \@comments;
 }
 
+# This is needed by xt/search.t.
+sub percentage_complete {
+    my $self = shift;
+    return undef if $self->{'error'} || !Bugzilla->user->is_timetracker;
+    my $remaining = $self->remaining_time;
+    my $actual    = $self->actual_time;
+    my $total = $remaining + $actual;
+    return undef if $total == 0;
+    return 100 * ($actual / $total);
+}
+
 sub product {
     my ($self) = @_;
     return $self->{product} if exists $self->{product};
index 4d9b1edc336a1c55aa9b8de68af430b03fbb5e84..63219833eedb30a7fb289caeaa4eef662eb31acb 100644 (file)
@@ -141,11 +141,13 @@ use File::Basename;
     USAGE_MODE_XMLRPC
     USAGE_MODE_EMAIL
     USAGE_MODE_JSON
+    USAGE_MODE_TEST
 
     ERROR_MODE_WEBPAGE
     ERROR_MODE_DIE
     ERROR_MODE_DIE_SOAP_FAULT
     ERROR_MODE_JSON_RPC
+    ERROR_MODE_TEST
 
     COLOR_ERROR
 
@@ -457,6 +459,7 @@ use constant USAGE_MODE_CMDLINE    => 1;
 use constant USAGE_MODE_XMLRPC     => 2;
 use constant USAGE_MODE_EMAIL      => 3;
 use constant USAGE_MODE_JSON       => 4;
+use constant USAGE_MODE_TEST       => 5;
 
 # Error modes. Default set by Bugzilla->usage_mode (so ERROR_MODE_WEBPAGE
 # usually). Use with Bugzilla->error_mode.
@@ -464,6 +467,7 @@ use constant ERROR_MODE_WEBPAGE        => 0;
 use constant ERROR_MODE_DIE            => 1;
 use constant ERROR_MODE_DIE_SOAP_FAULT => 2;
 use constant ERROR_MODE_JSON_RPC       => 3;
+use constant ERROR_MODE_TEST           => 4;
 
 # The ANSI colors of messages that command-line scripts use
 use constant COLOR_ERROR => 'red';
index 60e7837deb50bf68034152a91cc8f7f7583745f5..649fdd48666413c8efcfa06caff88fa68386faf5 100644 (file)
@@ -33,6 +33,7 @@ use Bugzilla::WebService::Constants;
 use Bugzilla::Util;
 
 use Carp;
+use Data::Dumper;
 use Date::Format;
 
 # We cannot use $^S to detect if we are in an eval(), because mod_perl
@@ -102,6 +103,12 @@ sub _throw_error {
         $template->process($name, $vars)
           || ThrowTemplateError($template->error());
     }
+    # There are some tests that throw and catch a lot of errors,
+    # and calling $template->process over and over for those errors
+    # is too slow. So instead, we just "die" with a dump of the arguments.
+    elsif (Bugzilla->error_mode == ERROR_MODE_TEST) {
+        die Dumper($vars);
+    }
     else {
         my $message;
         $template->process($name, $vars, \$message)
index 3754c078710bbe4ea9941b2a7b22825ac3edb316..9536f4645ff55b7dc414713cd47f7c8671c435a0 100644 (file)
@@ -358,7 +358,9 @@ sub make_admin {
         write_params();
     }
 
-    print "\n", get_text('install_admin_created', { user => $user }), "\n";
+    if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) {
+        print "\n", get_text('install_admin_created', { user => $user }), "\n";
+    }
 }
 
 sub _prompt_for_password {
index 0ca49a75c630a1e1198ac73d962e1a9e7ed47f0e..db55576a4ec8efc12bc64a45f9df9a281c12db26 100644 (file)
@@ -241,6 +241,8 @@ sub FILESYSTEM {
                                     dirs  => DIR_OWNER_WRITE },
          t                     => { files => OWNER_WRITE,
                                      dirs => DIR_OWNER_WRITE },
+         xt                    => { files => OWNER_WRITE,
+                                     dirs => DIR_OWNER_WRITE },
          'docs/lib'            => { files => OWNER_WRITE,
                                      dirs => DIR_OWNER_WRITE },
          'docs/*/xml'          => { files => OWNER_WRITE,
@@ -333,6 +335,8 @@ EOT
                                           contents => HT_DEFAULT_DENY },
         't/.htaccess'                => { perms    => WS_SERVE,
                                           contents => HT_DEFAULT_DENY },
+        'xt/.htaccess'               => { perms    => WS_SERVE,
+                                          contents => HT_DEFAULT_DENY },
         "$datadir/.htaccess"         => { perms    => WS_SERVE,
                                           contents => HT_DEFAULT_DENY },
 
index ad8ab0edbdbbd2d1293821abe692e8b0aa2efea4..89e2dfa61b93d683c7e40a84cd7ec162ea112165 100644 (file)
@@ -2159,7 +2159,7 @@ sub _owner_idle_time_greater_less {
     
     my $table = "idle_" . $$chartid;
     $$v =~ /^(\d+)\s*([hHdDwWmMyY])?$/;
-    my $quantity = $1;
+    my $quantity = $1 || 0;
     my $unit = lc $2;
     my $unitinterval = 'DAY';
     if ($unit eq 'h') {
diff --git a/xt/README b/xt/README
new file mode 100644 (file)
index 0000000..22f9f17
--- /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 (file)
index 0000000..80e9e0c
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..d7b21af
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..95bba8e
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+
+# 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 (file)
index 0000000..e20a57d
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..4c43e34
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..2110262
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..6291fba
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..101e19f
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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 (file)
index 0000000..bd77f5b
--- /dev/null
@@ -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 <mkanat@bugzilla.org>
+
+# 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<Bugzilla::Search>
+
+=head1 DESCRIPTION
+
+This test tests L<Bugzilla::Search>.
+
+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</--top-operators> 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<CANNOT BE REVERSED>, 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