perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add endpoint surface regression tests

alice e76542c9 5b9d1f61

+344
+190
t/extended-api.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use JSON::PP (); 9 + use Mojo::URL; 10 + use Test2::V0; 11 + 12 + BEGIN { 13 + require lib; 14 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 15 + lib->import( 16 + File::Spec->catdir($root, 'lib'), 17 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 18 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 19 + ); 20 + } 21 + 22 + use Test::Mojo; 23 + use ATProto::PDS; 24 + 25 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 26 + my $tmp = tempdir(CLEANUP => 1); 27 + 28 + my $app = ATProto::PDS->new( 29 + project_root => $root, 30 + settings => { 31 + base_url => 'http://127.0.0.1:7755', 32 + service_handle_domain => 'example.test', 33 + service_did_method => 'did:web', 34 + jwt_secret => 'extended-secret', 35 + admin_password => 'admin-secret', 36 + data_dir => $tmp, 37 + db_path => File::Spec->catfile($tmp, 'perlds.sqlite'), 38 + }, 39 + ); 40 + 41 + my $t = Test::Mojo->new($app); 42 + 43 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 44 + handle => 'alice.example.test', 45 + email => 'alice@example.test', 46 + password => 'hunter22', 47 + })->status_is(200); 48 + 49 + my $created = $t->tx->res->json; 50 + my $access = $created->{accessJwt}; 51 + my $did = $created->{did}; 52 + 53 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 54 + Authorization => "Bearer $access", 55 + } => json => { 56 + useCount => 2, 57 + })->status_is(200) 58 + ->json_has('/code'); 59 + 60 + my $invite_code = $t->tx->res->json->{code}; 61 + 62 + $t->get_ok('/xrpc/com.atproto.server.getAccountInviteCodes' => { 63 + Authorization => "Bearer $access", 64 + })->status_is(200) 65 + ->json_is('/codes/0/code', $invite_code); 66 + 67 + $t->get_ok('/xrpc/com.atproto.admin.getAccountInfo' => { 68 + Authorization => 'Bearer admin-secret', 69 + } => form => { 70 + did => $did, 71 + })->status_is(200) 72 + ->json_is('/did', $did) 73 + ->json_is('/handle', 'alice.example.test'); 74 + 75 + $t->post_ok('/xrpc/com.atproto.temp.addReservedHandle' => { 76 + Authorization => 'Bearer admin-secret', 77 + } => json => { 78 + handle => 'reserved.example.test', 79 + })->status_is(200); 80 + 81 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.temp.checkHandleAvailability')->query( 82 + handle => 'reserved.example.test', 83 + ))->status_is(200) 84 + ->json_is('/available', JSON::PP::false); 85 + 86 + $t->post_ok('/xrpc/com.atproto.identity.updateHandle' => { 87 + Authorization => "Bearer $access", 88 + } => json => { 89 + handle => 'alice-renamed.example.test', 90 + })->status_is(200); 91 + 92 + $t->post_ok('/xrpc/com.atproto.identity.refreshIdentity' => json => { 93 + identifier => 'alice-renamed.example.test', 94 + })->status_is(200) 95 + ->json_is('/did', $did) 96 + ->json_is('/handle', 'alice-renamed.example.test'); 97 + 98 + $t->post_ok('/xrpc/com.atproto.server.requestEmailUpdate' => { 99 + Authorization => "Bearer $access", 100 + } => json => {})->status_is(200); 101 + ok($t->tx->res->json->{tokenRequired}, 'confirmed email requires update token'); 102 + 103 + my $email_update = $app->store->latest_action_token( 104 + did => $did, 105 + purpose => 'email_update', 106 + ); 107 + 108 + $t->post_ok('/xrpc/com.atproto.server.updateEmail' => { 109 + Authorization => "Bearer $access", 110 + } => json => { 111 + email => 'alice+new@example.test', 112 + token => $email_update->{token}, 113 + })->status_is(200); 114 + 115 + $t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => { 116 + Authorization => "Bearer $access", 117 + } => json => {})->status_is(200); 118 + 119 + my $email_confirm = $app->store->latest_action_token( 120 + did => $did, 121 + purpose => 'email_confirm', 122 + ); 123 + 124 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 125 + email => 'alice+new@example.test', 126 + token => $email_confirm->{token}, 127 + })->status_is(200); 128 + 129 + my $blob_tx = $t->ua->build_tx( 130 + POST => '/xrpc/com.atproto.repo.uploadBlob' => { 131 + Authorization => "Bearer $access", 132 + 'Content-Type' => 'image/png', 133 + } => 'blob-bytes', 134 + ); 135 + $t->request_ok($blob_tx)->status_is(200); 136 + 137 + my $blob = $t->tx->res->json->{blob}; 138 + my $blob_cid = $blob->{ref}{'$link'}; 139 + 140 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.listBlobs')->query( 141 + did => $did, 142 + ))->status_is(200) 143 + ->json_is('/cids/0', $blob_cid); 144 + 145 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlob')->query( 146 + did => $did, 147 + cid => $blob_cid, 148 + ))->status_is(200); 149 + is($t->tx->res->body, 'blob-bytes', 'blob bytes are served back'); 150 + like($t->tx->res->headers->content_type // '', qr{image/png}, 'blob content type preserved'); 151 + 152 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getLatestCommit')->query( 153 + did => $did, 154 + ))->status_is(200) 155 + ->json_has('/cid'); 156 + 157 + my $commit_cid = $t->tx->res->json->{cid}; 158 + 159 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlocks')->query( 160 + did => $did, 161 + cids => $commit_cid, 162 + ))->status_is(200); 163 + like($t->tx->res->headers->content_type // '', qr{application/vnd\.ipld\.car}, 'block export is a CAR'); 164 + 165 + $t->post_ok('/xrpc/com.atproto.admin.updateSubjectStatus' => { 166 + Authorization => 'Bearer admin-secret', 167 + } => json => { 168 + subject => { did => $did }, 169 + takedown => { applied => JSON::PP::true, ref => 'unit-test' }, 170 + })->status_is(200); 171 + 172 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.label.queryLabels')->query( 173 + uriPatterns => "at://$did*", 174 + ))->status_is(200) 175 + ->json_is('/labels/0/val', '!hide'); 176 + 177 + $t->post_ok('/xrpc/com.atproto.sync.requestCrawl' => json => { 178 + hostname => 'relay.example.test', 179 + })->status_is(200); 180 + 181 + $t->get_ok('/xrpc/com.atproto.sync.listHosts') 182 + ->status_is(200) 183 + ->json_is('/hosts/0/hostname', 'relay.example.test'); 184 + 185 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getHostStatus')->query( 186 + hostname => 'relay.example.test', 187 + ))->status_is(200) 188 + ->json_is('/hostname', 'relay.example.test'); 189 + 190 + done_testing;
+154
t/external-surface.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use Test2::V0; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use Test::Mojo; 21 + use JSON::PP (); 22 + use Mojo::URL; 23 + use ATProto::PDS; 24 + 25 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 26 + my $tmp = tempdir(CLEANUP => 1); 27 + 28 + my $app = ATProto::PDS->new( 29 + project_root => $root, 30 + settings => { 31 + base_url => 'http://127.0.0.1:7755', 32 + service_handle_domain => 'example.test', 33 + service_did_method => 'did:web', 34 + jwt_secret => 'surface-secret', 35 + admin_password => 'admin-secret', 36 + data_dir => File::Spec->catdir($tmp, 'data'), 37 + db_path => File::Spec->catfile($tmp, 'perlds.sqlite'), 38 + }, 39 + ); 40 + 41 + my $t = Test::Mojo->new($app); 42 + 43 + for my $endpoint (@{ $app->endpoint_catalog }) { 44 + ok($app->api_registry->handler_for($endpoint->{id}), "$endpoint->{id} has a handler"); 45 + } 46 + 47 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.lexicon.resolveLexicon')->query( 48 + nsid => 'com.atproto.server.createSession', 49 + ))->status_is(200) 50 + ->json_is('/schema/id' => 'com.atproto.server.createSession') 51 + ->json_has('/cid') 52 + ->json_has('/uri'); 53 + 54 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.temp.checkHandleAvailability')->query( 55 + handle => 'alice.example.test', 56 + ))->status_is(200) 57 + ->json_is('/handle' => 'alice.example.test') 58 + ->json_has('/result'); 59 + 60 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 61 + handle => 'alice.example.test', 62 + email => 'alice@example.test', 63 + password => 'hunter22', 64 + })->status_is(200); 65 + 66 + my $session = $t->tx->res->json; 67 + my $did = $session->{did}; 68 + my $access = $session->{accessJwt}; 69 + 70 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.temp.checkHandleAvailability')->query( 71 + handle => 'alice.example.test', 72 + ))->status_is(200) 73 + ->json_has('/result/suggestions/0/handle'); 74 + 75 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { Authorization => "Bearer $access" } => json => { 76 + repo => $did, 77 + collection => 'app.bsky.feed.post', 78 + rkey => 'hello-world', 79 + record => { 80 + '$type' => 'app.bsky.feed.post', 81 + text => 'hello surface', 82 + createdAt => '2026-03-10T00:00:00Z', 83 + }, 84 + })->status_is(200); 85 + 86 + my $record = $t->tx->res->json; 87 + my $record_uri = $record->{uri}; 88 + my $record_cid = $record->{cid}; 89 + 90 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.listReposByCollection')->query( 91 + collection => 'app.bsky.feed.post', 92 + ))->status_is(200) 93 + ->json_is('/repos/0/did' => $did); 94 + 95 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getLatestCommit')->query( 96 + did => $did, 97 + ))->status_is(200); 98 + 99 + my $latest = $t->tx->res->json; 100 + 101 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlocks')->query( 102 + did => $did, 103 + cids => $latest->{cid}, 104 + ))->status_is(200) 105 + ->content_type_like(qr{application/vnd\.ipld\.car}) 106 + ->content_like(qr/.+/s); 107 + 108 + $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 109 + Authorization => "Bearer $access", 110 + 'Content-Type' => 'text/plain', 111 + } => 'blob-bytes')->status_is(200); 112 + 113 + my $blob = $t->tx->res->json->{blob}; 114 + my $blob_cid = $blob->{ref}{'$link'}; 115 + 116 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.sync.getBlob')->query( 117 + did => $did, 118 + cid => $blob_cid, 119 + ))->status_is(200) 120 + ->content_type_is('text/plain') 121 + ->content_is('blob-bytes'); 122 + 123 + $t->get_ok('/xrpc/com.atproto.server.checkAccountStatus' => { 124 + Authorization => "Bearer $access", 125 + })->status_is(200) 126 + ->json_has('/activated') 127 + ->json_has('/repoCommit') 128 + ->json_has('/repoRev') 129 + ->json_has('/repoBlocks') 130 + ->json_has('/indexedRecords') 131 + ->json_has('/expectedBlobs') 132 + ->json_has('/importedBlobs'); 133 + 134 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 135 + did => $did, 136 + ) => { 137 + Authorization => 'Bearer admin-secret', 138 + })->status_is(200) 139 + ->json_is('/handle' => 'alice.example.test'); 140 + 141 + $t->post_ok('/xrpc/com.atproto.admin.updateSubjectStatus' => { 142 + Authorization => 'Bearer admin-secret', 143 + } => json => { 144 + subject => { uri => $record_uri, cid => $record_cid }, 145 + takedown => { applied => JSON::PP::true }, 146 + })->status_is(200); 147 + 148 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.label.queryLabels')->query( 149 + uriPatterns => $record_uri, 150 + ))->status_is(200) 151 + ->json_is('/labels/0/val' => '!hide') 152 + ->json_is('/labels/0/uri' => $record_uri); 153 + 154 + done_testing;