From f55ef8652410b1fda86d73927528d375e6c6ad2a Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Fri, 30 Dec 2022 10:10:49 -0800 Subject: [PATCH] finish implementing getAuthorFeed --- common.py | 3 - tests/test_xrpc_actor.py | 27 +++--- tests/test_xrpc_feed.py | 203 +++++++++++++++++++++++++++------------ tests/test_xrpc_graph.py | 84 ++++++++-------- tests/testutil.py | 10 +- xrpc_actor.py | 3 +- xrpc_feed.py | 49 ++++++---- 7 files changed, 236 insertions(+), 143 deletions(-) diff --git a/common.py b/common.py index 75e0257..662686f 100644 --- a/common.py +++ b/common.py @@ -18,6 +18,3 @@ DOMAIN_BLOCKLIST = frozenset(( 't.co', 'twitter.com', ) + DOMAINS) - -# alias allows unit tests to mock the function -utcnow = datetime.datetime.utcnow diff --git a/tests/test_xrpc_actor.py b/tests/test_xrpc_actor.py index 8f3344d..8d5383f 100644 --- a/tests/test_xrpc_actor.py +++ b/tests/test_xrpc_actor.py @@ -21,9 +21,9 @@ class XrpcActorTest(testutil.TestCase): """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json + resp = self.client.get('/xrpc/app.bsky.actor.getProfile', + query_string={'actor': 'foo.com'}) + self.assertEqual(200, resp.status_code) self.assertEqual({ '$type': 'app.bsky.actor.profile', 'handle': 'foo.com', @@ -45,7 +45,7 @@ class XrpcActorTest(testutil.TestCase): 'follow': 'TODO', 'member': 'TODO', }, - }, got) + }, resp.json) def test_getProfile_not_domain(self, _): resp = self.client.get('/xrpc/app.bsky.actor.getProfile', @@ -53,21 +53,24 @@ class XrpcActorTest(testutil.TestCase): self.assertEqual(400, resp.status_code) def test_getSuggestions(self, _): - got = self.client.get('/xrpc/app.bsky.actor.getSuggestions').json + resp = self.client.get('/xrpc/app.bsky.actor.getSuggestions') + self.assertEqual(200, resp.status_code) self.assertEqual({ 'actors': [], - }, got) + }, resp.json) def test_search(self, _): - got = self.client.get('/xrpc/app.bsky.actor.search', - query_string={'term': 'foo'}).json + resp = self.client.get('/xrpc/app.bsky.actor.search', + query_string={'term': 'foo'}) + self.assertEqual(200, resp.status_code) self.assertEqual({ 'users': [], - }, got) + }, resp.json) def test_searchTypeahead(self, _): - got = self.client.get('/xrpc/app.bsky.actor.searchTypeahead', - query_string={'term': 'foo'}).json + resp = self.client.get('/xrpc/app.bsky.actor.searchTypeahead', + query_string={'term': 'foo'}) + self.assertEqual(200, resp.status_code) self.assertEqual({ 'users': [], - }, got) + }, resp.json) diff --git a/tests/test_xrpc_feed.py b/tests/test_xrpc_feed.py index 3a3b458..cb9a696 100644 --- a/tests/test_xrpc_feed.py +++ b/tests/test_xrpc_feed.py @@ -1,94 +1,173 @@ """Unit tests for feed.py.""" +import copy from unittest.mock import patch from oauth_dropins.webutil import util -from oauth_dropins.webutil.testutil import requests_response +from oauth_dropins.webutil.testutil import NOW, requests_response import requests from . import testutil +POST_HTML = """ +
+
My post
+ + +
+""" +POST = { + '$type': 'app.bsky.feed.post#view', + 'uri': 'http://orig/post', + 'cid': 'TODO', + 'record': { + 'text': 'My post', + 'createdAt': '2007-07-07T03:04:05', + }, + 'replyCount': 0, + 'repostCount': 0, + 'upvoteCount': 0, + 'downvoteCount': 0, + 'indexedAt': '2022-01-02T03:04:05+00:00', + 'viewer': {} +} + +REPLY_HTML = """ +
+
I hereby reply to this
+ + + +
+""" +REPLY = copy.deepcopy(POST) +REPLY.update({ + 'uri': 'http://a/reply', + 'record': { + 'text': 'I hereby reply to this', + 'createdAt': '2008-08-08T03:04:05', + 'reply': { + 'root': { + 'uri': 'http://orig/post', + 'cid': 'TODO', + }, + 'parent': { + 'uri': 'http://orig/post', + 'cid': 'TODO', + }, + }, + }, +}) + +REPOST_HTML = """ +
+
A compelling post
+ + +
+""" +REPOST = copy.deepcopy(POST) +REPOST['record'].update({ + 'text': '', + 'createdAt': '', +}) +REPOST_REASON = { + '$type': 'app.bsky.feed.feedViewPost#reasonRepost', + 'by': { + 'did': 'TODO', + 'declaration': { + 'cid': 'TODO', + 'actorType': 'app.bsky.system.actorUser', + }, + 'handle': 'alice.com', + 'displayName': 'Alice', + 'avatar': 'https://alice.com/alice.jpg', + }, + 'indexedAt': NOW.isoformat(), +} + + @patch('requests.get') class XrpcFeedTest(testutil.TestCase): def test_getAuthorFeed(self, mock_get): - mock_get.return_value = requests_response(""" - + mock_get.return_value = requests_response(f"""\ + + + Alice + +{POST_HTML} +{REPLY_HTML} +{REPOST_HTML} -""", url='https://foo.com/') +""", url='https://alice.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json + resp = self.client.get('/xrpc/app.bsky.feed.getAuthorFeed', + query_string={'author': 'alice.com'}) + self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) self.assertEqual({ 'feed': [{ - 'post': { - }, + '$type': 'app.bsky.feed.feedViewPost', + 'post': POST, }, { - 'post': { - }, - 'reply': { - }, + '$type': 'app.bsky.feed.feedViewPost', + 'post': REPLY, }, { - 'post': { - }, - 'reason': { - 'by': '', - 'indexedAt': testutil.NOW.isoformat(), - } + '$type': 'app.bsky.feed.feedViewPost', + 'post': REPOST, + 'reason': REPOST_REASON, }], - }, got) - + }, resp.json) def test_getAuthorFeed_not_domain(self, _): resp = self.client.get('/xrpc/app.bsky.feed.getAuthorFeed', - query_string={'actor': 'not a domain'}) + query_string={'author': 'not a domain'}) self.assertEqual(400, resp.status_code) - def test_getPostThread(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getPostThread(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getRepostedBy(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getRepostedBy(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getTimeline(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getTimeline(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getVotes(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getVotes(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getVotes', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getVotes', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) diff --git a/tests/test_xrpc_graph.py b/tests/test_xrpc_graph.py index 6ce54af..1f01ebd 100644 --- a/tests/test_xrpc_graph.py +++ b/tests/test_xrpc_graph.py @@ -8,53 +8,53 @@ import requests from . import testutil -@patch('requests.get') -class XrpcGraphTest(testutil.TestCase): +# @patch('requests.get') +# class XrpcGraphTest(testutil.TestCase): - def test_getAuthorFeed(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getAuthorFeed(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getPostThread(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getPostThread(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getRepostedBy(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getRepostedBy(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) - def test_getTimeline(self, mock_get): - mock_get.return_value = requests_response(""" - - -""", url='https://foo.com/') +# def test_getTimeline(self, mock_get): +# mock_get.return_value = requests_response(""" +# +# +# """, url='https://foo.com/') - got = self.client.get('/xrpc/app.bsky.actor.getProfile', - query_string={'actor': 'foo.com'}, - ).json - self.assertEqual({ - }, got) +# got = self.client.get('/xrpc/app.bsky.actor.getProfile', +# query_string={'actor': 'foo.com'}, +# ).json +# self.assertEqual({ +# }, got) diff --git a/tests/testutil.py b/tests/testutil.py index 4bb3171..5c50c89 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -5,14 +5,13 @@ import unittest import requests from app import app, cache -import common from oauth_dropins.webutil import testutil, util from oauth_dropins.webutil.appengine_config import ndb_client from oauth_dropins.webutil.util import json_dumps, json_loads -NOW = datetime.datetime(2022, 12, 24, 22, 29, 19) - +# can't use webutil.testutil.TestCase because it mock out requests.* with mox, +# which collides with bridgy-at doing the same thing with unittest.mock. class TestCase(unittest.TestCase, testutil.Asserts): maxDiff = None @@ -21,14 +20,15 @@ class TestCase(unittest.TestCase, testutil.Asserts): app.testing = True cache.clear() self.client = app.test_client() - self.xrpc = app.test_client() - common.utcnow = lambda: NOW # clear datastore requests.post('http://%s/reset' % ndb_client.host) + self.ndb_context = ndb_client.context() self.ndb_context.__enter__() + util.now = lambda **kwargs: testutil.NOW + def tearDown(self): self.ndb_context.__exit__(None, None, None) super().tearDown() diff --git a/xrpc_actor.py b/xrpc_actor.py index 4cb852a..21f53ce 100644 --- a/xrpc_actor.py +++ b/xrpc_actor.py @@ -31,10 +31,11 @@ def getProfile(input, actor=None): logger.info(f'Representative h-card: {json.dumps(hcard, indent=2)}') actor = microformats2.json_to_object(hcard) + actor.setdefault('url', url) logger.info(f'AS1 actor: {json.dumps(actor, indent=2)}') profile = { - **bluesky.from_as1(actor, from_url=url), + **bluesky.from_as1(actor), 'myState': { # ? 'follow': 'TODO', diff --git a/xrpc_feed.py b/xrpc_feed.py index ea50b1c..a5a5c95 100644 --- a/xrpc_feed.py +++ b/xrpc_feed.py @@ -1,5 +1,11 @@ """app.bsky.feed.* XRPC methods.""" +import json import logging +import re + +from granary import microformats2, bluesky +import mf2util +from oauth_dropins.webutil import util from app import xrpc_server @@ -12,29 +18,36 @@ def getAuthorFeed(input, author=None, limit=None, before=None): lexicons/app/bsky/feed/getAuthorFeed.json, feedViewPost.json """ if not re.match(util.DOMAIN_RE, author): - raise ValueError(f'{actor} is not a domain') + raise ValueError(f'{author} is not a domain') - url = f'https://{actor}/' + url = f'https://{author}/' mf2 = util.fetch_mf2(url, gateway=True) - hcard = mf2util.representative_hcard(mf2, mf2['url']) - if not hcard: - raise ValueError(f"Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {mf2['url']}") + logger.info(f'Got mf2: {json.dumps(mf2, indent=2)}') - logger.info(f'Representative h-card: {json.dumps(hcard, indent=2)}') + # hcard = mf2util.representative_hcard(mf2, mf2['url']) + feed_author = mf2util.find_author(mf2, source_url=url, fetch_mf2_func=util.fetch_mf2) + if feed_author: + logger.info(f'Authorship found: {feed_author}') + actor = { + 'url': feed_author.get('url') or url, + 'displayName': feed_author.get('name'), + 'image': {'url': feed_author.get('photo')}, + } + else: + logger.info(f'No authorship result on {url} ; generated {feed_author}') + actor = { + 'url': url, + 'displayName': author, + } - actor = microformats2.json_to_object(hcard) - logger.info(f'AS1 actor: {json.dumps(actor, indent=2)}') + # actor = microformats2.json_to_object(author) + activities = microformats2.json_to_activities(mf2) #, actor) + # default actor to feed author + for a in activities: + a.setdefault('actor', actor) + logger.info(f'AS1 activities: {json.dumps(activities, indent=2)}') - profile = { - **bluesky.from_as1(actor, from_url=url), - 'myState': { - # ? - 'follow': 'TODO', - 'member': 'TODO', - }, - } - logger.info(f'Bluesky profile: {json.dumps(profile, indent=2)}') - return profile + return {'feed': [bluesky.from_as1(a) for a in activities]} @xrpc_server.method('app.bsky.feed.getPostThread')