[Bps-public-commit] rt-extension-rest2 branch, master, updated. 5159c3fdefab807730854e41250f09ff9e4de0b4

Shawn Moore shawn at bestpractical.com
Wed Jul 5 18:14:03 EDT 2017


The branch, master has been updated
       via  5159c3fdefab807730854e41250f09ff9e4de0b4 (commit)
       via  5e00b93664fd4ab7760de0c185760ab61be15fb3 (commit)
       via  2e913aaa41c78edc1f09f74f61eceb7a91c3a630 (commit)
       via  b02decbe71313b3fcdc3099104c20ceec17b7203 (commit)
       via  d0d507c6e763ca3d755657cc7f189053305b4f52 (commit)
       via  d65d32da024d6b77afedd9ea5f12fec9c2cbb43f (commit)
       via  5d2b5c0230983def954c76212bfa03bc5f50d749 (commit)
       via  bb92e233ecbfd72880df0c70ff5d1f287f2f0f01 (commit)
       via  cb014a6bbda1c474c7b8a936fb758daf2adc9f0b (commit)
       via  a7b28956057de8cf07e6a573a9de3103451fe29d (commit)
       via  d3993461d6c9505fbeeca19e68b763366c6d2114 (commit)
       via  9196c258548a9fddb92ecf9b144623e450489ff7 (commit)
      from  d48400c8d86824f0b18b0bd2d38b63db77db2dbf (commit)

Summary of changes:
 TODO                                               |  13 +-
 .../Resource/{Attachment.pm => CustomField.pm}     |  11 +-
 .../REST2/Resource/{Queues.pm => CustomFields.pm}  |   7 +-
 .../Resource/{Attachment.pm => CustomRole.pm}      |  10 +-
 .../REST2/Resource/{Queues.pm => CustomRoles.pm}   |   7 +-
 lib/RT/Extension/REST2/Resource/Group.pm           |  40 ++
 .../REST2/Resource/{Queues.pm => Groups.pm}        |   7 +-
 .../Extension/REST2/Resource/Record/Hypermedia.pm  |  44 +-
 lib/RT/Extension/REST2/Resource/Record/Writable.pm |  96 +++-
 lib/RT/Extension/REST2/Util.pm                     |   2 +-
 t/ticket-customfields.t                            |  86 +++-
 t/ticket-customroles.t                             | 512 ++++++++++++++++++
 t/ticket-watchers.t                                | 569 +++++++++++++++++++++
 13 files changed, 1351 insertions(+), 53 deletions(-)
 copy lib/RT/Extension/REST2/Resource/{Attachment.pm => CustomField.pm} (56%)
 copy lib/RT/Extension/REST2/Resource/{Queues.pm => CustomFields.pm} (64%)
 copy lib/RT/Extension/REST2/Resource/{Attachment.pm => CustomRole.pm} (61%)
 copy lib/RT/Extension/REST2/Resource/{Queues.pm => CustomRoles.pm} (64%)
 create mode 100644 lib/RT/Extension/REST2/Resource/Group.pm
 copy lib/RT/Extension/REST2/Resource/{Queues.pm => Groups.pm} (66%)
 create mode 100644 t/ticket-customroles.t
 create mode 100644 t/ticket-watchers.t

- Log -----------------------------------------------------------------
commit 9196c258548a9fddb92ecf9b144623e450489ff7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 17:01:19 2017 +0000

    Basic ticket watcher tests
    
    Creating and reading all roles, updating owner. No changes to lib code
    necessary for these. Updating multi-user roles will require some changes

diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
new file mode 100644
index 0000000..2e254b2
--- /dev/null
+++ b/t/ticket-watchers.t
@@ -0,0 +1,243 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
+
+my $mech = RT::Extension::REST2::Test->mech;
+
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+my $queue = RT::Test->load_or_create_queue( Name => "General" );
+
+$user->PrincipalObj->GrantRight( Right => $_ )
+    for qw/CreateTicket ShowTicket ModifyTicket OwnTicket AdminUsers/;
+
+# Create and view ticket with no watchers
+{
+    my $payload = {
+        Subject => 'Ticket with no watchers',
+        Queue   => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [], 'no Requestor');
+    cmp_deeply($content->{Cc}, [], 'no Cc');
+    cmp_deeply($content->{AdminCc}, [], 'no AdminCc');
+    cmp_deeply($content->{Owner}, {
+        type => 'user',
+        id   => 'Nobody',
+        _url => re(qr{$rest_base_path/user/Nobody$}),
+    }, 'Owner is Nobody');
+
+    $res = $mech->get($content->{Owner}{_url},
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id => RT->Nobody->id,
+        Name => 'Nobody',
+        RealName => 'Nobody in particular',
+    }), 'Nobody user');
+}
+
+# Create and view ticket with single users as watchers
+{
+    my $payload = {
+        Subject   => 'Ticket with single watchers',
+        Queue     => 'General',
+        Requestor => 'requestor at example.com',
+        Cc        => 'cc at example.com',
+        AdminCc   => 'admincc at example.com',
+        Owner     => $user->EmailAddress,
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [{
+        type => 'user',
+        id   => 'requestor at example.com',
+        _url => re(qr{$rest_base_path/user/requestor\@example\.com$}),
+    }], 'one Requestor');
+
+    cmp_deeply($content->{Cc}, [{
+        type => 'user',
+        id   => 'cc at example.com',
+        _url => re(qr{$rest_base_path/user/cc\@example\.com$}),
+    }], 'one Cc');
+
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    }], 'one AdminCc');
+
+    cmp_deeply($content->{Owner}, {
+        type => 'user',
+        id   => 'test',
+        _url => re(qr{$rest_base_path/user/test$}),
+    }, 'Owner is REST test user');
+}
+
+# Create and view ticket with multiple users as watchers
+{
+    my $payload = {
+        Subject   => 'Ticket with multiple watchers',
+        Queue     => 'General',
+        Requestor => ['requestor at example.com', 'requestor2 at example.com'],
+        Cc        => ['cc at example.com', 'cc2 at example.com'],
+        AdminCc   => ['admincc at example.com', 'admincc2 at example.com'],
+        Owner     => $user->EmailAddress,
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [{
+        type => 'user',
+        id   => 'requestor at example.com',
+        _url => re(qr{$rest_base_path/user/requestor\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'requestor2 at example.com',
+        _url => re(qr{$rest_base_path/user/requestor2\@example\.com$}),
+    }], 'two Requestors');
+
+    cmp_deeply($content->{Cc}, [{
+        type => 'user',
+        id   => 'cc at example.com',
+        _url => re(qr{$rest_base_path/user/cc\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'cc2 at example.com',
+        _url => re(qr{$rest_base_path/user/cc2\@example\.com$}),
+    }], 'two Ccs');
+
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'admincc2 at example.com',
+        _url => re(qr{$rest_base_path/user/admincc2\@example\.com$}),
+    }], 'two AdminCcs');
+
+    cmp_deeply($content->{Owner}, {
+        type => 'user',
+        id   => 'test',
+        _url => re(qr{$rest_base_path/user/test$}),
+    }, 'Owner is REST test user');
+}
+
+# Modify owner
+{
+    my $payload = {
+        Subject   => 'Ticket for modifying owner',
+        Queue     => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    cmp_deeply($mech->json_response->{Owner}, {
+        type => 'user',
+        id   => 'Nobody',
+        _url => re(qr{$rest_base_path/user/Nobody$}),
+    }, 'Owner is Nobody');
+
+    for my $identifier ($user->id, $user->Name) {
+        $payload = {
+            Owner => $identifier,
+        };
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Owner changed from Nobody to test"], "updated Owner with identifier $identifier");
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+
+        cmp_deeply($mech->json_response->{Owner}, {
+            type => 'user',
+            id   => 'test',
+            _url => re(qr{$rest_base_path/user/test$}),
+        }, 'Owner has changed to test');
+
+        $payload = {
+            Owner => 'Nobody',
+        };
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Owner changed from test to Nobody"], 'updated Owner');
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+
+        cmp_deeply($mech->json_response->{Owner}, {
+            type => 'user',
+            id   => 'Nobody',
+            _url => re(qr{$rest_base_path/user/Nobody$}),
+        }, 'Owner has changed to Nobody');
+    }
+}
+
+done_testing;
+

commit d3993461d6c9505fbeeca19e68b763366c6d2114
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 18:41:26 2017 +0000

    Implementation and tests for modifying multi-member roles

diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index 588f840..73b18ae 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -40,13 +40,13 @@ sub update_record {
     my $self = shift;
     my $data = shift;
 
-    # XXX TODO: ->Update doesn't handle roles
     my @results = $self->record->Update(
         ARGSRef       => $data,
         AttributesRef => [ $self->record->WritableAttributes ],
     );
 
     push @results, $self->_update_custom_fields($data->{CustomFields});
+    push @results, $self->_update_role_members($data);
 
     # XXX TODO: Figure out how to return success/failure?  Core RT::Record's
     # ->Update will need to be replaced or improved.
@@ -132,6 +132,100 @@ sub _update_custom_fields {
     return @results;
 }
 
+sub _update_role_members {
+    my $self = shift;
+    my $data = shift;
+
+    my $record = $self->record;
+
+    return unless $record->DOES('RT::Record::Role::Roles');
+
+    my @results;
+
+    foreach my $role ($record->Roles) {
+        next unless exists $data->{$role};
+
+        # special case: RT::Ticket->Update already handles Owner for us
+        next if $role eq 'Owner' && $record->isa('RT::Ticket');
+
+        my $val = $data->{$role};
+
+        if ($record->Role($role)->{Single}) {
+            if (ref($val) eq 'ARRAY') {
+                $val = $val->[0];
+            }
+            elsif (ref($val)) {
+                die "Invalid value type for role $role";
+            }
+
+            my ($ok, $msg) = $record->AddWatcher(
+                Type => $role,
+                User => $val,
+            );
+            push @results, $msg;
+        }
+        else {
+            my %count;
+            my @vals;
+
+            for (ref($val) eq 'ARRAY' ? @$val : $val) {
+                my $key = 'User';
+                $key = 'PrincipalId' if /^\d+$/;
+                my ($principal, $msg) = $record->CanonicalizePrincipal($key => $_);
+                if (!$principal) {
+                    push @results, $msg;
+                    next;
+                }
+
+                push @vals, $principal->Id;
+                $count{$principal->Id}++;
+            }
+
+            my $group = $record->RoleGroup($role);
+            my $members = $group->MembersObj;
+            while (my $member = $members->Next) {
+                $count{$member->MemberId}--;
+            }
+
+            # RT::Ticket has specialized methods
+            my $add_method = $record->can('AddWatcher') ? 'AddWatcher' : 'AddRoleMember';
+            my $del_method = $record->can('DeleteWatcher') ? 'DeleteWatcher' : 'DeleteRoleMember';
+
+            # we want to provide a stable order, so first go by the order
+            # provided in the argument list, and then for any role members
+            # that are being removed, remove in sorted order
+            for my $id (uniq(@vals, sort keys %count)) {
+                my $count = $count{$id};
+                if ($count == 0) {
+                    # new == old, no change needed
+                }
+                elsif ($count > 0) {
+                    # new > old, need to add new
+                    while ($count-- > 0) {
+                        my ($ok, $msg) = $record->$add_method(
+                            Type        => $role,
+                            PrincipalId => $id,
+                        );
+                        push @results, $msg;
+                    }
+                }
+                elsif ($count < 0) {
+                    # old > new, need to remove old
+                    while ($count++ < 0) {
+                        my ($ok, $msg) = $record->$del_method(
+                            Type        => $role,
+                            PrincipalId => $id,
+                        );
+                        push @results, $msg;
+                    }
+                }
+            }
+        }
+    }
+
+    return @results;
+}
+
 sub update_resource {
     my $self = shift;
     my $data = shift;
diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
index 2e254b2..a490855 100644
--- a/t/ticket-watchers.t
+++ b/t/ticket-watchers.t
@@ -239,5 +239,223 @@ $user->PrincipalObj->GrantRight( Right => $_ )
     }
 }
 
+# Modify multi-member roles
+{
+    my $payload = {
+        Subject   => 'Ticket for modifying watchers',
+        Queue     => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [], 'no Requestor');
+    cmp_deeply($content->{Cc}, [], 'no Cc');
+    cmp_deeply($content->{AdminCc}, [], 'no AdminCc');
+
+    $payload = {
+        Requestor => 'requestor at example.com',
+        Cc        => 'cc at example.com',
+        AdminCc   => 'admincc at example.com',
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Added cc at example.com as Cc for this ticket', 'Added requestor at example.com as Requestor for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [{
+        type => 'user',
+        id   => 'requestor at example.com',
+        _url => re(qr{$rest_base_path/user/requestor\@example\.com$}),
+    }], 'one Requestor');
+
+    cmp_deeply($content->{Cc}, [{
+        type => 'user',
+        id   => 'cc at example.com',
+        _url => re(qr{$rest_base_path/user/cc\@example\.com$}),
+    }], 'one Cc');
+
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    }], 'one AdminCc');
+
+    $payload = {
+        Requestor => ['requestor2 at example.com'],
+        Cc        => ['cc2 at example.com'],
+        AdminCc   => ['admincc2 at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added admincc2 at example.com as AdminCc for this ticket', 'admincc at example.com is no longer AdminCc for this ticket', 'Added cc2 at example.com as Cc for this ticket', 'cc at example.com is no longer Cc for this ticket', 'Added requestor2 at example.com as Requestor for this ticket', 'requestor at example.com is no longer Requestor for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [{
+        type => 'user',
+        id   => 'requestor2 at example.com',
+        _url => re(qr{$rest_base_path/user/requestor2\@example\.com$}),
+    }], 'new Requestor');
+
+    cmp_deeply($content->{Cc}, [{
+        type => 'user',
+        id   => 'cc2 at example.com',
+        _url => re(qr{$rest_base_path/user/cc2\@example\.com$}),
+    }], 'new Cc');
+
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc2 at example.com',
+        _url => re(qr{$rest_base_path/user/admincc2\@example\.com$}),
+    }], 'new AdminCc');
+
+    $payload = {
+        Requestor => ['requestor at example.com', 'requestor2 at example.com'],
+        Cc        => ['cc at example.com', 'cc2 at example.com'],
+        AdminCc   => ['admincc at example.com', 'admincc2 at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Added cc at example.com as Cc for this ticket', 'Added requestor at example.com as Requestor for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{Requestor}, [{
+        type => 'user',
+        id   => 'requestor2 at example.com',
+        _url => re(qr{$rest_base_path/user/requestor2\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'requestor at example.com',
+        _url => re(qr{$rest_base_path/user/requestor\@example\.com$}),
+    }], 'two Requestors');
+
+    cmp_deeply($content->{Cc}, [{
+        type => 'user',
+        id   => 'cc2 at example.com',
+        _url => re(qr{$rest_base_path/user/cc2\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'cc at example.com',
+        _url => re(qr{$rest_base_path/user/cc\@example\.com$}),
+    }], 'two Ccs');
+
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc2 at example.com',
+        _url => re(qr{$rest_base_path/user/admincc2\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    }], 'two AdminCcs');
+
+    my $users = RT::Users->new(RT->SystemUser);
+    $users->UnLimit;
+    my %user_id = map { $_->Name => $_->Id } @{ $users->ItemsArrayRef };
+
+    my @stable_payloads = (
+    {
+        Subject => 'no changes to watchers',
+        _messages => ["Ticket 5: Subject changed from 'Ticket for modifying watchers' to 'no changes to watchers'"],
+        _name => 'no watcher keys',
+    },
+    {
+        Requestor => ['requestor at example.com', 'requestor2 at example.com'],
+        Cc        => ['cc at example.com', 'cc2 at example.com'],
+        AdminCc   => ['admincc at example.com', 'admincc2 at example.com'],
+        _name     => 'identical watcher values',
+    },
+    {
+        Requestor => ['requestor2 at example.com', 'requestor at example.com'],
+        Cc        => ['cc2 at example.com', 'cc at example.com'],
+        AdminCc   => ['admincc2 at example.com', 'admincc at example.com'],
+        _name     => 'out of order watcher values',
+    },
+    {
+        Requestor => [$user_id{'requestor2 at example.com'}, $user_id{'requestor at example.com'}],
+        Cc        => [$user_id{'cc2 at example.com'}, $user_id{'cc at example.com'}],
+        AdminCc   => [$user_id{'admincc2 at example.com'}, $user_id{'admincc at example.com'}],
+        _name     => 'watcher ids instead of names',
+    });
+
+    for my $payload (@stable_payloads) {
+        my $messages = delete $payload->{_messages} || [];
+        my $name = delete $payload->{_name} || '(undef)';
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, $messages, "watchers are preserved when $name");
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+        $content = $mech->json_response;
+        cmp_deeply($content->{Requestor}, [{
+            type => 'user',
+            id   => 'requestor2 at example.com',
+            _url => re(qr{$rest_base_path/user/requestor2\@example\.com$}),
+        }, {
+            type => 'user',
+            id   => 'requestor at example.com',
+            _url => re(qr{$rest_base_path/user/requestor\@example\.com$}),
+        }], "preserved two Requestors when $name");
+
+        cmp_deeply($content->{Cc}, [{
+            type => 'user',
+            id   => 'cc2 at example.com',
+            _url => re(qr{$rest_base_path/user/cc2\@example\.com$}),
+        }, {
+            type => 'user',
+            id   => 'cc at example.com',
+            _url => re(qr{$rest_base_path/user/cc\@example\.com$}),
+        }], "preserved two Ccs when $name");
+
+        cmp_deeply($content->{AdminCc}, [{
+            type => 'user',
+            id   => 'admincc2 at example.com',
+            _url => re(qr{$rest_base_path/user/admincc2\@example\.com$}),
+        }, {
+            type => 'user',
+            id   => 'admincc at example.com',
+            _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+        }], "preserved two AdminCcs when $name");
+    }
+}
+
 done_testing;
 

commit a7b28956057de8cf07e6a573a9de3103451fe29d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 18:59:35 2017 +0000

    Switch to using a variable for CF IDs in test

diff --git a/t/ticket-customfields.t b/t/ticket-customfields.t
index 40fd217..2467899 100644
--- a/t/ticket-customfields.t
+++ b/t/ticket-customfields.t
@@ -14,10 +14,12 @@ my $queue = RT::Test->load_or_create_queue( Name => "General" );
 my $single_cf = RT::CustomField->new( RT->SystemUser );
 my ($ok, $msg) = $single_cf->Create( Name => 'Freeform', Type => 'FreeformSingle', Queue => $queue->Id );
 ok($ok, $msg);
+my $single_cf_id = $single_cf->Id;
 
 my $multi_cf = RT::CustomField->new( RT->SystemUser );
 ($ok, $msg) = $multi_cf->Create( Name => 'Multi', Type => 'FreeformMultiple', Queue => $queue->Id );
 ok($ok, $msg);
+my $multi_cf_id = $multi_cf->Id;
 
 # Ticket Creation with no ModifyCustomField
 my ($ticket_url, $ticket_id);
@@ -29,7 +31,7 @@ my ($ticket_url, $ticket_id);
         Queue   => 'General',
         Content => 'Testing ticket creation using REST API.',
         CustomFields => {
-            $single_cf->Id => 'Hello world!',
+            $single_cf_id => 'Hello world!',
         },
     };
 
@@ -104,7 +106,7 @@ my ($ticket_url, $ticket_id);
     is($content->{Type}, 'ticket');
     is($content->{Status}, 'new');
     is($content->{Subject}, 'Ticket creation using REST');
-    is_deeply($content->{CustomFields}, { $single_cf->Id => [], $multi_cf->Id => [] }, 'No ticket custom field values');
+    is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No ticket custom field values');
 }
 
 # Ticket Update without ModifyCustomField
@@ -113,7 +115,7 @@ my ($ticket_url, $ticket_id);
         Subject  => 'Ticket update using REST',
         Priority => 42,
         CustomFields => {
-            $single_cf->Id => 'Modified CF',
+            $single_cf_id => 'Modified CF',
         },
     };
 
@@ -145,7 +147,7 @@ my ($ticket_url, $ticket_id);
     my $content = $mech->json_response;
     is($content->{Subject}, 'Ticket update using REST');
     is($content->{Priority}, 42);
-    is_deeply($content->{CustomFields}, { $single_cf->Id => [], $multi_cf->Id => [] }, 'No update to CF');
+    is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No update to CF');
 }
 
 # Ticket Update with ModifyCustomField
@@ -155,7 +157,7 @@ my ($ticket_url, $ticket_id);
         Subject  => 'More updates using REST',
         Priority => 43,
         CustomFields => {
-            $single_cf->Id => 'Modified CF',
+            $single_cf_id => 'Modified CF',
         },
     };
     my $res = $mech->put_json($ticket_url,
@@ -173,10 +175,10 @@ my ($ticket_url, $ticket_id);
     my $content = $mech->json_response;
     is($content->{Subject}, 'More updates using REST');
     is($content->{Priority}, 43);
-    is_deeply($content->{CustomFields}, { $single_cf->Id => ['Modified CF'], $multi_cf->Id => [] }, 'New CF value');
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified CF'], $multi_cf_id => [] }, 'New CF value');
 
     # make sure changing the CF doesn't add a second OCFV
-    $payload->{CustomFields}{$single_cf->Id} = 'Modified Again';
+    $payload->{CustomFields}{$single_cf_id} = 'Modified Again';
     $res = $mech->put_json($ticket_url,
         $payload,
         'Authorization' => $auth,
@@ -190,10 +192,10 @@ my ($ticket_url, $ticket_id);
     is($res->code, 200);
 
     $content = $mech->json_response;
-    is_deeply($content->{CustomFields}, { $single_cf->Id => ['Modified Again'], $multi_cf->Id => [] }, 'New CF value');
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'New CF value');
 
     # stop changing the CF, change something else, make sure CF sticks around
-    delete $payload->{CustomFields}{$single_cf->Id};
+    delete $payload->{CustomFields}{$single_cf_id};
     $payload->{Subject} = 'No CF change';
     $res = $mech->put_json($ticket_url,
         $payload,
@@ -208,7 +210,7 @@ my ($ticket_url, $ticket_id);
     is($res->code, 200);
 
     $content = $mech->json_response;
-    is_deeply($content->{CustomFields}, { $single_cf->Id => ['Modified Again'], $multi_cf->Id => [] }, 'Same CF value');
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'Same CF value');
 }
 
 # Ticket Creation with ModifyCustomField
@@ -220,7 +222,7 @@ my ($ticket_url, $ticket_id);
         Queue   => 'General',
         Content => 'Testing ticket creation using REST API.',
         CustomFields => {
-            $single_cf->Id => 'Hello world!',
+            $single_cf_id => 'Hello world!',
         },
     };
 
@@ -245,7 +247,7 @@ my ($ticket_url, $ticket_id);
     is($content->{Type}, 'ticket');
     is($content->{Status}, 'new');
     is($content->{Subject}, 'Ticket creation using REST');
-    is_deeply($content->{'CustomFields'}{$single_cf->Id}, ['Hello world!'], 'Ticket custom field');
+    is_deeply($content->{'CustomFields'}{$single_cf_id}, ['Hello world!'], 'Ticket custom field');
 }
 
 # Ticket Creation for multi-value CF
@@ -258,7 +260,7 @@ for my $value (
         Subject => 'Multi-value CF',
         Queue   => 'General',
         CustomFields => {
-            $multi_cf->Id => $value,
+            $multi_cf_id => $value,
         },
     };
 
@@ -282,7 +284,7 @@ for my $value (
     is($content->{Subject}, 'Multi-value CF');
 
     my $output = ref($value) ? $value : [$value]; # scalar input comes out as array reference
-    is_deeply($content->{'CustomFields'}, { $multi_cf->Id => $output, $single_cf->Id => [] }, 'Ticket custom field');
+    is_deeply($content->{'CustomFields'}, { $multi_cf_id => $output, $single_cf_id => [] }, 'Ticket custom field');
 }
 
 {
@@ -295,7 +297,7 @@ for my $value (
 
         my $payload = {
             CustomFields => {
-                $multi_cf->Id => $input,
+                $multi_cf_id => $input,
             },
         };
         my $res = $mech->put_json($ticket_url,
@@ -311,7 +313,7 @@ for my $value (
         is($res->code, 200);
 
         my $content = $mech->json_response;
-        my @values = sort @{ $content->{CustomFields}{$multi_cf->Id} };
+        my @values = sort @{ $content->{CustomFields}{$multi_cf_id} };
         is_deeply(\@values, $output, $name || 'New CF value');
     }
 

commit cb014a6bbda1c474c7b8a936fb758daf2adc9f0b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 19:42:30 2017 +0000

    CustomField endpoints

diff --git a/lib/RT/Extension/REST2/Resource/CustomField.pm b/lib/RT/Extension/REST2/Resource/CustomField.pm
new file mode 100644
index 0000000..904424b
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/CustomField.pm
@@ -0,0 +1,27 @@
+package RT::Extension::REST2::Resource::CustomField;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Record';
+with 'RT::Extension::REST2::Resource::Record::Readable',
+     'RT::Extension::REST2::Resource::Record::Hypermedia';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customfield/?$},
+        block => sub { { record_class => 'RT::CustomField' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customfield/(\d+)/?$},
+        block => sub { { record_class => 'RT::CustomField', record_id => shift->pos(1) } },
+    )
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
+
diff --git a/lib/RT/Extension/REST2/Resource/CustomFields.pm b/lib/RT/Extension/REST2/Resource/CustomFields.pm
new file mode 100644
index 0000000..e227dca
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/CustomFields.pm
@@ -0,0 +1,21 @@
+package RT::Extension::REST2::Resource::CustomFields;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Collection';
+with 'RT::Extension::REST2::Resource::Collection::QueryByJSON';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customfields/?$},
+        block => sub { { collection_class => 'RT::CustomFields' } },
+    ),
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+

commit bb92e233ecbfd72880df0c70ff5d1f287f2f0f01
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 19:42:55 2017 +0000

    Add hypermedia entry for each CF

diff --git a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
index e8aef68..1d5ffcc 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
@@ -9,7 +9,7 @@ use JSON qw(to_json);
 
 sub hypermedia_links {
     my $self = shift;
-    return [ $self->_self_link, $self->_rtlink_links ];
+    return [ $self->_self_link, $self->_rtlink_links, $self->_customfield_links ];
 }
 
 sub _self_link {
@@ -72,5 +72,22 @@ sub _rtlink_links {
     return @links;
 }
 
+sub _customfield_links {
+    my $self = shift;
+    my $record = $self->record;
+    my @links;
+
+    my $cfs = $record->CustomFields;
+    while (my $cf = $cfs->Next) {
+        my $entry = expand_uid($cf->UID);
+        push @links, {
+            %$entry,
+            ref => 'customfield',
+        };
+    }
+
+    return @links;
+}
+
 1;
 
diff --git a/t/ticket-customfields.t b/t/ticket-customfields.t
index 2467899..fb5fcca 100644
--- a/t/ticket-customfields.t
+++ b/t/ticket-customfields.t
@@ -2,6 +2,7 @@ use strict;
 use warnings;
 use lib 't/lib';
 use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
 
 my $mech = RT::Extension::REST2::Test->mech;
 
@@ -12,7 +13,7 @@ my $user = RT::Extension::REST2::Test->user;
 my $queue = RT::Test->load_or_create_queue( Name => "General" );
 
 my $single_cf = RT::CustomField->new( RT->SystemUser );
-my ($ok, $msg) = $single_cf->Create( Name => 'Freeform', Type => 'FreeformSingle', Queue => $queue->Id );
+my ($ok, $msg) = $single_cf->Create( Name => 'Single', Type => 'FreeformSingle', Queue => $queue->Id );
 ok($ok, $msg);
 my $single_cf_id = $single_cf->Id;
 
@@ -90,6 +91,7 @@ my ($ticket_url, $ticket_id);
     is($content->{Status}, 'new');
     is($content->{Subject}, 'Ticket creation using REST');
     is_deeply($content->{'CustomFields'}, {}, 'Ticket custom field not present');
+    is_deeply([grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} }], [], 'No CF hypermedia');
 }
 
 # Rights Test - With ShowTicket and SeeCustomField
@@ -107,6 +109,50 @@ my ($ticket_url, $ticket_id);
     is($content->{Status}, 'new');
     is($content->{Subject}, 'Ticket creation using REST');
     is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No ticket custom field values');
+    cmp_deeply(
+        [grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} }],
+        [{
+            ref => 'customfield',
+            id  => $single_cf_id,
+            type => 'customfield',
+            _url => re(qr[$rest_base_path/customfield/$single_cf_id$]),
+        }, {
+            ref => 'customfield',
+            id  => $multi_cf_id,
+            type => 'customfield',
+            _url => re(qr[$rest_base_path/customfield/$multi_cf_id$]),
+        }],
+        'Two CF hypermedia',
+    );
+
+    my ($single_url) = map { $_->{_url} } grep { $_->{ref} eq 'customfield' && $_->{id} == $single_cf_id } @{ $content->{'_hyperlinks'} };
+    my ($multi_url) = map { $_->{_url} } grep { $_->{ref} eq 'customfield' && $_->{id} == $multi_cf_id } @{ $content->{'_hyperlinks'} };
+
+    $res = $mech->get($single_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $single_cf_id,
+        Disabled   => 0,
+        LookupType => RT::Ticket->CustomFieldLookupType,
+        MaxValues  => 1,
+	Name       => 'Single',
+	Type       => 'Freeform',
+    }), 'single cf');
+
+    $res = $mech->get($multi_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $multi_cf_id,
+        Disabled   => 0,
+        LookupType => RT::Ticket->CustomFieldLookupType,
+        MaxValues  => 0,
+	Name       => 'Multi',
+	Type       => 'Freeform',
+    }), 'multi cf');
 }
 
 # Ticket Update without ModifyCustomField
@@ -165,7 +211,7 @@ my ($ticket_url, $ticket_id);
         'Authorization' => $auth,
     );
     is($res->code, 200);
-    is_deeply($mech->json_response, ["Ticket 1: Priority changed from '42' to '43'", "Ticket 1: Subject changed from 'Ticket update using REST' to 'More updates using REST'", 'Freeform Modified CF added']);
+    is_deeply($mech->json_response, ["Ticket 1: Priority changed from '42' to '43'", "Ticket 1: Subject changed from 'Ticket update using REST' to 'More updates using REST'", 'Single Modified CF added']);
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,
@@ -184,7 +230,7 @@ my ($ticket_url, $ticket_id);
         'Authorization' => $auth,
     );
     is($res->code, 200);
-    is_deeply($mech->json_response, ['Freeform Modified CF changed to Modified Again']);
+    is_deeply($mech->json_response, ['Single Modified CF changed to Modified Again']);
 
     $res = $mech->get($ticket_url,
         'Authorization' => $auth,

commit 5d2b5c0230983def954c76212bfa03bc5f50d749
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:16:36 2017 +0000

    Add resources for custom roles

diff --git a/lib/RT/Extension/REST2/Resource/CustomRole.pm b/lib/RT/Extension/REST2/Resource/CustomRole.pm
new file mode 100644
index 0000000..c659c92
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/CustomRole.pm
@@ -0,0 +1,26 @@
+package RT::Extension::REST2::Resource::CustomRole;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Record';
+with 'RT::Extension::REST2::Resource::Record::Readable',
+     'RT::Extension::REST2::Resource::Record::Hypermedia';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customrole/?$},
+        block => sub { { record_class => 'RT::CustomRole' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customrole/(\d+)/?$},
+        block => sub { { record_class => 'RT::CustomRole', record_id => shift->pos(1) } },
+    )
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/lib/RT/Extension/REST2/Resource/CustomRoles.pm b/lib/RT/Extension/REST2/Resource/CustomRoles.pm
new file mode 100644
index 0000000..64ff8c9
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/CustomRoles.pm
@@ -0,0 +1,21 @@
+package RT::Extension::REST2::Resource::CustomRoles;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Collection';
+with 'RT::Extension::REST2::Resource::Collection::QueryByJSON';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/customroles/?$},
+        block => sub { { collection_class => 'RT::CustomRoles' } },
+    ),
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+

commit d65d32da024d6b77afedd9ea5f12fec9c2cbb43f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:16:59 2017 +0000

    Add custom role hypermedia

diff --git a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
index 1d5ffcc..3af34e0 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Hypermedia.pm
@@ -9,7 +9,7 @@ use JSON qw(to_json);
 
 sub hypermedia_links {
     my $self = shift;
-    return [ $self->_self_link, $self->_rtlink_links, $self->_customfield_links ];
+    return [ $self->_self_link, $self->_rtlink_links, $self->_customfield_links, $self->_customrole_links ];
 }
 
 sub _self_link {
@@ -89,5 +89,30 @@ sub _customfield_links {
     return @links;
 }
 
+sub _customrole_links {
+    my $self = shift;
+    my $record = $self->record;
+    my @links;
+
+    return unless $record->DOES('RT::Record::Role::Roles');
+
+    for my $role ($record->Roles(UserDefined => 1)) {
+        if ($role =~ /^RT::CustomRole-(\d+)$/) {
+            my $cr = RT::CustomRole->new($record->CurrentUser);
+            $cr->Load($1);
+            if ($cr->Id) {
+                my $entry = expand_uid($cr->UID);
+                push @links, {
+                    %$entry,
+                    group_type => $cr->GroupType,
+                    ref => 'customrole',
+                };
+            }
+        }
+    }
+
+    return @links;
+}
+
 1;
 

commit d0d507c6e763ca3d755657cc7f189053305b4f52
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:17:22 2017 +0000

    Don't "clean up" custom role fields
    
    We can't rely simply on HasRole, unfortunately, because the empty
    TicketObj doesn't know which queue it will be created in, and so it
    can't ask which custom roles apply to it.
    
    So instead use a simple heuristic: treat anything RT::CustomRole-### as
    a role, even if it may not be a legitimate parameter

diff --git a/lib/RT/Extension/REST2/Util.pm b/lib/RT/Extension/REST2/Util.pm
index a15c52c..2a4f4ce 100644
--- a/lib/RT/Extension/REST2/Util.pm
+++ b/lib/RT/Extension/REST2/Util.pm
@@ -141,7 +141,7 @@ sub deserialize_record {
             # points to the same record type (class).
             $data->{$field} = $value->{id} || 0;
         }
-        elsif ($does_roles and $record->HasRole($field)) {
+        elsif ($does_roles and ($field =~ /^RT::CustomRole-\d+$/ or $record->HasRole($field))) {
             my @members = ref $value eq 'ARRAY'
                 ? @$value : $value;
 

commit b02decbe71313b3fcdc3099104c20ceec17b7203
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:18:40 2017 +0000

    Custom role tests

diff --git a/t/ticket-customroles.t b/t/ticket-customroles.t
new file mode 100644
index 0000000..80e3fba
--- /dev/null
+++ b/t/ticket-customroles.t
@@ -0,0 +1,404 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
+
+my $mech = RT::Extension::REST2::Test->mech;
+
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+my $queue = RT::Test->load_or_create_queue( Name => "General" );
+
+my $single = RT::CustomRole->new(RT->SystemUser);
+my ($ok, $msg) = $single->Create(Name => 'Single Member', MaxValues => 1);
+ok($ok, $msg);
+my $single_id = $single->Id;
+
+($ok, $msg) = $single->AddToObject($queue->id);
+ok($ok, $msg);
+
+my $multi = RT::CustomRole->new(RT->SystemUser);
+($ok, $msg) = $multi->Create(Name => 'Multi Member');
+ok($ok, $msg);
+my $multi_id = $multi->Id;
+
+($ok, $msg) = $multi->AddToObject($queue->id);
+ok($ok, $msg);
+
+$user->PrincipalObj->GrantRight( Right => $_ )
+    for qw/CreateTicket ShowTicket ModifyTicket OwnTicket AdminUsers/;
+
+# Create and view ticket with no watchers
+{
+    my $payload = {
+        Subject => 'Ticket with no watchers',
+        Queue   => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [], 'no Multi Member');
+    cmp_deeply($content->{$single->GroupType}, {
+        type => 'user',
+        id   => 'Nobody',
+        _url => re(qr{$rest_base_path/user/Nobody$}),
+    }, 'Single Member is Nobody');
+
+    cmp_deeply(
+        [grep { $_->{ref} eq 'customrole' } @{ $content->{'_hyperlinks'} }],
+        [{
+            ref => 'customrole',
+            id  => $single_id,
+            type => 'customrole',
+            group_type => $single->GroupType,
+            _url => re(qr[$rest_base_path/customrole/$single_id$]),
+        }, {
+            ref => 'customrole',
+            id  => $multi_id,
+            type => 'customrole',
+            group_type => $multi->GroupType,
+            _url => re(qr[$rest_base_path/customrole/$multi_id$]),
+        }],
+        'Two CF hypermedia',
+    );
+
+    my ($single_url) = map { $_->{_url} } grep { $_->{ref} eq 'customrole' && $_->{id} == $single_id } @{ $content->{'_hyperlinks'} };
+    my ($multi_url) = map { $_->{_url} } grep { $_->{ref} eq 'customrole' && $_->{id} == $multi_id } @{ $content->{'_hyperlinks'} };
+
+    $res = $mech->get($content->{$single->GroupType}{_url},
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id => RT->Nobody->id,
+        Name => 'Nobody',
+        RealName => 'Nobody in particular',
+    }), 'Nobody user');
+
+    $res = $mech->get($single_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $single_id,
+        Disabled   => 0,
+        MaxValues  => 1,
+        Name       => 'Single Member',
+    }), 'single role');
+
+    $res = $mech->get($multi_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id         => $multi_id,
+        Disabled   => 0,
+        MaxValues  => 0,
+        Name       => 'Multi Member',
+    }), 'multi role');
+}
+
+# Create and view ticket with single users as watchers
+{
+    my $payload = {
+        Subject   => 'Ticket with single watchers',
+        Queue     => 'General',
+        $multi->GroupType  => 'multi at example.com',
+        $single->GroupType => 'test at localhost',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    }], 'one Multi Member');
+
+    cmp_deeply($content->{$single->GroupType}, {
+        type => 'user',
+        id   => 'test',
+        _url => re(qr{$rest_base_path/user/test$}),
+    }, 'one Single Member');
+}
+
+# Create and view ticket with multiple users as watchers
+{
+    my $payload = {
+        Subject   => 'Ticket with multiple watchers',
+        Queue     => 'General',
+        $multi->GroupType  => ['multi at example.com', 'multi2 at example.com'],
+        $single->GroupType => ['test at localhost', 'single2 at example.com'],
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'multi2 at example.com',
+        _url => re(qr{$rest_base_path/user/multi2\@example\.com$}),
+    }], 'two Multi Members');
+
+    cmp_deeply($content->{$single->GroupType}, {
+        type => 'user',
+        id   => 'test',
+        _url => re(qr{$rest_base_path/user/test$}),
+    }, 'one Single Member');
+}
+
+# Modify single-member role
+{
+    my $payload = {
+        Subject   => 'Ticket for modifying Single Member',
+        Queue     => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    cmp_deeply($mech->json_response->{$single->GroupType}, {
+        type => 'user',
+        id   => 'Nobody',
+        _url => re(qr{$rest_base_path/user/Nobody$}),
+    }, 'Single Member is Nobody');
+
+    for my $identifier ($user->id, $user->Name) {
+        $payload = {
+            $single->GroupType => $identifier,
+        };
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, ["Single Member changed from Nobody to test"], "updated Single Member with identifier $identifier");
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+
+        cmp_deeply($mech->json_response->{$single->GroupType}, {
+            type => 'user',
+            id   => 'test',
+            _url => re(qr{$rest_base_path/user/test$}),
+        }, 'Single Member has changed to test');
+
+        $payload = {
+            $single->GroupType => 'Nobody',
+        };
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, ["Single Member changed from test to Nobody"], 'updated Single Member');
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+
+        cmp_deeply($mech->json_response->{$single->GroupType}, {
+            type => 'user',
+            id   => 'Nobody',
+            _url => re(qr{$rest_base_path/user/Nobody$}),
+        }, 'Single Member has changed to Nobody');
+    }
+}
+
+# Modify multi-member roles
+{
+    my $payload = {
+        Subject => 'Ticket for modifying watchers',
+        Queue   => 'General',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [], 'no Multi Member');
+
+    $payload = {
+        $multi->GroupType => 'multi at example.com',
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added multi at example.com as Multi Member for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    }], 'one Multi Member');
+
+    $payload = {
+        $multi->GroupType => ['multi2 at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added multi2 at example.com as Multi Member for this ticket', 'multi at example.com is no longer Multi Member for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi2 at example.com',
+        _url => re(qr{$rest_base_path/user/multi2\@example\.com$}),
+    }], 'new Multi Member');
+
+    $payload = {
+        $multi->GroupType => ['multi at example.com', 'multi2 at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added multi at example.com as Multi Member for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi2 at example.com',
+        _url => re(qr{$rest_base_path/user/multi2\@example\.com$}),
+    }, {
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    }], 'two Multi Member');
+
+    my $users = RT::Users->new(RT->SystemUser);
+    $users->UnLimit;
+    my %user_id = map { $_->Name => $_->Id } @{ $users->ItemsArrayRef };
+
+    my @stable_payloads = (
+    {
+        Subject => 'no changes to watchers',
+        _messages => ["Ticket 5: Subject changed from 'Ticket for modifying watchers' to 'no changes to watchers'"],
+        _name => 'no watcher keys',
+    },
+    {
+        $multi->GroupType => ['multi at example.com', 'multi2 at example.com'],
+        _name => 'identical watcher values',
+    },
+    {
+        $multi->GroupType => ['multi2 at example.com', 'multi at example.com'],
+        _name => 'out of order watcher values',
+    },
+    {
+        $multi->GroupType => [$user_id{'multi2 at example.com'}, $user_id{'multi at example.com'}],
+        _name => 'watcher ids instead of names',
+    });
+
+    for my $payload (@stable_payloads) {
+        my $messages = delete $payload->{_messages} || [];
+        my $name = delete $payload->{_name} || '(undef)';
+
+        $res = $mech->put_json($ticket_url,
+            $payload,
+            'Authorization' => $auth,
+        );
+        is_deeply($mech->json_response, $messages, "watchers are preserved when $name");
+
+        $res = $mech->get($ticket_url,
+            'Authorization' => $auth,
+        );
+        is($res->code, 200);
+        $content = $mech->json_response;
+        cmp_deeply($content->{$multi->GroupType}, [{
+            type => 'user',
+            id   => 'multi2 at example.com',
+            _url => re(qr{$rest_base_path/user/multi2\@example\.com$}),
+        }, {
+            type => 'user',
+            id   => 'multi at example.com',
+            _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+        }], "preserved two Multi Members when $name");
+    }
+}
+
+done_testing;
+

commit 2e913aaa41c78edc1f09f74f61eceb7a91c3a630
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:27:47 2017 +0000

    Tests for groups as core&custom role members

diff --git a/t/ticket-customroles.t b/t/ticket-customroles.t
index 80e3fba..805ea26 100644
--- a/t/ticket-customroles.t
+++ b/t/ticket-customroles.t
@@ -400,5 +400,92 @@ $user->PrincipalObj->GrantRight( Right => $_ )
     }
 }
 
+# groups as members
+{
+    my $group = RT::Group->new(RT->SystemUser);
+    my ($ok, $msg) = $group->CreateUserDefinedGroup(Name => 'Watcher Group');
+    ok($ok, $msg);
+    my $group_id = $group->Id;
+
+    for my $email ('multi at example.com', 'multi2 at example.com') {
+        my $user = RT::User->new(RT->SystemUser);
+        $user->LoadByEmail($email);
+        $group->AddMember($user->PrincipalId);
+    }
+
+    my $payload = {
+        Subject           => 'Ticket for modifying watchers',
+        Queue             => 'General',
+        $multi->GroupType => $group->PrincipalId,
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        id   => $group_id,
+        type => 'group',
+        _url => re(qr{$rest_base_path/group/$group_id$}),
+    }], 'group Multi Member');
+
+    $payload = {
+        $multi->GroupType => 'multi at example.com',
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added multi at example.com as Multi Member for this ticket', 'Watcher Group is no longer Multi Member for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    }], 'one Multi Member user');
+
+    $payload = {
+        $multi->GroupType => [$group_id, 'multi at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added Watcher Group as Multi Member for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{$multi->GroupType}, [{
+        type => 'user',
+        id   => 'multi at example.com',
+        _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+    },
+    {
+        id   => $group_id,
+        type => 'group',
+        _url => re(qr{$rest_base_path/group/$group_id$}),
+    }], 'Multi Member user and group');
+}
+
 done_testing;
 
diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
index a490855..270b11c 100644
--- a/t/ticket-watchers.t
+++ b/t/ticket-watchers.t
@@ -457,5 +457,92 @@ $user->PrincipalObj->GrantRight( Right => $_ )
     }
 }
 
+# groups as members
+{
+    my $group = RT::Group->new(RT->SystemUser);
+    my ($ok, $msg) = $group->CreateUserDefinedGroup(Name => 'Watcher Group');
+    ok($ok, $msg);
+    my $group_id = $group->Id;
+
+    for my $email ('admincc at example.com', 'admincc2 at example.com') {
+        my $user = RT::User->new(RT->SystemUser);
+        $user->LoadByEmail($email);
+        $group->AddMember($user->PrincipalId);
+    }
+
+    my $payload = {
+        Subject   => 'Ticket for modifying watchers',
+        Queue     => 'General',
+        AdminCc   => $group->PrincipalId,
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok(my $ticket_url = $res->header('location'));
+    ok((my $ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    my $content = $mech->json_response;
+    cmp_deeply($content->{AdminCc}, [{
+        id   => $group_id,
+        type => 'group',
+        _url => re(qr{$rest_base_path/group/$group_id$}),
+    }], 'group AdminCc');
+
+    $payload = {
+        AdminCc => 'admincc at example.com',
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added admincc at example.com as AdminCc for this ticket', 'Watcher Group is no longer AdminCc for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    }], 'one AdminCc user');
+
+    $payload = {
+        AdminCc => [$group_id, 'admincc at example.com'],
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is_deeply($mech->json_response, ['Added Watcher Group as AdminCc for this ticket'], "updated ticket watchers");
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    $content = $mech->json_response;
+    cmp_deeply($content->{AdminCc}, [{
+        type => 'user',
+        id   => 'admincc at example.com',
+        _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+    },
+    {
+        id   => $group_id,
+        type => 'group',
+        _url => re(qr{$rest_base_path/group/$group_id$}),
+    }], 'AdminCc user and group');
+}
+
 done_testing;
 

commit 5e00b93664fd4ab7760de0c185760ab61be15fb3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:43:59 2017 +0000

    Group and Groups resources

diff --git a/lib/RT/Extension/REST2/Resource/Group.pm b/lib/RT/Extension/REST2/Resource/Group.pm
new file mode 100644
index 0000000..fa423cf
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Group.pm
@@ -0,0 +1,40 @@
+package RT::Extension::REST2::Resource::Group;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+use RT::Extension::REST2::Util qw(expand_uid);
+
+extends 'RT::Extension::REST2::Resource::Record';
+with 'RT::Extension::REST2::Resource::Record::Readable'
+        => { -alias => { serialize => '_default_serialize' } },
+     'RT::Extension::REST2::Resource::Record::Hypermedia';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/group/?$},
+        block => sub { { record_class => 'RT::Group' } },
+    ),
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/group/(\d+)/?$},
+        block => sub { { record_class => 'RT::Group', record_id => shift->pos(1) } },
+    )
+}
+
+sub serialize {
+    my $self = shift;
+    my $data = $self->_default_serialize(@_);
+
+    $data->{Members} = [
+        map { expand_uid($_->MemberObj->Object->UID) }
+        @{ $self->record->MembersObj->ItemsArrayRef }
+    ];
+
+    return $data;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/lib/RT/Extension/REST2/Resource/Groups.pm b/lib/RT/Extension/REST2/Resource/Groups.pm
new file mode 100644
index 0000000..8b928f9
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/Groups.pm
@@ -0,0 +1,21 @@
+package RT::Extension::REST2::Resource::Groups;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::Extension::REST2::Resource::Collection';
+with 'RT::Extension::REST2::Resource::Collection::QueryByJSON';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/groups/?$},
+        block => sub { { collection_class => 'RT::Groups' } },
+    ),
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/t/ticket-customroles.t b/t/ticket-customroles.t
index 805ea26..a87a749 100644
--- a/t/ticket-customroles.t
+++ b/t/ticket-customroles.t
@@ -485,6 +485,27 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         type => 'group',
         _url => re(qr{$rest_base_path/group/$group_id$}),
     }], 'Multi Member user and group');
+
+    $res = $mech->get($content->{$multi->GroupType}[1]{_url},
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id           => $group->Id,
+        Name         => 'Watcher Group',
+        Domain       => 'UserDefined',
+        CustomFields => {},
+        Members      => [{
+            type => 'user',
+            id   => 'multi at example.com',
+            _url => re(qr{$rest_base_path/user/multi\@example\.com$}),
+        },
+        {
+            type => 'user',
+            id   => 'multi2 at example.com',
+            _url => re(qr{$rest_base_path/user/multi2\@example\.com$}),
+        }],
+    }), 'fetched group');
 }
 
 done_testing;
diff --git a/t/ticket-watchers.t b/t/ticket-watchers.t
index 270b11c..b145ef0 100644
--- a/t/ticket-watchers.t
+++ b/t/ticket-watchers.t
@@ -542,6 +542,27 @@ $user->PrincipalObj->GrantRight( Right => $_ )
         type => 'group',
         _url => re(qr{$rest_base_path/group/$group_id$}),
     }], 'AdminCc user and group');
+
+    $res = $mech->get($content->{AdminCc}[1]{_url},
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    cmp_deeply($mech->json_response, superhashof({
+        id           => $group->Id,
+        Name         => 'Watcher Group',
+        Domain       => 'UserDefined',
+        CustomFields => {},
+        Members      => [{
+            type => 'user',
+            id   => 'admincc at example.com',
+            _url => re(qr{$rest_base_path/user/admincc\@example\.com$}),
+        },
+        {
+            type => 'user',
+            id   => 'admincc2 at example.com',
+            _url => re(qr{$rest_base_path/user/admincc2\@example\.com$}),
+        }],
+    }), 'fetched group');
 }
 
 done_testing;

commit 5159c3fdefab807730854e41250f09ff9e4de0b4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Jul 5 21:44:39 2017 +0000

    Update TODO

diff --git a/TODO b/TODO
index cf9e98b..91febb6 100644
--- a/TODO
+++ b/TODO
@@ -1,23 +1,12 @@
 Find TODOs in the code via `ag TODO`.
 
-* Testing
 * Implement main endpoints:
-  - /tickets
-  - /users
-  - /groups
-  - /queues
   - /articles
-  - /customfields
   - /scrips
-  - /roles
   - /templates
 * Remove any session mocking
 * Add ETag and handle If-Match to support 409 Conflict responses leading
   to more robust client applications
 
-XXX TODO: Include Links in record serializations
-XXX TODO: Serialized resources for CFs, Links, Groups
-XXX TODO: History + attachments
 XXX TODO: Articles, Classes, Topics
-XXX TODO: Updating of roles and CFs (and links, etc)
-XXX TODO: Adding comments/replies
+XXX TODO: Updating of links

-----------------------------------------------------------------------


More information about the Bps-public-commit mailing list