diff --git a/Bugzilla/API/V1/BugUserLastVisit.pm b/Bugzilla/API/V1/BugUserLastVisit.pm new file mode 100644 index 0000000000..1f0a291f2c --- /dev/null +++ b/Bugzilla/API/V1/BugUserLastVisit.pm @@ -0,0 +1,179 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::API::V1::BugUserLastVisit; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Bug; +use Bugzilla::Constants; +use Bugzilla::Util qw(datetime_from); +use Bugzilla::WebService::Util qw(filter merge_request_params); + +sub setup_routes { + my ($class, $r) = @_; + my $routes = $r->under( + '/bug_user_last_visit' => sub { Bugzilla->usage_mode(USAGE_MODE_MOJO_REST); }); + $routes->get('/')->to('V1::BugUserLastVisit#get'); + $routes->get('/:id' => [id => qr/\d+/])->to('V1::BugUserLastVisit#get'); + $routes->post('/')->to('V1::BugUserLastVisit#update'); + $routes->post('/:id' => [id => qr/\d+/])->to('V1::BugUserLastVisit#update'); + + $routes->options('/')->to('V1::BugUserLastVisit#options'); + $routes->options('/:id' => [id => qr/\d+/]) + ->to('V1::BugUserLastVisit#options'); +} + +sub options { + my ($self) = @_; + + $self->res->headers->header('Allow' => 'GET, POST'); + $self->res->headers->header('Access-Control-Allow-Methods' => 'GET, POST'); + + return $self->rendered(200); +} + +sub get { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($params, $params_error) = $self->_request_params; + return $self->user_error($params_error) if $params_error; + + my ($ids, $error, $vars) = $self->_ids_from_request($params); + return $self->user_error($error, $vars) if $error; + + if ($ids) { + + # Cache permissions for bugs. This highly reduces the number of calls to + # the DB. visible_bugs() is only able to handle bug IDs, so we have to + # skip aliases. + $user->visible_bugs([grep {/^[0-9]+$/} @$ids]); + } + + my @last_visits = @{$user->last_visited}; + + if ($ids) { + + # remove bugs that we are not interested in if ids is passed in. + my %id_set = map { ($_ => 1) } @$ids; + @last_visits = grep { $id_set{$_->bug_id} } @last_visits; + } + + return $self->render( + json => [ + map { + $self->_bug_user_last_visit_to_hash($_->bug_id, $_->last_visit_ts, $params) + } @last_visits + ] + ); +} + +sub update { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($params, $params_error) = $self->_request_params; + return $self->user_error($params_error) if $params_error; + + my ($ids, $error, $vars) = $self->_ids_from_request($params); + return $self->user_error($error, $vars) if $error; + return $self->code_error('param_required', {param => 'ids'}) + unless $ids && @$ids; + + # Cache permissions for bugs. This highly reduces the number of calls to the + # DB. visible_bugs() is only able to handle bug IDs, so we have to skip + # aliases. + $user->visible_bugs([grep {/^[0-9]+$/} @$ids]); + + my $dbh = Bugzilla->dbh; + + $dbh->bz_start_transaction(); + my @results; + my $last_visit_ts = $dbh->selectrow_array('SELECT NOW()'); + foreach my $bug_id (@$ids) { + my $bug = Bugzilla::Bug->check({id => $bug_id, cache => 1}); + + next unless $user->can_see_bug($bug->id); + + $bug->update_user_last_visit($user, $last_visit_ts); + + push(@results, + $self->_bug_user_last_visit_to_hash($bug->id, $last_visit_ts, $params)); + } + $dbh->bz_commit_transaction(); + + return $self->render(json => \@results); +} + +sub _ids_from_request { + my ($self, $params) = @_; + + my $path_id = $self->stash('id'); + + # Legacy REST layer (_retrieve_json_params in + # Bugzilla::WebService::Server::REST): for GET, the path id wins over any + # query-string ids. For POST, request-body/query-string params are merged + # in *after* the path-derived params, so they win instead. + if (defined $path_id && $self->req->method ne 'POST') { + return [$path_id]; + } + + my $ids = $params->{ids}; + if (!defined $ids) { + return defined $path_id ? [$path_id] : undef; + } + + return (undef, 'invalid_params', {type_error => 'ids must be an array'}) + if ref $ids && ref $ids ne 'ARRAY'; + return ref $ids eq 'ARRAY' ? $ids : [$ids]; +} + +sub _request_params { + my ($self) = @_; + + my ($params, $error) = merge_request_params($self, ['ids']); + return (undef, $error) if $error; + + for my $field (qw(include_fields exclude_fields)) { + $params->{$field} = [split(/[\s,]+/, $params->{$field})] + if exists $params->{$field} && !ref $params->{$field}; + } + + return ($params, undef); +} + +sub _bug_user_last_visit_to_hash { + my ($self, $bug_id, $last_visit_ts, $params) = @_; + + return filter( + $params, + { + id => 0 + $bug_id, + last_visit_ts => datetime_from($last_visit_ts, 'UTC')->iso8601() . 'Z', + } + ); +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::API::V1::BugUserLastVisit - Find and Store the last time a user +visited a bug. + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to lookup and update the last +time a user visited a bug. diff --git a/Bugzilla/WebService/BugUserLastVisit.pm b/Bugzilla/WebService/BugUserLastVisit.pm deleted file mode 100644 index 1b07dbf102..0000000000 --- a/Bugzilla/WebService/BugUserLastVisit.pm +++ /dev/null @@ -1,206 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::WebService::BugUserLastVisit; - -use 5.10.1; -use strict; -use warnings; - -use base qw(Bugzilla::WebService); - -use Bugzilla::Bug; -use Bugzilla::Error; -use Bugzilla::WebService::Util qw( validate filter ); -use Bugzilla::Constants; - -use constant PUBLIC_METHODS => qw( - get - update -); - -sub update { - my ($self, $params) = validate(@_, 'ids'); - my $user = Bugzilla->user; - my $dbh = Bugzilla->dbh; - - $user->login(LOGIN_REQUIRED); - - my $ids = $params->{ids} // []; - ThrowCodeError('param_required', {param => 'ids'}) unless @$ids; - - # Cache permissions for bugs. This highly reduces the number of calls to the - # DB. visible_bugs() is only able to handle bug IDs, so we have to skip - # aliases. - $user->visible_bugs([grep /^[0-9]+$/, @$ids]); - - $dbh->bz_start_transaction(); - my @results; - my $last_visit_ts = $dbh->selectrow_array('SELECT NOW()'); - foreach my $bug_id (@$ids) { - my $bug = Bugzilla::Bug->check({id => $bug_id, cache => 1}); - - next unless $user->can_see_bug($bug->id); - - $bug->update_user_last_visit($user, $last_visit_ts); - - push(@results, - $self->_bug_user_last_visit_to_hash($bug_id, $last_visit_ts, $params)); - } - $dbh->bz_commit_transaction(); - - return \@results; -} - -sub get { - my ($self, $params) = validate(@_, 'ids'); - my $user = Bugzilla->user; - my $ids = $params->{ids}; - - $user->login(LOGIN_REQUIRED); - - if ($ids) { - - # Cache permissions for bugs. This highly reduces the number of calls to - # the DB. visible_bugs() is only able to handle bug IDs, so we have to - # skip aliases. - $user->visible_bugs([grep /^[0-9]+$/, @$ids]); - } - - my @last_visits = @{$user->last_visited}; - - if ($ids) { - - # remove bugs that we are not interested in if ids is passed in. - my %id_set = map { ($_ => 1) } @$ids; - @last_visits = grep { $id_set{$_->bug_id} } @last_visits; - } - - return [ - map { - $self->_bug_user_last_visit_to_hash($_->bug_id, $_->last_visit_ts, $params) - } @last_visits - ]; -} - -sub _bug_user_last_visit_to_hash { - my ($self, $bug_id, $last_visit_ts, $params) = @_; - - my %result = ( - id => $self->type('int', $bug_id), - last_visit_ts => $self->type('dateTime', $last_visit_ts) - ); - - return filter($params, \%result); -} - -1; - -__END__ -=head1 NAME - -Bugzilla::WebService::BugUserLastVisit - Find and Store the last time a user -visited a bug. - -=head1 METHODS - -See L for a description of how parameters are passed, -and what B, B, and B mean. - -Although the data input and output is the same for JSON-RPC and REST, -the directions for how to access the data via REST is noted in each method -where applicable. - -=head2 update - -B - -=over - -=item B - -Update the last visit time for the specified bug and current user. - -=item B - -To add a single bug id: - - POST /rest/bug_user_last_visit/ - -Tp add one or more bug ids at once: - - POST /rest/bug_user_last_visit - -The returned data format is the same as below. - -=item B - -=over - -=item C (array) - One or more bug ids to add. - -=back - -=item B - -=over - -=item C - An array of hashes containing the following: - -=over - -=item C - (int) The bug id. - -=item C - (string) The timestamp the user last visited the bug. - -=back - -=back - -=back - -=head2 get - -B - -=over - -=item B - -Get the last visited timestamp for one or more specified bug ids. - -=item B - -To return the last visited timestamp for a single bug id: - - GET /rest/bug_user_last_visit/ - -=item B - -=over - -=item C (integer) - One or more optional bug ids to get. - -=back - -=item B - -=over - -=item C - An array of hashes containing the following: - -=over - -=item C - (int) The bug id. - -=item C - (string) The timestamp the user last visited the bug. - -=back - -=back - -=back diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm index 1986c9a479..c0f22d8f6f 100644 --- a/Bugzilla/WebService/Constants.pm +++ b/Bugzilla/WebService/Constants.pm @@ -318,7 +318,6 @@ sub WS_DISPATCH { 'User' => 'Bugzilla::WebService::User', 'Product' => 'Bugzilla::WebService::Product', 'Group' => 'Bugzilla::WebService::Group', - 'BugUserLastVisit' => 'Bugzilla::WebService::BugUserLastVisit', %hook_dispatch }; return $dispatch; diff --git a/Bugzilla/WebService/Server/REST.pm b/Bugzilla/WebService/Server/REST.pm index b9ae7b9a3e..5ad30efe09 100644 --- a/Bugzilla/WebService/Server/REST.pm +++ b/Bugzilla/WebService/Server/REST.pm @@ -27,7 +27,6 @@ use Bugzilla::WebService::Server::REST::Resources::Bugzilla; use Bugzilla::WebService::Server::REST::Resources::Group; use Bugzilla::WebService::Server::REST::Resources::Product; use Bugzilla::WebService::Server::REST::Resources::User; -use Bugzilla::WebService::Server::REST::Resources::BugUserLastVisit; use List::MoreUtils qw(uniq); use Scalar::Util qw(blessed reftype); diff --git a/Bugzilla/WebService/Server/REST/Resources/BugUserLastVisit.pm b/Bugzilla/WebService/Server/REST/Resources/BugUserLastVisit.pm deleted file mode 100644 index 72aa0d40f0..0000000000 --- a/Bugzilla/WebService/Server/REST/Resources/BugUserLastVisit.pm +++ /dev/null @@ -1,57 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::WebService::Server::REST::Resources::BugUserLastVisit; - -use 5.10.1; -use strict; -use warnings; - -BEGIN { - *Bugzilla::WebService::BugUserLastVisit::rest_resources = \&_rest_resources; -} - -sub _rest_resources { - return [ - # bug-id - qr{^/bug_user_last_visit/(\d+)$}, - { - GET => { - method => 'get', - params => sub { - return {ids => $_[0]}; - }, - }, - POST => { - method => 'update', - params => sub { - return {ids => $_[0]}; - }, - }, - }, - - # no bug-id - qr{^/bug_user_last_visit$}, - {GET => {method => 'get',}, POST => {method => 'update',},}, - ]; -} - -1; -__END__ - -=head1 NAME - -Bugzilla::Webservice::Server::REST::Resources::BugUserLastVisit - The -BugUserLastVisit REST API - -=head1 DESCRIPTION - -This part of the Bugzilla REST API allows you to lookup and update the last time -a user visited a bug. - -See L for more details on how to use -this part of the REST API. diff --git a/Bugzilla/WebService/Util.pm b/Bugzilla/WebService/Util.pm index 0a5e94a98c..d282700a5d 100644 --- a/Bugzilla/WebService/Util.pm +++ b/Bugzilla/WebService/Util.pm @@ -301,29 +301,38 @@ sub params_to_objects { } sub merge_request_params { - my ($c) = @_; + my ($c, $list_params) = @_; # $c->req->params already covers the query string plus, for POST/PUT, an # application/x-www-form-urlencoded or multipart body. Layer a JSON body # underneath that, so params work from either the query string or a JSON - # request body. Query-string values win on a key collision, matching the - # legacy REST layer (see fix_credentials/_retrieve_json_params in - # Bugzilla::WebService::Server::REST) and the documented behavior in + # request body. Query-string/form-body values win on a key collision, + # matching the legacy REST layer (see fix_credentials/_retrieve_json_params + # in Bugzilla::WebService::Server::REST) and the documented behavior in # docs/en/rst/api/core/v1/general.rst. # - # ->to_hash would turn a repeated key (e.g. ?note=a¬e=b) into an - # arrayref, which validators don't expect, so collapse to scalars instead. - my $params = {}; - $params->{$_} = $c->req->param($_) for @{$c->req->params->names}; + # A param's type must not depend on how many times it was sent: ->to_hash + # returns a scalar for one occurrence and an arrayref for two. Callers + # therefore declare which params are lists; those always come back as + # arrayrefs, everything else always as a scalar. + my %is_list = map { $_ => 1 } @{$list_params || []}; + my $params = {}; + for my $name (@{$c->req->params->names}) { + $params->{$name} + = $is_list{$name} ? $c->req->every_param($name) : $c->req->param($name); + } # Only decode a body that wasn't already parsed as form params, otherwise a # form-urlencoded or multipart request would be rejected as malformed JSON. # The legacy REST layer gets this for free: CGI.pm only populates # POSTDATA/PUTDATA for non-form content types. - if (length $c->req->body && !@{$c->req->body_params->names}) { + # Read the body once: for a file-backed request asset each ->body call + # re-slurps it from disk. + my $body = $c->req->body; + if (length $body && !@{$c->req->body_params->names}) { my $body_params; my $error; - try { $body_params = decode_json($c->req->body); } + try { $body_params = decode_json($body); } catch { $error = 'rest_malformed_json'; }; return (undef, $error) if $error; $params = {%$body_params, %$params} if ref $body_params eq 'HASH'; @@ -443,6 +452,12 @@ key collision. For use by native Mojo REST controllers that need to accept parameters from either the query string or a JSON body on non-GET requests. +An optional second argument is an arrayref of parameter names that are +lists, e.g. C. Those are always returned +as arrayrefs, however many times they appear in the request; every other +parameter is always returned as a scalar. Declaring them is required because +a parameter's type must not depend on the number of occurrences sent. + If the request has a non-empty body that fails to decode as JSON, C<$params> is C and C<$error> is set to C; callers should pass it to C. Otherwise C<$error> is C. diff --git a/qa/t/rest_bug_user_last_visit.t b/qa/t/rest_bug_user_last_visit.t new file mode 100644 index 0000000000..19cbb0657e --- /dev/null +++ b/qa/t/rest_bug_user_last_visit.t @@ -0,0 +1,227 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. +use strict; +use warnings; +use 5.10.1; +use lib qw(lib ../../lib ../../local/lib/perl5); + +use Bugzilla; +use QA::Util qw(get_config); +use QA::Tests qw(create_bug_fields PRIVATE_BUG_USER); + +use Mojo::JSON qw(encode_json); +use Test::Mojo; +use Test::More; + +my $config = get_config(); +my $api_key = $config->{editbugs_user_api_key}; +my $url = Bugzilla->localconfig->urlbase; + +my $t = Test::Mojo->new(); +$t->ua->max_redirects(1); + +### Setup: create two bugs to record visits against + +sub create_bug { + my ($summary) = @_; + $t->post_ok($url + . 'rest/bug' => {'X-Bugzilla-API-Key' => $api_key} => json => { + product => 'Firefox', + component => 'General', + summary => $summary, + type => 'defect', + version => 'unspecified', + severity => 'blocker', + description => $summary, + })->status_is(200)->json_has('/id'); + return $t->tx->res->json->{id}; +} + +my $bug_id_1 = create_bug('bug_user_last_visit test bug 1'); +my $bug_id_2 = create_bug('bug_user_last_visit test bug 2'); + +### Section 1: Anonymous access requires login + +$t->get_ok($url . 'rest/bug_user_last_visit')->status_is(401) + ->json_is( + '/message' => 'You must log in before using this part of Bugzilla.'); + +### Section 2: OPTIONS + +$t->options_ok($url . 'rest/bug_user_last_visit')->status_is(200) + ->header_is('Allow' => 'GET, POST'); +$t->options_ok($url . "rest/bug_user_last_visit/$bug_id_1")->status_is(200) + ->header_is('Allow' => 'GET, POST'); + +### Section 3: POST /rest/bug_user_last_visit/ records a visit via the path + +$t->post_ok($url + . "rest/bug_user_last_visit/$bug_id_1" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200) + ->json_is('/0/id' => $bug_id_1)->json_has('/0/last_visit_ts'); + +like($t->tx->res->json->[0]->{last_visit_ts}, qr/Z$/, 'last_visit_ts ends in Z'); + +### Section 4: POST /rest/bug_user_last_visit with a JSON ids body records +### visits for multiple bugs at once + +$t->post_ok($url + . 'rest/bug_user_last_visit' => {'X-Bugzilla-API-Key' => $api_key} => + json => {ids => [$bug_id_1, $bug_id_2]})->status_is(200); + +my @posted_ids = sort { $a <=> $b } map { $_->{id} } @{$t->tx->res->json}; +is_deeply(\@posted_ids, [sort { $a <=> $b } ($bug_id_1, $bug_id_2)], + 'both bugs recorded from a JSON body ids array'); + +### Section 5: a JSON body ids overrides a path id on POST (matches the +### legacy REST layer, where non-GET body/query params are merged in after, +### and so win over, path-derived params) + +$t->post_ok($url + . "rest/bug_user_last_visit/$bug_id_1" => + {'X-Bugzilla-API-Key' => $api_key} => json => {ids => [$bug_id_2]}) + ->status_is(200); + +my @body_override_ids = map { $_->{id} } @{$t->tx->res->json}; +is_deeply(\@body_override_ids, [$bug_id_2], + 'a JSON body ids overrides the path id on POST'); + +### Section 6: a JSON body with no Content-Type header still works (real +### frontend callers post this way) + +my $raw_json = encode_json({ids => [$bug_id_1]}); +$t->post_ok($url + . 'rest/bug_user_last_visit' => {'X-Bugzilla-API-Key' => $api_key} => + $raw_json)->status_is(200); + +my @no_content_type_ids = map { $_->{id} } @{$t->tx->res->json}; +is_deeply(\@no_content_type_ids, [$bug_id_1], + 'a JSON body with no Content-Type header is still parsed'); + +### Section 7: GET /rest/bug_user_last_visit/ -- the path id wins over a +### query-string ids on GET (unchanged from before the POST precedence fix) + +$t->get_ok($url + . "rest/bug_user_last_visit/$bug_id_1?ids=$bug_id_2" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200); + +my @get_path_wins_ids = map { $_->{id} } @{$t->tx->res->json}; +is_deeply(\@get_path_wins_ids, [$bug_id_1], + 'the path id wins over a query-string ids on GET'); + +### Section 8: GET /rest/bug_user_last_visit?ids=...&ids=... filters to the +### requested bugs + +$t->get_ok($url + . "rest/bug_user_last_visit?ids=$bug_id_1&ids=$bug_id_2" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200); + +my @get_query_ids = sort { $a <=> $b } map { $_->{id} } @{$t->tx->res->json}; +is_deeply(\@get_query_ids, [sort { $a <=> $b } ($bug_id_1, $bug_id_2)], + 'query-string ids filters to the requested bugs'); + +### Section 9: GET /rest/bug_user_last_visit with no ids at all returns +### every visited bug, not an empty list + +$t->get_ok($url . 'rest/bug_user_last_visit' => {'X-Bugzilla-API-Key' => $api_key}) + ->status_is(200); + +my @get_all_ids = sort { $a <=> $b } map { $_->{id} } @{$t->tx->res->json}; +ok((grep { $_ == $bug_id_1 } @get_all_ids) + && (grep { $_ == $bug_id_2 } @get_all_ids), + 'GET with no ids returns every visited bug'); + +### Section 10: a bug in a group the user is not a member of is not +### accessible, and does not leak through the GET filter + +# File it as, and restrict it to, a group the editbugs user is not in. Created +# by the private user so that the editbugs user is not its reporter either. +# Same setup as qa/t/rest_relationship_trees.t. +my $private_api_key = $config->{PRIVATE_BUG_USER . '_user_api_key'}; + +my $private_bug_data = create_bug_fields($config); +delete $private_bug_data->{cc}; +$private_bug_data->{summary} = 'bug_user_last_visit private test bug'; +$private_bug_data->{description} = 'bug_user_last_visit private test bug'; + +$t->post_ok($url + . 'rest/bug' => {'X-Bugzilla-API-Key' => $private_api_key} => json => + $private_bug_data)->status_is(200)->json_has('/id'); + +my $private_bug_id = $t->tx->res->json->{id}; + +$t->put_ok($url + . "rest/bug/$private_bug_id" => {'X-Bugzilla-API-Key' => $private_api_key} + => json => {groups => {add => ['QA-Selenium-TEST']}})->status_is(200); + +$t->post_ok($url + . "rest/bug_user_last_visit/$private_bug_id" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(401) + ->json_is('/code' => 102) + ->json_like('/message' => qr/not authorized to access/); + +$t->get_ok($url + . "rest/bug_user_last_visit?ids=$private_bug_id" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200); +is_deeply($t->tx->res->json, [], + 'a bug the user cannot see is not returned by GET'); + +### Section 11: anonymous POST requires login (anonymous GET is section 1) + +$t->post_ok($url . 'rest/bug_user_last_visit' => json => {ids => [$bug_id_1]}) + ->status_is(401) + ->json_is( + '/message' => 'You must log in before using this part of Bugzilla.'); + +### Section 12: a nonexistent bug id fails the whole request, and the visit +### recorded earlier in the same loop is rolled back + +$t->post_ok($url + . "rest/bug_user_last_visit/$bug_id_1" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200); +my $ts_before = $t->tx->res->json->[0]->{last_visit_ts}; + +# last_visit_ts has second granularity, so without this the rolled-back and +# the would-be-new timestamp could be identical and the test pass spuriously. +sleep 1; + +# Bugzilla::Bug->new sets error => 'InvalidBugId' rather than 'NotFound' when +# handed a hashref, so check() reports improper_bug_id_field_value with no bug +# id rather than bug_id_does_not_exist. The legacy endpoint calls check() the +# same way and behaves identically. +$t->post_ok($url + . 'rest/bug_user_last_visit' => {'X-Bugzilla-API-Key' => $api_key} => + json => {ids => [$bug_id_1, 99999999]})->status_is(400) + ->json_is('/code' => 100)->json_like('/message' => qr/valid bug number/); + +$t->get_ok($url + . "rest/bug_user_last_visit/$bug_id_1" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200); +is($t->tx->res->json->[0]->{last_visit_ts}, + $ts_before, 'the visit recorded before the bad id was rolled back'); + +### Section 13: POST with no ids in the path, query string or body + +$t->post_ok($url + . 'rest/bug_user_last_visit' => {'X-Bugzilla-API-Key' => $api_key} => + json => {})->status_is(400)->json_is('/code' => 50) + ->json_like('/message' => qr/argument was not set/); + +### Section 14: include_fields / exclude_fields + +$t->get_ok($url + . "rest/bug_user_last_visit/$bug_id_1?include_fields=id" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200)->json_has('/0/id') + ->json_hasnt('/0/last_visit_ts'); + +$t->get_ok($url + . "rest/bug_user_last_visit/$bug_id_1?exclude_fields=last_visit_ts" => + {'X-Bugzilla-API-Key' => $api_key})->status_is(200)->json_has('/0/id') + ->json_hasnt('/0/last_visit_ts'); + +done_testing();