[Rt-commit] rt branch, 5.0/rest2-article, created. rt-5.0.0-12-gd014198c6d

? sunnavy sunnavy at bestpractical.com
Wed Sep 2 14:12:55 EDT 2020


The branch, 5.0/rest2-article has been created
        at  d014198c6dc927a04cce6097df13cc8d74eff638 (commit)

- Log -----------------------------------------------------------------
commit c04e8fc88861ea4548fb36acf045b13df6892e0b
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Sep 1 22:55:27 2020 +0800

    Refactor code so we can add history endpoints to new classes more easily
    
    As possible values of $class are already limited in previous code, we
    can be sure that $package is always valid.

diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Transactions.pm
index 719a4e754a..8ecd83de85 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Transactions.pm
@@ -67,22 +67,8 @@ sub dispatch_rules {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));
 
-            my $record;
-            if ($class eq 'ticket') {
-                $record = RT::Ticket->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'asset') {
-                $record = RT::Asset->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'group') {
-                $record = RT::Group->new($req->env->{"rt.current_user"});
-            }
+            my $package = 'RT::' . ucfirst $class;
+            my $record = $package->new( $req->env->{"rt.current_user"} );
 
             $record->Load($id);
             return { collection => $record->Transactions };

commit 48673bfccd6d3eea808c6e196e376cbcf10a9a9e
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Sep 2 06:07:20 2020 +0800

    Avoid permission check to get CF type in CustomFieldValueIsEmpty
    
    This is initially to fix uninitialized warnings in RT::Article::Create,
    when people set a custom field they don't have right to see, in which
    case $cf->Type is undef.

diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index b3cc0787e4..a4ed13fc44 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -2173,7 +2173,7 @@ sub CustomFieldValueIsEmpty {
            : $self->LoadCustomFieldByIdentifier( $args{'Field'} );
 
     if ($cf) {
-        if ( $cf->Type =~ /^Date(?:Time)?$/ ) {
+        if ( $cf->__Value('Type') =~ /^Date(?:Time)?$/ ) {
             my $DateObj = RT::Date->new( $self->CurrentUser );
             $DateObj->Set(
                 Format => 'unknown',

commit 2a61c604e3cc8e3c8a02e3d4956bfe56128c7948
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu Sep 3 00:25:34 2020 +0800

    Avoid permission check to get CF CanonicalizeClass
    
    Similar to 68b6a66f7 that gets CF Type without permission check, we need
    it for CanonicalizeClass too, otherwise canonicalization wouldn't work
    for users that don't have global SeeCustomField right(e.g. at article
    class level).

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index a9169ec24f..ae1ac0bd19 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -1838,9 +1838,8 @@ sub _CanonicalizeValueWithCanonicalizer {
     my $self = shift;
     my $args = shift;
 
-    return 1 if !$self->CanonicalizeClass;
+    my $class = $self->__Value('CanonicalizeClass') or return 1;
 
-    my $class = $self->CanonicalizeClass;
     $class->require or die "Can't load $class: $@";
     my $canonicalizer = $class->new($self->CurrentUser);
 

commit 36bd4b9574064d59966a58aede837ac0e336e549
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Sep 1 23:04:28 2020 +0800

    Add basic article/class endpoints to REST2
    
    It supports CRUD and also basic searches.

diff --git a/lib/RT/REST2.pm b/lib/RT/REST2.pm
index 0f3fc7024b..1079f95cd6 100644
--- a/lib/RT/REST2.pm
+++ b/lib/RT/REST2.pm
@@ -638,6 +638,51 @@ Below are some examples using the endpoints above.
     DELETE /catalog/:name
         disable catalog
 
+=head3 Articles
+
+    GET /articles?query=<JSON>
+    POST /articles
+        search for articles using L</JSON searches> syntax
+
+    POST /article
+        create an article; provide JSON content
+
+    GET /article/:id
+        retrieve an article
+
+    PUT /article/:id
+        update an article's metadata; provide JSON content
+
+    DELETE /article/:id
+        set status to deleted
+
+    GET /article/:id/history
+        retrieve list of transactions for article
+
+=head3 Classes
+
+    GET /classes/all
+        retrieve list of all classes you can see
+
+    GET /classes?query=<JSON>
+    POST /classes
+        search for classes using L</JSON searches> syntax
+
+    POST /class
+        create a class; provide JSON content
+
+    GET /class/:id
+    GET /class/:name
+        retrieve a class by numeric id or name
+
+    PUT /class/:id
+    PUT /class/:name
+        update a class's metadata; provide JSON content
+
+    DELETE /class/:id
+    DELETE /class/:name
+        disable class
+
 =head3 Users
 
     GET /users?query=<JSON>
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Article.pm
similarity index 59%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Article.pm
index 8ecd83de85..eacfa0d75b 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Article.pm
@@ -46,52 +46,68 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Article;
 use strict;
 use warnings;
 
 use Moose;
 use namespace::autoclean;
 
-extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::QueryByJSON';
+extends 'RT::REST2::Resource::Record';
+with (
+    'RT::REST2::Resource::Record::Readable',
+    'RT::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
+    'RT::REST2::Resource::Record::Deletable',
+    'RT::REST2::Resource::Record::Writable'
+        => { -alias => { create_record => '_create_record' } },
+);
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/article/?$},
+        block => sub { { record_class => 'RT::Article' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+        regex => qr{^/article/(\d+)/?$},
+        block => sub { { record_class => 'RT::Article', record_id => shift->pos(1) } },
+    )
+}
 
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
+sub create_record {
+    my $self = shift;
+    my $data = shift;
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+    return (\400, "Invalid Class") if !$data->{Class};
 
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
+    my $class = RT::Class->new(RT->SystemUser);
+    $class->Load($data->{Class});
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
+    return (\400, "Invalid Class") if !$class->Id;
+
+    return ( \403, $self->record->loc("Permission Denied") )
+        unless $self->record->CurrentUser->HasRight(
+        Right  => 'CreateArticle',
+        Object => $class,
+        )
+        and $class->Disabled != 1;
+
+    my ($ok, $txn, $msg) = $self->_create_record($data);
+    return ($ok, $msg);
+}
+
+sub forbidden {
+    my $self = shift;
+    return 0 unless $self->record->id;
+    return !$self->record->CurrentUserHasRight('ShowArticle');
+}
+
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    push @$links, $self->_transaction_history_link;
+
+    return $links;
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Articles.pm
similarity index 64%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Articles.pm
index 8ecd83de85..2cdee8fe4c 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Articles.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Articles;
 use strict;
 use warnings;
 
@@ -58,39 +58,8 @@ with 'RT::REST2::Resource::Collection::QueryByJSON';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
+        regex => qr{^/articles/?$},
+        block => sub { { collection_class => 'RT::Articles' } },
     )
 }
 
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Class.pm
similarity index 56%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Class.pm
index 8ecd83de85..15f4adb650 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Class.pm
@@ -46,54 +46,82 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Class;
 use strict;
 use warnings;
 
 use Moose;
 use namespace::autoclean;
+use RT::REST2::Util qw(expand_uid);
 
-extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::QueryByJSON';
+extends 'RT::REST2::Resource::Record';
+with (
+    'RT::REST2::Resource::Record::Readable',
+    'RT::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
+    'RT::REST2::Resource::Record::DeletableByDisabling',
+    'RT::REST2::Resource::Record::Writable',
+);
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/class/?$},
+        block => sub { { record_class => 'RT::Class' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
+        regex => qr{^/class/(\d+)/?$},
+        block => sub { { record_class => 'RT::Class', record_id => shift->pos(1) } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
+        regex => qr{^/class/([^/]+)/?$},
         block => sub {
             my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+            my $class = RT::Class->new($req->env->{"rt.current_user"});
+            $class->Load($match->pos(1));
+            return { record => $class };
+        },
+    ),
+}
 
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    my $class = $self->record;
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
+    push @$links, {
+        ref  => 'create',
+        type => 'article',
+        _url => RT::REST2->base_uri . '/article?Class=' . $class->Id,
+    } if $class->CurrentUserHasRight('CreateArticle');
+
+    return $links;
 }
 
+around 'serialize' => sub {
+    my $orig = shift;
+    my $self = shift;
+    my $data = $self->$orig(@_);
+
+    # Load Article Custom Fields for this class
+    if ( my $article_cfs = $self->record->ArticleCustomFields ) {
+        my @values;
+        while (my $cf = $article_cfs->Next) {
+            my $entry = expand_uid($cf->UID);
+            my $content = {
+                %$entry,
+                ref      => 'customfield',
+                name     => $cf->Name,
+            };
+
+            push @values, $content;
+        }
+
+        $data->{ArticleCustomFields} = \@values;
+    }
+
+    return $data;
+};
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Classes.pm
similarity index 68%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Classes.pm
index 8ecd83de85..7f2b4a3210 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Classes.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Classes;
 use strict;
 use warnings;
 
@@ -58,40 +58,18 @@ with 'RT::REST2::Resource::Collection::QueryByJSON';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/classes/?$},
+        block => sub { { collection_class => 'RT::Classes' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
+        regex => qr{^/classes/all/?$},
         block => sub {
             my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
+            my $classes = RT::Classes->new($req->env->{"rt.current_user"});
+            $classes->UnLimit;
+            return { collection => $classes };
         },
     ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/Record/Writable.pm b/lib/RT/REST2/Resource/Record/Writable.pm
index 3ff69bdda0..8fdcb4bd57 100644
--- a/lib/RT/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/REST2/Resource/Record/Writable.pm
@@ -410,7 +410,7 @@ sub create_record {
     # if a record class handles CFs in ->Create, use it (so it doesn't generate
     # spurious transactions and interfere with default values, etc). Otherwise,
     # add OCFVs after ->Create
-    if ($record->isa('RT::Ticket') || $record->isa('RT::Asset')) {
+    if ($record->isa('RT::Ticket') || $record->isa('RT::Asset') || $record->isa('RT::Article') ) {
         if ($cfs) {
             while (my ($id, $value) = each(%$cfs)) {
                 delete $cfs->{$id};
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Transactions.pm
index 8ecd83de85..8b7737b90c 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Transactions.pm
@@ -62,7 +62,7 @@ sub dispatch_rules {
         block => sub { { collection_class => 'RT::Transactions' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
+        regex => qr{^/(ticket|queue|asset|user|group|article)/(\d+)/history/?$},
         block => sub {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));

commit d014198c6dc927a04cce6097df13cc8d74eff638
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Sep 2 06:07:39 2020 +0800

    Add REST2 tests for articles/classes
    
    They are based on asset/catalog tests, with necessary changes to test
    articles/classes instead.

diff --git a/t/rest2/article-customfields.t b/t/rest2/article-customfields.t
new file mode 100644
index 0000000000..03d90d11ea
--- /dev/null
+++ b/t/rest2/article-customfields.t
@@ -0,0 +1,389 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+my $class = RT::Class->new( RT->SystemUser );
+$class->Load('General');
+$class->Create( Name => 'General' ) if !$class->Id;
+ok( $class->Id, "General class" );
+
+my $single_cf = RT::CustomField->new( RT->SystemUser );
+my ( $ok, $msg ) = $single_cf->Load('Content');
+ok( $ok, $msg );
+my $single_cf_id = $single_cf->Id;
+
+( $ok, $msg ) = $single_cf->AddToObject($class);
+ok( $ok, $msg );
+
+my $multi_cf = RT::CustomField->new( RT->SystemUser );
+( $ok, $msg )
+    = $multi_cf->Create( Name => 'Multi', Type => 'FreeformMultiple',
+    LookupType => RT::Article->CustomFieldLookupType );
+ok( $ok, $msg );
+my $multi_cf_id = $multi_cf->Id;
+
+( $ok, $msg ) = $multi_cf->AddToObject($class);
+ok( $ok, $msg );
+
+# Article Creation with no ModifyCustomField
+my ( $article_url, $article_id );
+{
+    my $payload = {
+        Name         => 'Article creation using REST',
+        Class        => 'General',
+        CustomFields => { $single_cf_id => 'Hello world!', },
+    };
+
+    # Rights Test - No CreateArticle
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+    my $content = $mech->json_response;
+    is( $content->{message}, 'Permission Denied', "can't create Article with custom fields you can't set" );
+
+    # Rights Test - With CreateArticle
+    $user->PrincipalObj->GrantRight( Right => 'CreateArticle' );
+
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 400 );
+
+    delete $payload->{CustomFields};
+
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Article Display
+{
+    # Rights Test - No ShowArticle
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+}
+
+# Rights Test - With ShowArticle but no SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowArticle' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    is_deeply( $content->{'CustomFields'}, [], 'Article custom field not present' );
+    is_deeply( [ grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} } ], [], 'No CF hypermedia' );
+}
+
+my $no_article_cf_values = bag(
+    { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => [] },
+    { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+);
+
+# Rights Test - With ShowArticle and SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'SeeCustomField' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    cmp_deeply( $content->{CustomFields}, $no_article_cf_values, 'No article custom field values' );
+
+    cmp_deeply(
+        [ grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} } ],
+        [   {   ref  => 'customfield',
+                id   => $single_cf_id,
+                name => 'Content',
+                type => 'customfield',
+                _url => re(qr[$rest_base_path/customfield/$single_cf_id$]),
+            },
+            {   ref  => 'customfield',
+                id   => $multi_cf_id,
+                name => 'Multi',
+                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::Article->CustomFieldLookupType,
+                MaxValues  => 1,
+                Name       => 'Content',
+                Type       => 'Text',
+            }
+        ),
+        '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::Article->CustomFieldLookupType,
+                MaxValues  => 0,
+                Name       => 'Multi',
+                Type       => 'Freeform',
+            }
+        ),
+        'multi cf'
+    );
+}
+
+# Article Update without ModifyCustomField
+{
+    my $payload = {
+        Name         => 'Article update using REST',
+        CustomFields => { $single_cf_id => 'Modified CF', },
+    };
+
+    # Rights Test - No ModifyArticle
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is( $res->code, 403 );
+    }
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article creation using REST: Permission Denied',
+            'Could not add new custom field value: Permission Denied'
+        ]
+    );
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyArticle' );
+
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article update using REST: Name changed from "Article creation using REST" to "Article update using REST"',
+            'Could not add new custom field value: Permission Denied'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+    cmp_deeply( $content->{CustomFields}, $no_article_cf_values, 'No update to CF' );
+}
+
+# Article Update with ModifyCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ModifyCustomField' );
+    my $payload = {
+        Name         => 'More updates using REST',
+        CustomFields => { $single_cf_id => 'Modified CF', },
+    };
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article More updates using REST: Name changed from "Article update using REST" to "More updates using REST"',
+            'Content Modified CF added'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $modified_article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => ['Modified CF'] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'More updates using REST' );
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'New CF value' );
+
+    # make sure changing the CF doesn't add a second OCFV
+    $payload->{CustomFields}{$single_cf_id} = 'Modified Again';
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response, ['Content Modified CF changed to Modified Again'] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $modified_article_cf_values = bag(
+        {   name   => 'Content',
+            id     => $single_cf_id,
+            type   => 'customfield',
+            _url   => ignore(),
+            values => ['Modified Again']
+        },
+        { name => 'Multi', id => $multi_cf_id, type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    $content = $mech->json_response;
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'New CF value' );
+
+    # stop changing the CF, change something else, make sure CF sticks around
+    delete $payload->{CustomFields}{$single_cf_id};
+    $payload->{Name} = 'No CF change';
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response,
+        ['Article No CF change: Name changed from "More updates using REST" to "No CF change"'] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $content = $mech->json_response;
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'Same CF value' );
+}
+
+# Article Creation with ModifyCustomField
+{
+    my $payload = {
+        Name         => 'Article creation using REST',
+        Class        => 'General',
+        CustomFields => { $single_cf_id => 'Hello world!', },
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Rights Test - With ShowArticle and SeeCustomField
+{
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => ['Hello world!'] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    cmp_deeply( $content->{'CustomFields'}, $article_cf_values, 'Article custom field' );
+}
+
+# Article Creation for multi-value CF
+my $i = 1;
+for my $value ( 'scalar', ['array reference'], [ 'multiple', 'values' ], ) {
+    my $payload = {
+        Name         => 'Multi-value CF ' . $i,
+        Class        => 'General',
+        CustomFields => { $multi_cf_id => $value, },
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Multi-value CF ' . $i );
+
+    my $output            = ref($value) ? $value : [$value];    # scalar input comes out as array reference
+    my $article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => [] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => $output },
+    );
+
+    cmp_deeply( $content->{'CustomFields'}, $article_cf_values, 'Article custom field' );
+    $i++;
+}
+
+{
+
+    sub modify_multi_ok {
+        local $Test::Builder::Level = $Test::Builder::Level + 1;
+        my $input    = shift;
+        my $messages = shift;
+        my $output   = shift;
+        my $name     = shift;
+
+        my $payload = { CustomFields => { $multi_cf_id => $input, }, };
+        my $res     = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+        is( $res->code, 200 );
+        is_deeply( $mech->json_response, $messages );
+
+        $res = $mech->get( $article_url, 'Authorization' => $auth, );
+        is( $res->code, 200 );
+
+        my $content = $mech->json_response;
+        my $values;
+        for my $cf ( @{ $content->{CustomFields} } ) {
+            next unless $cf->{id} == $multi_cf_id;
+
+            $values = [ sort @{ $cf->{values} } ];
+        }
+        cmp_deeply( $values, $output, $name || 'New CF value' );
+    }
+
+    # starting point: ['multiple', 'values'],
+    modify_multi_ok( [ 'multiple', 'values' ], [], [ 'multiple', 'values' ], 'no change' );
+    modify_multi_ok(
+        [ 'multiple', 'values', 'new' ],
+        ['new added as a value for Multi'],
+        [ 'multiple', 'new', 'values' ],
+        'added "new"'
+    );
+    modify_multi_ok(
+        [ 'multiple', 'new' ],
+        ['values is no longer a value for custom field Multi'],
+        [ 'multiple', 'new' ],
+        'removed "values"'
+    );
+    modify_multi_ok(
+        'replace all',
+        [   'replace all added as a value for Multi',
+            'multiple is no longer a value for custom field Multi',
+            'new is no longer a value for custom field Multi'
+        ],
+        ['replace all'],
+        'replaced all values'
+    );
+    modify_multi_ok( [], ['replace all is no longer a value for custom field Multi'], [], 'removed all values' );
+
+    modify_multi_ok(
+        [ 'foo',                            'foo', 'bar' ],
+        [ 'foo added as a value for Multi', undef, 'bar added as a value for Multi' ],
+        [ 'bar',                            'foo' ],
+        'multiple values with the same name'
+    );
+    modify_multi_ok( [ 'foo', 'bar' ], [], [ 'bar', 'foo' ], 'multiple values with the same name' );
+    modify_multi_ok( ['bar'], ['foo is no longer a value for custom field Multi'],
+        ['bar'], 'multiple values with the same name' );
+    modify_multi_ok( [ 'bar', 'bar', 'bar' ], [ undef, undef ], ['bar'], 'multiple values with the same name' );
+}
+
+done_testing;
diff --git a/t/rest2/articles.t b/t/rest2/articles.t
new file mode 100644
index 0000000000..db6ccd8538
--- /dev/null
+++ b/t/rest2/articles.t
@@ -0,0 +1,184 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+# Empty DB
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/articles",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code,                    200 );
+    is( $mech->json_response->{count}, 0 );
+}
+
+# Missing Class
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/article",
+        { Name => 'Article creation using REST', },
+        'Authorization' => $auth,
+    );
+    is( $res->code,                      400 );
+    is( $mech->json_response->{message}, 'Invalid Class' );
+}
+
+# Article Creation
+my ( $article_url, $article_id );
+{
+    my $payload = {
+        Name    => 'Article creation using REST',
+        Summary => 'Article summary',
+        Class   => 'General',
+    };
+
+    # Rights Test - No CreateArticle
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+
+    # Rights Test - With CreateArticle
+    $user->PrincipalObj->GrantRight( Right => 'CreateArticle' );
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Article Display
+{
+    # Rights Test - No ShowArticle
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+}
+
+# Rights Test - With ShowArticle
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowArticle' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+
+    ok( exists $content->{$_} ) for qw(Creator Created LastUpdated LastUpdatedBy Name Summary);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   1 );
+    is( $links->[0]{type}, 'article' );
+    like( $links->[0]{_url}, qr[$rest_base_path/article/$article_id$] );
+
+    is( $links->[1]{ref}, 'history' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article/$article_id/history$] );
+
+    my $class = $content->{Class};
+    is( $class->{id},   1 );
+    is( $class->{type}, 'class' );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'test' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/test$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# Article Search
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/articles",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $article = $content->{items}->[0];
+    is( $article->{type}, 'article' );
+    is( $article->{id},   1 );
+    like( $article->{_url}, qr{$rest_base_path/article/1$} );
+}
+
+# Article Update
+{
+    my $payload = { Name => 'Article update using REST', };
+
+    # Rights Test - No ModifyArticle
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is( $res->code, 403 );
+    }
+    is_deeply( $mech->json_response, ['Article Article creation using REST: Permission Denied'] );
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyArticle' );
+
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article update using REST: Name changed from "Article creation using REST" to "Article update using REST"'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+
+    # update again with no changes
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response, [] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+}
+
+# Transactions
+{
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $res = $mech->get( $mech->url_for_hypermedia('history'), 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             2 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             2 );
+    is( scalar @{ $content->{items} }, 2 );
+
+    for my $txn ( @{ $content->{items} } ) {
+        is( $txn->{type}, 'transaction' );
+        like( $txn->{_url}, qr{$rest_base_path/transaction/\d+$} );
+    }
+}
+
+done_testing;
diff --git a/t/rest2/classes.t b/t/rest2/classes.t
new file mode 100644
index 0000000000..56f4b9ffc2
--- /dev/null
+++ b/t/rest2/classes.t
@@ -0,0 +1,210 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+
+my $mech           = RT::Test::REST2->mech;
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+$user->PrincipalObj->GrantRight( Right => 'SuperUser' );
+
+my $class_url;
+
+# search Name = General
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'Name', value => 'General' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   1 );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+    $class_url = $class->{_url};
+}
+
+# Class display
+{
+    my $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},          1 );
+    is( $content->{Name},        'General' );
+    is( $content->{Description}, 'The default class' );
+    is( $content->{Disabled},    0 );
+
+    ok( exists $content->{$_}, "got $_" ) for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   1 );
+    is( $links->[0]{type}, 'class' );
+    like( $links->[0]{_url}, qr[$rest_base_path/class/1$] );
+
+    is( $links->[1]{ref},  'create' );
+    is( $links->[1]{type}, 'article' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article\?Class=1$] );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'RT_System' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/RT_System$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'RT_System' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/RT_System$} );
+}
+
+# Class update
+{
+    my $payload = {
+        Name        => 'Servers',
+        Description => 'gotta serve em all',
+    };
+
+    my $res = $mech->put_json( $class_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Class General: Description changed from "The default class" to "gotta serve em all"',
+            'Class Servers: Name changed from "General" to "Servers"'
+        ]
+    );
+
+    $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name},        'Servers' );
+    is( $content->{Description}, 'gotta serve em all' );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# search Name = Servers
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'Name', value => 'Servers' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   1 );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+}
+
+# Class delete
+{
+    my $res = $mech->delete( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 204 );
+
+    my $class = RT::Class->new( RT->SystemUser );
+    $class->Load(1);
+    is( $class->Id, 1, '"deleted" class still in the database' );
+    ok( $class->Disabled, '"deleted" class disabled' );
+
+    $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name},     'Servers' );
+    is( $content->{Disabled}, 1 );
+}
+
+# Class create
+my ( $laptops_url, $laptops_id );
+{
+    my $payload = { Name => 'Laptops', };
+
+    my $res = $mech->post_json( "$rest_base_path/class", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $laptops_url = $res->header('location') );
+    ok( ($laptops_id) = $laptops_url =~ qr[/class/(\d+)] );
+}
+
+# Class display
+{
+    my $res = $mech->get( $laptops_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},       $laptops_id );
+    is( $content->{Name},     'Laptops' );
+    is( $content->{Disabled}, 0 );
+
+    ok( exists $content->{$_}, "got $_" ) for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   $laptops_id );
+    is( $links->[0]{type}, 'class' );
+    like( $links->[0]{_url}, qr[$rest_base_path/class/$laptops_id$] );
+
+    is( $links->[1]{ref},  'create' );
+    is( $links->[1]{type}, 'article' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article\?Class=$laptops_id$] );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'test' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/test$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# id > 0 (finds new Laptops class but not disabled Servers class)
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   $laptops_id );
+    like( $class->{_url}, qr{$rest_base_path/class/$laptops_id$} );
+}
+
+done_testing;

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


More information about the rt-commit mailing list