jsonld-outbound
Alain St-Denis 2022-09-11 16:52:39 +00:00
rodzic 63b591db46
commit 46a518f04a
10 zmienionych plików z 182 dodań i 59 usunięć

Wyświetl plik

@ -28,7 +28,13 @@ def get_outbound_entity(entity: BaseEntity, private_key):
return entity
outbound = None
cls = entity.__class__
if cls == Accept:
if cls in [
models.Accept, models.Follow, models.Person, models.Note,
models.Delete, models.Tombstone, models.Announce,
] and isinstance(entity, BaseEntity):
# Already fine
outbound = entity
elif cls == Accept:
outbound = models.Accept.from_base(entity)
elif cls == Follow:
outbound = models.Follow.from_base(entity)

Wyświetl plik

@ -206,7 +206,6 @@ class MixedField(fields.Nested):
return super()._serialize(value, attr, obj, **kwargs)
def _deserialize(self, value, attr, data, **kwargs):
print(attr, value, type(value))
# this is just so the ACTIVITYPUB_POST_OBJECT_IMAGES test payload passes
if len(value) == 0: return value
@ -293,7 +292,6 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation):
def to_as2(self):
obj = self.activity if isinstance(self.activity, Activity) else self
print('to_as2', obj, getattr(obj, 'tag_objects', None))
return jsonld.compact(obj.dump(), CONTEXT)
@classmethod
@ -399,10 +397,8 @@ class Home(metaclass=JsonLDAnnotation):
class NormalizedList(fields.List):
def _deserialize(self,value, attr, data, **kwargs):
print('List', attr, value)
value = normalize_value(value)
ret = super()._deserialize(value,attr,data,**kwargs)
print('List after', ret)
return ret
@ -555,9 +551,9 @@ class Emoji(Object):
class Person(Object, base.Profile):
id = fields.Id()
inbox = IRI(ldp.inbox)
outbox = IRI(as2.outbox, dump_derived={'fmt': '{id}/outbox/', 'fields': ['id']})
following = IRI(as2.following, dump_derived={'fmt': '{id}/following/', 'fields': ['id']})
followers = IRI(as2.followers, dump_derived={'fmt': '{id}/followers/', 'fields': ['id']})
outbox = IRI(as2.outbox)
following = IRI(as2.following)
followers = IRI(as2.followers)
username = fields.String(as2.preferredUsername)
endpoints = CompactedDict(as2.endpoints)
shared_inbox = IRI(as2.sharedInbox) # misskey adds this
@ -570,7 +566,7 @@ class Person(Object, base.Profile):
devices = IRI(toot.devices)
public_key_dict = CompactedDict(sec.publicKey)
guid = fields.String(diaspora.guid)
handle = fields.String(diaspora.handle)
handle = fields.String(diaspora.handle, default="")
raw_content = fields.String(as2.summary, default="") # None fails in extract_mentions
has_address = MixedField(vcard.hasAddress, nested='HomeSchema')
has_instant_message = fields.List(vcard.hasInstantMessage, cls_or_instance=fields.String)
@ -580,6 +576,7 @@ class Person(Object, base.Profile):
copied_to = IRI(toot.copiedTo)
capabilities = CompactedDict(litepub.capabilities)
suspended = fields.Boolean(toot.suspended)
public = True
_inboxes = None
_public_key = None
_image_urls = None
@ -598,7 +595,21 @@ class Person(Object, base.Profile):
self._allowed_children += (PropertyValue, IdentityProof)
def to_as2(self):
self.id = self.id.rstrip('/') # TODO: sort out the trailing / business
#self.id = self.id.rstrip('/') # TODO: sort out the trailing / business
self.followers = f'{with_slash(self.id)}followers/'
self.following = f'{with_slash(self.id)}following/'
self.outbox = f'{with_slash(self.id)}outbox/'
if hasattr(self, 'times'):
if self.times.get('updated',0) > self.times.get('created',0):
self.updated = self.times.get('updated')
if self.times.get('edited'):
self.activity = Update(
activity_id=f'{self.id}#profile-{uuid.uuid4()}',
actor_id=self.id,
created_at=self.times.get('updated'),
object_=self,
)
return super().to_as2()
@property
@ -617,10 +628,11 @@ class Person(Object, base.Profile):
@inboxes.setter
def inboxes(self, value):
self._inboxes = value
if isinstance(value, dict):
self.inbox = value.get('private', None)
self.endpoints = {'sharedInbox': value.get('public', None)}
if value != {'private':None, 'public':None}:
self._inboxes = value
if isinstance(value, dict):
self.inbox = value.get('private', None)
self.endpoints = {'sharedInbox': value.get('public', None)}
@property
def public_key(self):
@ -634,8 +646,9 @@ class Person(Object, base.Profile):
@public_key.setter
def public_key(self, value):
self._public_key = value
id_ = self.id.rstrip('/')
self.public_key_dict = {'id': id_+'#main-key', 'owner': id_, 'publicKeyPem': value}
#id_ = self.id.rstrip('/')
#self.public_key_dict = {'id': id_+'#main-key', 'owner': id_, 'publicKeyPem': value}
self.public_key_dict = {'id': self.id+'#main-key', 'owner': self.id, 'publicKeyPem': value}
@property
def image_urls(self):
@ -652,18 +665,15 @@ class Person(Object, base.Profile):
@image_urls.setter
def image_urls(self, value):
self._image_urls = value
if value.get('large'):
try:
profile_icon = base.Image(url=value.get('large'))
if profile_icon.media_type:
self.icon = [Image.from_base(profile_icon)]
except Exception as ex:
logger.warning("models.Person - failed to set profile icon: %s", ex)
def to_base(self):
set_public(self)
return self
if value != {'large':'', 'medium':'', 'small':''}:
self._image_urls = value
if value.get('large'):
try:
profile_icon = base.Image(url=value.get('large'))
if profile_icon.media_type:
self.icon = [Image.from_base(profile_icon)]
except Exception as ex:
logger.warning("models.Person - failed to set profile icon: %s", ex)
class Meta:
rdf_type = as2.Person
@ -988,11 +998,10 @@ class Follow(Activity, base.Follow):
def to_as2(self):
if not self.following:
self.activity = Undo(
activity_id = self.activity_id,
activity_id = self.activity_id if self.activity_id else f"{self.actor_id}#follow-{uuid.uuid4()}",
actor_id = self.actor_id,
object_ = self
)
self.activity_id = f"{self.actor_id}#follow-{uuid.uuid4()}"
return super().to_as2()
@ -1067,10 +1076,8 @@ class Announce(Activity, base.Share):
self.activity = self.activity(
activity_id = self.activity_id if self.activity_id else f"{self.actor_id}#share-{uuid.uuid4()}",
actor_id = self.actor_id,
created_at = self.created_at,
object_ = self
)
self.id = f"{self.target_id}"
return super().to_as2()
@ -1097,7 +1104,7 @@ class Tombstone(Object, base.Retraction):
def to_as2(self):
if not isinstance(self.activity, type): return None
self.activity = self.activity(
activity_id = self.activity_id,
activity_id = self.activity_id if self.activity_id else f"{self.actor_id}#delete-{uuid.uuid4()}",
actor_id = self.actor_id,
created_at = self.created_at,
object_ = self,
@ -1200,9 +1207,9 @@ def extract_receivers(entity):
"""
receivers = []
profile = None
# don't care about receivers for payloads without an actor_id
with rc.enabled(cache_name='fed_cache', backend=backend):
if getattr(entity, 'actor_id'):
# don't care about receivers for payloads without an actor_id
if getattr(entity, 'actor_id'):
with rc.enabled(cache_name='fed_cache', backend=backend):
profile = retrieve_and_parse_profile(entity.actor_id)
if not profile: return receivers
@ -1262,11 +1269,10 @@ def element_to_objects(element: Union[Dict, Object]) -> List:
# json-ld handling with calamus
# Skips unimplemented payloads
# TODO: remove unused code
entity = model_to_objects(element) if not isinstance(element, Object) else element
#if entity: entity = entity.to_base()
if entity and hasattr(entity, 'to_base'):
entity = entity.to_base()
if isinstance(entity, BaseEntity):
try:
extract_and_validate(entity)
except ValueError as ex:

Wyświetl plik

@ -358,6 +358,7 @@ def handle_send(
# Do actual sending
for payload in payloads:
for url in payload["urls"]:
# Comment this out for testing
#try:
# pprint(json.loads(payload["payload"]))
#except:

Wyświetl plik

@ -15,7 +15,7 @@ def disable_network_calls(monkeypatch):
"""Disable network calls."""
monkeypatch.setattr("requests.post", Mock())
class MockResponse(str):
class MockGetResponse(str):
status_code = 200
text = ""
@ -29,8 +29,17 @@ def disable_network_calls(monkeypatch):
return saved_get(*args, **kwargs)
return DEFAULT
monkeypatch.setattr("requests.get", Mock(return_value=MockResponse, side_effect=side_effect))
monkeypatch.setattr("requests.get", Mock(return_value=MockGetResponse, side_effect=side_effect))
class MockHeadResponse(dict):
status_code = 200
headers = {'Content-Type':'image/jpeg'}
@staticmethod
def raise_for_status():
pass
monkeypatch.setattr("requests.head", Mock(return_value=MockHeadResponse))
@pytest.fixture
def private_key():

Wyświetl plik

@ -376,10 +376,12 @@ class TestEntitiesConvertToAS2:
'id': 'http://127.0.0.1:8000/post/123456/#delete',
'actor': 'http://127.0.0.1:8000/profile/123456/',
'object': {
'actor': 'http://127.0.0.1:8000/profile/123456/',
'id': 'http://127.0.0.1:8000/post/123456/activity',
'object': 'http://127.0.0.1:8000/post/123456',
'type': 'Announce',
'published': '2019-04-27T00:00:00',
},
'published': '2019-04-27T00:00:00',
}

Wyświetl plik

@ -1,6 +1,7 @@
from datetime import datetime
from unittest.mock import patch, Mock
from unittest.mock import patch, Mock, DEFAULT
import json
import pytest
#from federation.entities.activitypub.entities import (
@ -8,12 +9,13 @@ import pytest
# models.Delete, models.Announce)
import federation.entities.activitypub.models as models
from federation.entities.activitypub.mappers import message_to_objects, get_outbound_entity
from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share
from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share, Retraction
from federation.tests.fixtures.payloads import (
ACTIVITYPUB_FOLLOW, ACTIVITYPUB_PROFILE, ACTIVITYPUB_PROFILE_INVALID, ACTIVITYPUB_UNDO_FOLLOW, ACTIVITYPUB_POST,
ACTIVITYPUB_COMMENT, ACTIVITYPUB_RETRACTION, ACTIVITYPUB_SHARE, ACTIVITYPUB_RETRACTION_SHARE,
ACTIVITYPUB_POST_IMAGES, ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, ACTIVITYPUB_POST_WITH_TAGS,
ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, ACTIVITYPUB_POST_WITH_MENTIONS, ACTIVITYPUB_PROFILE_WITH_DIASPORA_GUID)
ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, ACTIVITYPUB_POST_WITH_MENTIONS, ACTIVITYPUB_PROFILE_WITH_DIASPORA_GUID,
ACTIVITYPUB_REMOTE_PROFILE, ACTIVITYPUB_COLLECTION)
from federation.types import UserType, ReceiverVariant
@ -217,7 +219,20 @@ class TestActivitypubEntityMappersReceive:
assert profile.id == "https://friendica.feneas.org/profile/feneas"
assert profile.guid == "76158462365bd347844d248732383358"
def test_message_to_objects_receivers_are_saved(self):
@patch('federation.utils.activitypub.fetch_document')
def test_message_to_objects_receivers_are_saved(self, mock_fetch):
def side_effect(*args, **kwargs):
payloads = {'https://diaspodon.fr/users/jaywink': json.dumps(ACTIVITYPUB_PROFILE),
'https://fosstodon.org/users/astdenis': json.dumps(ACTIVITYPUB_REMOTE_PROFILE),
'https://diaspodon.fr/users/jaywink/followers': json.dumps(ACTIVITYPUB_COLLECTION),
}
if args[0] in payloads.keys():
return payloads[args[0]], 200, None
else:
return DEFAULT
mock_fetch.side_effect = side_effect
# noinspection PyTypeChecker
entities = message_to_objects(
ACTIVITYPUB_POST,
@ -230,7 +245,7 @@ class TestActivitypubEntityMappersReceive:
id='https://diaspodon.fr/users/jaywink', receiver_variant=ReceiverVariant.FOLLOWERS,
),
UserType(
id='https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/',
id='https://fosstodon.org/users/astdenis',
receiver_variant=ReceiverVariant.ACTOR,
)
}
@ -239,7 +254,7 @@ class TestActivitypubEntityMappersReceive:
entities = message_to_objects(ACTIVITYPUB_RETRACTION, "https://friendica.feneas.org/profile/jaywink")
assert len(entities) == 1
entity = entities[0]
assert isinstance(entity, models.Delete)
assert isinstance(entity, Retraction)
assert entity.actor_id == "https://friendica.feneas.org/profile/jaywink"
assert entity.target_id == "https://friendica.feneas.org/objects/76158462-165d-3386-aa23-ba2090614385"
assert entity.entity_type == "Object"
@ -248,7 +263,7 @@ class TestActivitypubEntityMappersReceive:
entities = message_to_objects(ACTIVITYPUB_RETRACTION_SHARE, "https://mastodon.social/users/jaywink")
assert len(entities) == 1
entity = entities[0]
assert isinstance(entity, models.Announce)
assert isinstance(entity, Retraction)
assert entity.actor_id == "https://mastodon.social/users/jaywink"
assert entity.target_id == "https://mastodon.social/users/jaywink/statuses/102571932479036987/activity"
assert entity.entity_type == "Object"

Wyświetl plik

@ -19,7 +19,7 @@ class TestGetBaseAttributes:
assert set(attrs) == {
"created_at", "location", "provider_display_name", "public", "raw_content",
"signature", "base_url", "actor_id", "id", "handle", "guid", "activity", "activity_id",
"url", "mxid",
"url", "mxid", "times",
}
entity = Profile()
attrs = get_base_attributes(entity).keys()
@ -27,7 +27,7 @@ class TestGetBaseAttributes:
"created_at", "name", "email", "gender", "raw_content", "location", "public",
"nsfw", "public_key", "image_urls", "tag_list", "signature", "url", "atom_url",
"base_url", "id", "actor_id", "handle", "handle", "guid", "activity", "activity_id", "username",
"inboxes", "mxid",
"inboxes", "mxid", "times",
}

Wyświetl plik

@ -180,7 +180,7 @@ https://jasonrobinson.me/media/uploads/2019/07/16/daa24d89-cedf-4fc7-bad8-74a902
def activitypubprofile(mock_fetch):
with freeze_time("2022-09-06"):
return models.Person(
id="https://example.com/bob/", raw_content="foobar", name="Bob Bobertson", public=True,
id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True,
tag_list=["socialfederation", "federation"], image_urls={
"large": "urllarge", "medium": "urlmedium", "small": "urlsmall"
}, inboxes={
@ -195,7 +195,7 @@ def activitypubprofile(mock_fetch):
def activitypubprofile_diaspora_guid(mock_fetch):
with freeze_time("2022-09-06"):
return models.Person(
id="https://example.com/bob/", raw_content="foobar", name="Bob Bobertson", public=True,
id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True,
tag_list=["socialfederation", "federation"], image_urls={
"large": "urllarge", "medium": "urlmedium", "small": "urlsmall"
}, inboxes={
@ -222,7 +222,8 @@ def activitypubretraction():
def activitypubretraction_announce():
with freeze_time("2019-04-27"):
obj = Retraction(
target_id="http://127.0.0.1:8000/post/123456/activity",
id="http://127.0.0.1:8000/post/123456/activity",
target_id="http://127.0.0.1:8000/post/123456",
activity_id="http://127.0.0.1:8000/post/123456/#delete",
actor_id="http://127.0.0.1:8000/profile/123456/",
entity_type="Share",

Wyświetl plik

@ -128,6 +128,85 @@ ACTIVITYPUB_PROFILE = {
}
}
ACTIVITYPUB_REMOTE_PROFILE = {
"@context": ["https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{"Curve25519Key": "toot:Curve25519Key",
"Device": "toot:Device",
"Ed25519Key": "toot:Ed25519Key",
"Ed25519Signature": "toot:Ed25519Signature",
"EncryptedMessage": "toot:EncryptedMessage",
"PropertyValue": "schema:PropertyValue",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"cipherText": "toot:cipherText",
"claim": {"@id": "toot:claim", "@type": "@id"},
"deviceId": "toot:deviceId",
"devices": {"@id": "toot:devices", "@type": "@id"},
"discoverable": "toot:discoverable",
"featured": {"@id": "toot:featured", "@type": "@id"},
"featuredTags": {"@id": "toot:featuredTags", "@type": "@id"},
"fingerprintKey": {"@id": "toot:fingerprintKey", "@type": "@id"},
"focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
"identityKey": {"@id": "toot:identityKey", "@type": "@id"},
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
"publicKeyBase64": "toot:publicKeyBase64",
"schema": "http://schema.org#",
"suspended": "toot:suspended",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value"}],
"attachment": [{"name": "OS", "type": "PropertyValue", "value": "Manjaro"},
{"name": "Self Hosting",
"type": "PropertyValue",
"value": "Matrix HS, Nextcloud"}],
"devices": "https://fosstodon.org/users/astdenis/collections/devices",
"discoverable": True,
"endpoints": {"sharedInbox": "https://fosstodon.org/inbox"},
"featured": "https://fosstodon.org/users/astdenis/collections/featured",
"featuredTags": "https://fosstodon.org/users/astdenis/collections/tags",
"followers": "https://fosstodon.org/users/astdenis/followers",
"following": "https://fosstodon.org/users/astdenis/following",
"icon": {"mediaType": "image/jpeg",
"type": "Image",
"url": "https://cdn.fosstodon.org/accounts/avatars/000/252/976/original/09b7067cde009950.jpg"},
"id": "https://fosstodon.org/users/astdenis",
"image": {"mediaType": "image/jpeg",
"type": "Image",
"url": "https://cdn.fosstodon.org/accounts/headers/000/252/976/original/555a1ac1819e4e7f.jpg"},
"inbox": "https://fosstodon.org/users/astdenis/inbox",
"manuallyApprovesFollowers": False,
"name": "Alain",
"outbox": "https://fosstodon.org/users/astdenis/outbox",
"preferredUsername": "astdenis",
"publicKey": {"id": "https://fosstodon.org/users/astdenis#main-key",
"owner": "https://fosstodon.org/users/astdenis",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuaoIq/b+aUNqGAJNYF76\n"
"WY8tk49Vb1udyb7X+oseBXYtOwCDGfbZMalnFfqur1bAzogkKzuyjCeA3BfVs6R3\n"
"Cll897kUveMNHVc24pslhOx5ZzwpNT8e4q97dNaeHWLSLH5H+4JJGbeoD23G5SaY\n"
"9ZKt5iP+qRUlO/kSsUPwqsX9i2qSEqzwDiSvyRYhvvx4O588cUaaY9rAliLgtc/P\n"
"4EID3v6Edexe2QosUaghwGbb8zZWsYq0O4Umn2QMN4LzmQ0FjP+lq1TFX8FkGDZH\n"
"lnP+AMEQMyuac9Yb12t4RwvdsAIk4MXhAKvutMJm/X1GVQIyrsLEmvAO3rgk8dMr\n"
"6QIDAQAB\n"
"-----END PUBLIC KEY-----\n"},
"published": "2020-07-25T00:00:00Z",
"summary": "<p>Linux user and sysadmin since 1994, retired from the HPC field "
"since 2019.</p><p>Utilisateur et sysadmin Linux depuis 1994, "
"retraité du domaine du CHP depuis 2019.</p>",
"tag": [],
"type": "Person",
"url": "https://fosstodon.org/@astdenis"
}
ACTIVITYPUB_COLLECTION = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://diaspodon.fr/users/jaywink/followers",
"totalItems": 231,
"type": "OrderedCollection"
}
ACTIVITYPUB_PROFILE_INVALID = {
"@context": [
"https://www.w3.org/ns/activitystreams",
@ -313,7 +392,7 @@ ACTIVITYPUB_POST = {
'published': '2019-06-29T21:08:45Z',
'to': 'https://www.w3.org/ns/activitystreams#Public',
'cc': ['https://diaspodon.fr/users/jaywink/followers',
'https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/'],
'https://fosstodon.org/users/astdenis'],
'object': {'id': 'https://diaspodon.fr/users/jaywink/statuses/102356911717767237',
'type': 'Note',
'summary': None,
@ -323,7 +402,7 @@ ACTIVITYPUB_POST = {
'attributedTo': 'https://diaspodon.fr/users/jaywink',
'to': 'https://www.w3.org/ns/activitystreams#Public',
'cc': ['https://diaspodon.fr/users/jaywink/followers',
'https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/'],
'https://fosstodon.org/users/astdenis'],
'sensitive': False,
'atomUri': 'https://diaspodon.fr/users/jaywink/statuses/102356911717767237',
'inReplyToAtomUri': None,

Wyświetl plik

@ -50,35 +50,39 @@ class TestRetrieveAndParseDocument:
"https://example.com/foobar", extra_headers={'accept': 'application/activity+json'}, auth=auth,
)
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
json.dumps(ACTIVITYPUB_FOLLOW), None, None),
)
@patch.object(Follow, "post_receive")
def test_returns_entity_for_valid_document__follow(self, mock_post_receive, mock_fetch):
def test_returns_entity_for_valid_document__follow(self, mock_post_receive, mock_fetch, mock_recv):
entity = retrieve_and_parse_document("https://example.com/foobar")
assert isinstance(entity, Follow)
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
json.dumps(ACTIVITYPUB_POST_OBJECT), None, None),
)
def test_returns_entity_for_valid_document__post__without_activity(self, mock_fetch):
def test_returns_entity_for_valid_document__post__without_activity(self, mock_fetch, mock_recv):
entity = retrieve_and_parse_document("https://example.com/foobar")
assert isinstance(entity, Note)
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
json.dumps(ACTIVITYPUB_POST_OBJECT_IMAGES), None, None),
)
def test_returns_entity_for_valid_document__post__without_activity__with_images(self, mock_fetch):
def test_returns_entity_for_valid_document__post__without_activity__with_images(self, mock_fetch, mock_recv):
entity = retrieve_and_parse_document("https://example.com/foobar")
assert isinstance(entity, Note)
assert len(entity._children) == 1
assert entity._children[0].url == "https://files.mastodon.social/media_attachments/files/017/792/237/original" \
"/foobar.jpg"
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
json.dumps(ACTIVITYPUB_POST), None, None),
)
def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch):
def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch, mock_recv):
entity = retrieve_and_parse_document("https://example.com/foobar")
assert isinstance(entity, Note)