Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import pytest

def pytest_collection_modifyitems(items):
# Automatically adds django_db marker to all test functions
for item in items:
item.add_marker(pytest.mark.django_db)
3 changes: 2 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = testbed.settings
python_files = tests.py test_*.py *_tests.py
pythonpath = .
python_files = test_*.py
46 changes: 46 additions & 0 deletions testbed/core/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth.models import User
from testbed.core.models import Actor, Note, Activity, PortabilityOutbox


class UserFactory(DjangoModelFactory):
class Meta:
model = User

username = factory.Sequence(lambda n: f'user_{n}')
email = factory.LazyAttribute(lambda o: f'{o.username}@example.com')
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
is_staff = False
is_active = True

@classmethod
def _after_postgeneration(cls, instance, create, results=None):
if create:
instance.save()

class ActorFactory(DjangoModelFactory):
class Meta:
model = Actor

user = factory.SubFactory(UserFactory)
username = factory.SelfAttribute('user.username')
full_name = factory.Faker('name')
previously = factory.Dict({})

class NoteFactory(DjangoModelFactory):
class Meta:
model = Note

actor = factory.SubFactory(ActorFactory)
content = factory.Faker('text', max_nb_chars=200)
visibility = factory.Iterator(['public', 'private', 'followers-only'])

class ActivityFactory(DjangoModelFactory):
class Meta:
model = Activity

actor = factory.SubFactory(ActorFactory)
type = factory.Iterator(['Create', 'Like', 'Update', 'Follow', 'Announce', 'Delete', 'Undo', 'Flag'])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is going to create conformant ActivityPub objects. Some of these types can have a Note attached but some shouldn't. This is fine for this PR but we'll need to iterate. Possibly some of the Activity types should be subclasses of the Activity model class that enforce things like whether the type has a note or not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was something I was going to deal with in the next PR.

note = factory.SubFactory(NoteFactory)
visibility = factory.Iterator(['public', 'private', 'followers-only'])
48 changes: 48 additions & 0 deletions testbed/core/management/commands/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from testbed.core.factories import ActorFactory, ActivityFactory


class Command(BaseCommand):
help = 'Seed the database with sample data'

def handle(self, *args, **kwargs):
try:
# Check for admin user
if not User.objects.filter(is_staff=True, is_active=True).exists():
self.stdout.write(self.style.WARNING('No admin user found.'))
answer = input('Would you like to create one? (Y/N): ').strip().lower()
if answer == 'y':
self.stdout.write(self.style.WARNING('Creating admin user...'))
User.objects.create_superuser(
username='admin',
email='[email protected]',
password='admin123'
)
self.stdout.write(self.style.SUCCESS('Admin user created successfully.'))
else:
self.stdout.write(self.style.WARNING('Skipping admin user creation.'))
else:
self.stdout.write(self.style.SUCCESS('Admin user already exists.'))

# Create multiple actors (Outbox will be created automatically)
self.stdout.write('Creating actors...')
actors = ActorFactory.create_batch(10)

# Create activities (notes will be created automatically)
self.stdout.write('Creating activities with their notes...')
for actor in actors:
ActivityFactory.create_batch(
5,
actor=actor,
)
self.stdout.write(
self.style.SUCCESS(
f'Successfully created:\n'
f'- {len(actors)} actors and their outboxes\n'
f'- {len(actors) * 5} activities with their notes'
)
)

except Exception as e:
self.stdout.write(self.style.ERROR(f'Error seeding database: {str(e)}'))
18 changes: 18 additions & 0 deletions testbed/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,25 @@ class Actor(models.Model):

def __str__(self):
return self.username

def save(self, *args, **kwargs):
is_new = self._state.adding
super().save(*args, **kwargs)

if is_new:
# Create a PortabilityOutbox for new actor
outbox = PortabilityOutbox.objects.create(actor=self)

# Create an initial Activity for the Actor
activity = Activity.objects.create(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be the activity that announces the creation of the Actor themselves? Is it correct for an Activity of type "Create" to have no internal object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Yes, correct. It announces the creation of the Actor itself.

  • No, it shouldn't be that way. I should've added the internal object. The internal object should be the Actor itself.

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Create",
  "actor": "https://example.com/users/someuser",
  "object": {
    "type": "Person",
    "id": "https://example.com/users/someuser",
    "preferredUsername": "someuser",
    "name": "Some User",
  }
}

actor=self,
type='Create',
visibility='public'
)

outbox.activities.add(activity)


def get_json_ld(self):
# Return a LOLA-compliant JSON-LD representation of the account
return {
Expand Down
3 changes: 0 additions & 3 deletions testbed/core/tests.py

This file was deleted.

Empty file added testbed/core/tests/__init__.py
Empty file.
22 changes: 12 additions & 10 deletions testbed/core/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import pytest
from django.contrib.auth.models import User
from testbed.core.models import Actor, PortabilityOutbox
from testbed.core.factories import UserFactory, ActorFactory, NoteFactory, ActivityFactory


# Create and return a user
@pytest.fixture
def user():
return User.objects.create_user(username='testuser', password='testpass')
return UserFactory()

# Create and return an actor linked to the user
@pytest.fixture
def actor(user):
return Actor.objects.create(user=user, username='testactor', full_name='Test Actor')
def actor():
return ActorFactory()

# Create and return a portability outbox linked to the actor
@pytest.fixture
def outbox(actor):
return PortabilityOutbox.objects.create(actor=actor)
def note(actor):
return NoteFactory(actor=actor)

@pytest.fixture
def activity(actor, note):
return ActivityFactory(actor=actor, note=note)

@pytest.fixture
def outbox(actor):
return actor.portability_outbox.first()
12 changes: 12 additions & 0 deletions testbed/core/tests/test_activities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pytest


def test_activity_creation(activity):
assert activity.actor is not None
assert activity.note is not None
assert activity.type in ['Create', 'Like', 'Update', 'Follow', 'Announce', 'Delete', 'Undo', 'Flag']
assert activity.visibility in ['public', 'private', 'followers-only']

def test_activity_str_representation(activity):
assert activity.type in str(activity)
assert activity.actor.username in str(activity)
26 changes: 26 additions & 0 deletions testbed/core/tests/test_json_ld.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest


def test_actor_json_ld(actor):
json_ld = actor.get_json_ld()
assert json_ld['type'] == 'Person'
assert json_ld['preferredUsername'] == actor.username
assert '@context' in json_ld

def test_note_json_ld(note):
json_ld = note.get_json_ld()
assert json_ld['type'] == 'Note'
assert json_ld['content'] == note.content
assert '@context' in json_ld

def test_activity_json_ld(activity):
json_ld = activity.get_json_ld()
assert json_ld['type'] == activity.type
assert 'object' in json_ld
assert '@context' in json_ld

def test_outbox_json_ld(outbox):
json_ld = outbox.get_json_ld()
assert json_ld['type'] == 'OrderedCollection'
assert '@context' in json_ld
assert json_ld['totalItems'] == outbox.activities.count()
50 changes: 22 additions & 28 deletions testbed/core/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,28 @@
import pytest
from django.core.exceptions import ValidationError
from testbed.core.factories import ActorFactory


# Test that an Actor is created correctly
@pytest.mark.django_db
def test_actor_creation(actor):
assert actor.username == 'testactor'
assert actor.full_name == 'Test Actor'
assert actor.user.username == 'testuser'
assert actor.user is not None
assert actor.username == actor.user.username
assert actor.full_name is not None

# Test that the Actor's JSON-LD representation is correct
@pytest.mark.django_db
def test_actor_json_ld(actor):
expected = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://swicg.github.io/activitypub-data-portability/lola.jsonld",
],
"type": "Person",
"id": "https://example.com/users/testactor",
"preferredUsername": "testactor",
"name": "testactor",
"previously": {},
}
assert actor.get_json_ld() == expected
def test_actor_str_representation(actor):
assert str(actor) == actor.username

# Test that a Portability Outbox is linked to an Actor
@pytest.mark.django_db
def test_outbox_creation(outbox):
assert outbox.actor.username == 'testactor'
assert outbox.actor.full_name == 'Test Actor'
assert outbox.actor.user.username == 'testuser'
assert outbox.actor.user.actor == outbox.actor
assert outbox.actor.actor_activities.count() == 0
@pytest.mark.parametrize('invalid_username', [
'user space', # Contains space
'ab', # Too short
'user@name' # Non-alphanumeric
])
def test_username_validation(invalid_username):
with pytest.raises(ValidationError):
actor =ActorFactory(username=invalid_username)
actor.full_clean() # This triggers the validation

def test_portability_outbox_creation(actor):
assert actor.portability_outbox.count() == 1
outbox = actor.portability_outbox.first()
assert outbox.activities.count() == 1 # Initial Create activity
assert outbox.activities.first().type == 'Create'
12 changes: 12 additions & 0 deletions testbed/core/tests/test_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pytest


def test_note_creation(note):
assert note.actor is not None
assert note.content is not None
assert note.published is not None
assert note.visibility in ['public', 'private', 'followers-only']

def test_note_str_representation(note):
assert str(note).startswith('Note by')
assert note.content[:30] in str(note)