diff --git a/api/funkwhale_api/common/tests/test_jwt_querystring.py b/api/funkwhale_api/common/tests/test_jwt_querystring.py
deleted file mode 100644
index 90e63775d9ef7e5e2aeeaac6a144d3ff1812bd6e..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/common/tests/test_jwt_querystring.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from test_plus.test import TestCase
-from rest_framework_jwt.settings import api_settings
-
-from funkwhale_api.users.models import User
-
-
-jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
-jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
-
-
-class TestJWTQueryString(TestCase):
-    www_authenticate_realm = 'api'
-
-    def test_can_authenticate_using_token_param_in_url(self):
-        user = User.objects.create_superuser(
-            username='test', email='test@test.com', password='test')
-
-        url = self.reverse('api:v1:tracks-list')
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 401)
-
-        payload = jwt_payload_handler(user)
-        token = jwt_encode_handler(payload)
-        print(payload, token)
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url, data={
-                'jwt': token
-            })
-
-            self.assertEqual(response.status_code, 200)
diff --git a/api/funkwhale_api/downloader/tests/test_downloader.py b/api/funkwhale_api/downloader/tests/test_downloader.py
deleted file mode 100644
index 7cfaa63c83e40f040aaa4f21de1a519c74d18582..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/downloader/tests/test_downloader.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import os
-from test_plus.test import TestCase
-from .. import downloader
-from funkwhale_api.utils.tests import TMPDirTestCaseMixin
-
-
-class TestDownloader(TMPDirTestCaseMixin, TestCase):
-
-    def test_can_download_audio_from_youtube_url_to_vorbis(self):
-        data = downloader.download('https://www.youtube.com/watch?v=tPEE9ZwTmy0', target_directory=self.download_dir)
-        self.assertEqual(
-            data['audio_file_path'],
-            os.path.join(self.download_dir, 'tPEE9ZwTmy0.ogg'))
-        self.assertTrue(os.path.exists(data['audio_file_path']))
diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fed66edb2ed000e75df819ffb862949672889a5
--- /dev/null
+++ b/api/funkwhale_api/factories.py
@@ -0,0 +1,30 @@
+import factory
+import persisting_theory
+
+
+class FactoriesRegistry(persisting_theory.Registry):
+    look_into = 'factories'
+
+    def prepare_name(self, data, name=None):
+        return name or data._meta.model._meta.label
+
+
+registry = FactoriesRegistry()
+
+
+def ManyToManyFromList(field_name):
+    """
+    To automate the pattern described in
+    http://factoryboy.readthedocs.io/en/latest/recipes.html#simple-many-to-many-relationship
+    """
+
+    @factory.post_generation
+    def inner(self, create, extracted, **kwargs):
+        if not create:
+            return
+
+        if extracted:
+            field = getattr(self, field_name)
+            field.add(*extracted)
+
+    return inner
diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..233dd049c5477fdb0c83719d397f41c9f62536d4
--- /dev/null
+++ b/api/funkwhale_api/favorites/factories.py
@@ -0,0 +1,15 @@
+import factory
+
+from funkwhale_api.factories import registry
+
+from funkwhale_api.music.factories import TrackFactory
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class TrackFavorite(factory.django.DjangoModelFactory):
+    track = factory.SubFactory(TrackFactory)
+    user = factory.SubFactory(UserFactory)
+
+    class Meta:
+        model = 'favorites.TrackFavorite'
diff --git a/api/funkwhale_api/favorites/tests/test_favorites.py b/api/funkwhale_api/favorites/tests/test_favorites.py
deleted file mode 100644
index 78c64a41338607a11830a8fdb64df758d9214ab6..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/favorites/tests/test_favorites.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music.models import Track, Artist
-from funkwhale_api.favorites.models import TrackFavorite
-from funkwhale_api.users.models import User
-
-class TestFavorites(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.artist = Artist.objects.create(name='test')
-        self.track = Track.objects.create(title='test', artist=self.artist)
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_user_can_add_favorite(self):
-        TrackFavorite.add(self.track, self.user)
-
-        favorite = TrackFavorite.objects.latest('id')
-        self.assertEqual(favorite.track, self.track)
-        self.assertEqual(favorite.user, self.user)
-
-    def test_user_can_get_his_favorites(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-
-        response = self.client.get(url)
-
-        expected = [
-            {
-                'track': self.track.pk,
-                'id': favorite.id,
-                'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
-            }
-        ]
-        parsed_json = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(expected, parsed_json['results'])
-
-    def test_user_can_add_favorite_via_api(self):
-        url = reverse('api:v1:favorites:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.post(url, {'track': self.track.pk})
-
-        favorite = TrackFavorite.objects.latest('id')
-        expected = {
-            'track': self.track.pk,
-            'id': favorite.id,
-            'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
-        }
-        parsed_json = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(expected, parsed_json)
-        self.assertEqual(favorite.track, self.track)
-        self.assertEqual(favorite.user, self.user)
-
-    def test_user_can_remove_favorite_via_api(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.delete(url, {'track': self.track.pk})
-        self.assertEqual(response.status_code, 204)
-        self.assertEqual(TrackFavorite.objects.count(), 0)
-
-    def test_user_can_remove_favorite_via_api_using_track_id(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-remove')
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.delete(
-            url, json.dumps({'track': self.track.pk}),
-            content_type='application/json'
-        )
-
-        self.assertEqual(response.status_code, 204)
-        self.assertEqual(TrackFavorite.objects.count(), 0)
-
-    from funkwhale_api.users.models import User
-
-    def test_can_restrict_api_views_to_authenticated_users(self):
-        urls = [
-            ('api:v1:favorites:tracks-list', 'get'),
-        ]
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=True):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 401)
-
-        self.client.login(username=self.user.username, password='test')
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=False):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 200)
-
-    def test_can_filter_tracks_by_favorites(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-
-        response = self.client.get(url, data={'favorites': True})
-
-        parsed_json = json.loads(response.content.decode('utf-8'))
-        self.assertEqual(parsed_json['count'], 1)
-        self.assertEqual(parsed_json['results'][0]['id'], self.track.id)
diff --git a/api/funkwhale_api/history/tests/factories.py b/api/funkwhale_api/history/factories.py
similarity index 58%
rename from api/funkwhale_api/history/tests/factories.py
rename to api/funkwhale_api/history/factories.py
index 0a411adf0ce02467e41fe8dd94958755606f0b95..86fea64d251c3a0228bc2a0c5edd5525e10cd7dd 100644
--- a/api/funkwhale_api/history/tests/factories.py
+++ b/api/funkwhale_api/history/factories.py
@@ -1,9 +1,11 @@
 import factory
-from funkwhale_api.music.tests import factories
 
-from funkwhale_api.users.tests.factories import UserFactory
+from funkwhale_api.factories import registry
+from funkwhale_api.music import factories
+from funkwhale_api.users.factories import UserFactory
 
 
+@registry.register
 class ListeningFactory(factory.django.DjangoModelFactory):
     user = factory.SubFactory(UserFactory)
     track = factory.SubFactory(factories.TrackFactory)
diff --git a/api/funkwhale_api/history/tests/test_history.py b/api/funkwhale_api/history/tests/test_history.py
deleted file mode 100644
index 5cb45c946d092d8e0d58d357397581b117bb616f..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/history/tests/test_history.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import random
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-from django.utils import timezone
-
-from funkwhale_api.music.tests.factories import TrackFactory
-
-from funkwhale_api.users.models import User
-from funkwhale_api.history import models
-
-
-class TestHistory(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_create_listening(self):
-        track = TrackFactory()
-        now = timezone.now()
-        l = models.Listening.objects.create(user=self.user, track=track)
-
-    def test_anonymous_user_can_create_listening_via_api(self):
-        track = TrackFactory()
-        url = self.reverse('api:v1:history:listenings-list')
-        response = self.client.post(url, {
-            'track': track.pk,
-        })
-
-        listening = models.Listening.objects.latest('id')
-
-        self.assertEqual(listening.track, track)
-        self.assertIsNotNone(listening.session_key)
-
-    def test_logged_in_user_can_create_listening_via_api(self):
-        track = TrackFactory()
-
-        self.client.login(username=self.user.username, password='test')
-
-        url = self.reverse('api:v1:history:listenings-list')
-        response = self.client.post(url, {
-            'track': track.pk,
-        })
-
-        listening = models.Listening.objects.latest('id')
-
-        self.assertEqual(listening.track, track)
-        self.assertEqual(listening.user, self.user)
diff --git a/api/funkwhale_api/music/tests/factories.py b/api/funkwhale_api/music/factories.py
similarity index 81%
rename from api/funkwhale_api/music/tests/factories.py
rename to api/funkwhale_api/music/factories.py
index 567e2a765f426e8ca9ce4359bf6644431ef97dec..d776cd9459cd42fdbac62e7168b7262ef2718b0e 100644
--- a/api/funkwhale_api/music/tests/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -1,11 +1,16 @@
 import factory
 import os
 
-from funkwhale_api.users.tests.factories import UserFactory
+from funkwhale_api.factories import registry, ManyToManyFromList
+from funkwhale_api.users.factories import UserFactory
 
-SAMPLES_PATH = os.path.dirname(os.path.abspath(__file__))
+SAMPLES_PATH = os.path.join(
+    os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+    'tests', 'music'
+)
 
 
+@registry.register
 class ArtistFactory(factory.django.DjangoModelFactory):
     name = factory.Faker('name')
     mbid = factory.Faker('uuid4')
@@ -14,6 +19,7 @@ class ArtistFactory(factory.django.DjangoModelFactory):
         model = 'music.Artist'
 
 
+@registry.register
 class AlbumFactory(factory.django.DjangoModelFactory):
     title = factory.Faker('sentence', nb_words=3)
     mbid = factory.Faker('uuid4')
@@ -26,17 +32,19 @@ class AlbumFactory(factory.django.DjangoModelFactory):
         model = 'music.Album'
 
 
+@registry.register
 class TrackFactory(factory.django.DjangoModelFactory):
     title = factory.Faker('sentence', nb_words=3)
     mbid = factory.Faker('uuid4')
     album = factory.SubFactory(AlbumFactory)
     artist = factory.SelfAttribute('album.artist')
     position = 1
-
+    tags = ManyToManyFromList('tags')
     class Meta:
         model = 'music.Track'
 
 
+@registry.register
 class TrackFileFactory(factory.django.DjangoModelFactory):
     track = factory.SubFactory(TrackFactory)
     audio_file = factory.django.FileField(
@@ -46,6 +54,7 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
         model = 'music.TrackFile'
 
 
+@registry.register
 class ImportBatchFactory(factory.django.DjangoModelFactory):
     submitted_by = factory.SubFactory(UserFactory)
 
@@ -53,14 +62,17 @@ class ImportBatchFactory(factory.django.DjangoModelFactory):
         model = 'music.ImportBatch'
 
 
+@registry.register
 class ImportJobFactory(factory.django.DjangoModelFactory):
     batch = factory.SubFactory(ImportBatchFactory)
     source = factory.Faker('url')
+    mbid = factory.Faker('uuid4')
 
     class Meta:
         model = 'music.ImportJob'
 
 
+@registry.register
 class WorkFactory(factory.django.DjangoModelFactory):
     mbid = factory.Faker('uuid4')
     language = 'eng'
@@ -71,6 +83,7 @@ class WorkFactory(factory.django.DjangoModelFactory):
         model = 'music.Work'
 
 
+@registry.register
 class LyricsFactory(factory.django.DjangoModelFactory):
     work = factory.SubFactory(WorkFactory)
     url = factory.Faker('url')
@@ -80,6 +93,7 @@ class LyricsFactory(factory.django.DjangoModelFactory):
         model = 'music.Lyrics'
 
 
+@registry.register
 class TagFactory(factory.django.DjangoModelFactory):
     name = factory.SelfAttribute('slug')
     slug = factory.Faker('slug')
diff --git a/api/funkwhale_api/music/tests/test_api.py b/api/funkwhale_api/music/tests/test_api.py
deleted file mode 100644
index 2460fa97d0830a8b66ceaeef80f680def65b02cc..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_api.py
+++ /dev/null
@@ -1,256 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.utils.tests import TMPDirTestCaseMixin
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.users.models import User
-
-from . import data as api_data
-from . import factories
-
-
-class TestAPI(TMPDirTestCaseMixin, TestCase):
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
-    @unittest.mock.patch('funkwhale_api.music.models.TrackFile.download_file', return_value=None)
-    def test_can_submit_youtube_url_for_track_import(self, *mocks):
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        video_id = 'tPEE9ZwTmy0'
-        url = reverse('api:v1:submit-single')
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
-        track = models.Track.objects.get(mbid=mbid)
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-        self.assertEqual(track.album.title, 'Marsupial Madness')
-        # self.assertIn(video_id, track.files.first().audio_file.name)
-
-    def test_import_creates_an_import_with_correct_data(self):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        video_id = 'tPEE9ZwTmy0'
-        url = reverse('api:v1:submit-single')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 1)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-        job = batch.jobs.first()
-        self.assertEqual(str(job.mbid), mbid)
-        self.assertEqual(job.status, 'pending')
-        self.assertEqual(job.source, 'https://www.youtube.com/watch?v={0}'.format(video_id))
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
-    def test_can_import_whole_album(self, *mocks):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        payload = {
-            'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
-            'tracks': [
-                {
-                'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=1111111111',
-                },
-                {
-                'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=2222222222',
-                },
-                {
-                'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=3333333333',
-                },
-            ]
-        }
-        url = reverse('api:v1:submit-album')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, json.dumps(payload), content_type="application/json")
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 3)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-
-        album = models.Album.objects.latest('id')
-        self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
-        medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
-        self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
-
-        for track in medium_data['track-list']:
-            instance = models.Track.objects.get(mbid=track['recording']['id'])
-            self.assertEqual(instance.title, track['recording']['title'])
-            self.assertEqual(instance.position, int(track['position']))
-            self.assertEqual(instance.title, track['recording']['title'])
-
-        for row in payload['tracks']:
-            job = models.ImportJob.objects.get(mbid=row['mbid'])
-            self.assertEqual(str(job.mbid), row['mbid'])
-            self.assertEqual(job.status, 'pending')
-            self.assertEqual(job.source, row['source'])
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
-    def test_can_import_whole_artist(self, *mocks):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        payload = {
-            'artistId': 'mbid',
-            'albums': [
-                {
-                    'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
-                    'tracks': [
-                        {
-                        'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=1111111111',
-                        },
-                        {
-                        'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=2222222222',
-                        },
-                        {
-                        'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=3333333333',
-                        },
-                    ]
-                }
-            ]
-        }
-        url = reverse('api:v1:submit-artist')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, json.dumps(payload), content_type="application/json")
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 3)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-
-        album = models.Album.objects.latest('id')
-        self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
-        medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
-        self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
-
-        for track in medium_data['track-list']:
-            instance = models.Track.objects.get(mbid=track['recording']['id'])
-            self.assertEqual(instance.title, track['recording']['title'])
-            self.assertEqual(instance.position, int(track['position']))
-            self.assertEqual(instance.title, track['recording']['title'])
-
-        for row in payload['albums'][0]['tracks']:
-            job = models.ImportJob.objects.get(mbid=row['mbid'])
-            self.assertEqual(str(job.mbid), row['mbid'])
-            self.assertEqual(job.status, 'pending')
-            self.assertEqual(job.source, row['source'])
-
-    def test_user_can_query_api_for_his_own_batches(self):
-        user1 = User.objects.create_superuser(username='test1', email='test1@test.com', password='test')
-        user2 = User.objects.create_superuser(username='test2', email='test2@test.com', password='test')
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        source = 'https://www.youtube.com/watch?v=tPEE9ZwTmy0'
-
-        batch = models.ImportBatch.objects.create(submitted_by=user1)
-        job = models.ImportJob.objects.create(batch=batch, mbid=mbid, source=source)
-
-        url = reverse('api:v1:import-batches-list')
-
-        self.client.login(username=user2.username, password='test')
-        response2 = self.client.get(url)
-        self.assertJSONEqual(response2.content.decode('utf-8'), '{"count":0,"next":null,"previous":null,"results":[]}')
-        self.client.logout()
-
-        self.client.login(username=user1.username, password='test')
-        response1 = self.client.get(url)
-        self.assertIn(mbid, response1.content.decode('utf-8'))
-
-    def test_can_search_artist(self):
-        artist1 = models.Artist.objects.create(name='Test1')
-        artist2 = models.Artist.objects.create(name='Test2')
-        query = 'test1'
-        expected = '[{0}]'.format(json.dumps(serializers.ArtistSerializerNested(artist1).data))
-        url = self.reverse('api:v1:artists-search')
-        response = self.client.get(url + '?query={0}'.format(query))
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_search_artist_by_name_start(self):
-        artist1 = factories.ArtistFactory(name='alpha')
-        artist2 = factories.ArtistFactory(name='beta')
-        results = {
-            'next': None,
-            'previous': None,
-            'count': 1,
-            'results': [serializers.ArtistSerializerNested(artist1).data]
-        }
-        expected = json.dumps(results)
-        url = self.reverse('api:v1:artists-list')
-        response = self.client.get(url, {'name__startswith': 'a'})
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_search_tracks(self):
-        artist1 = models.Artist.objects.create(name='Test1')
-        artist2 = models.Artist.objects.create(name='Test2')
-        track1 = models.Track.objects.create(artist=artist1, title="test_track1")
-        track2 = models.Track.objects.create(artist=artist2, title="test_track2")
-        query = 'test track 1'
-        expected = '[{0}]'.format(json.dumps(serializers.TrackSerializerNested(track1).data))
-        url = self.reverse('api:v1:tracks-search')
-        response = self.client.get(url + '?query={0}'.format(query))
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_restrict_api_views_to_authenticated_users(self):
-        urls = [
-            ('api:v1:tags-list', 'get'),
-            ('api:v1:tracks-list', 'get'),
-            ('api:v1:artists-list', 'get'),
-            ('api:v1:albums-list', 'get'),
-        ]
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=True):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 401)
-
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=False):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 200)
-
-    def test_track_file_url_is_restricted_to_authenticated_users(self):
-        f = factories.TrackFileFactory()
-        self.assertNotEqual(f.audio_file, None)
-        url = f.path
-
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 401)
-
-        user = User.objects.create_superuser(
-            username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 200)
-
-        self.assertEqual(
-            response['X-Accel-Redirect'],
-            '/_protected{}'.format(f.audio_file.url)
-        )
diff --git a/api/funkwhale_api/music/tests/test_lyrics.py b/api/funkwhale_api/music/tests/test_lyrics.py
deleted file mode 100644
index 9a05e5eb87b88752ea9aa81ac081a0e2981d9570..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_lyrics.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.users.models import User
-from funkwhale_api.music import lyrics as lyrics_utils
-
-from .mocking import lyricswiki
-from . import factories
-from . import data as api_data
-
-
-
-class TestLyrics(TestCase):
-
-    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
-                         return_value=lyricswiki.content)
-    def test_works_import_lyrics_if_any(self, *mocks):
-        lyrics = factories.LyricsFactory(
-            url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
-
-        lyrics.fetch_content()
-        self.assertIn(
-            'Grab a brush and put on a little makeup',
-            lyrics.content,
-        )
-
-    def test_clean_content(self):
-        c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
-        d = lyrics_utils.extract_content(c)
-        d = lyrics_utils.clean_content(d)
-
-        expected = """Hello
-Is it me you're looking for?
-"""
-        self.assertEqual(d, expected)
-
-    def test_markdown_rendering(self):
-        content = """Hello
-Is it me you're looking for?"""
-
-        l = factories.LyricsFactory(content=content)
-
-        expected = "<p>Hello<br />Is it me you're looking for?</p>"
-        self.assertHTMLEqual(expected, l.content_rendered)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
-                         return_value=api_data.tracks['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
-                         return_value=lyricswiki.content)
-    def test_works_import_lyrics_if_any(self, *mocks):
-        track = factories.TrackFactory(
-            work=None,
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-
-        url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk})
-        user = User.objects.create_user(
-            username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 200)
-
-        track.refresh_from_db()
-        lyrics = models.Lyrics.objects.latest('id')
-        work = models.Work.objects.latest('id')
-
-        self.assertEqual(track.work, work)
-        self.assertEqual(lyrics.work, work)
diff --git a/api/funkwhale_api/music/tests/test_metadata.py b/api/funkwhale_api/music/tests/test_metadata.py
deleted file mode 100644
index 9b8c7665337597c04d5770efb4bdc829f18ba380..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_metadata.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import unittest
-import os
-import datetime
-from test_plus.test import TestCase
-from funkwhale_api.music import metadata
-
-DATA_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-class TestMetadata(TestCase):
-
-    def test_can_get_metadata_from_ogg_file(self, *mocks):
-        path = os.path.join(DATA_DIR, 'test.ogg')
-        data = metadata.Metadata(path)
-
-        self.assertEqual(
-            data.get('title'),
-            'Peer Gynt Suite no. 1, op. 46: I. Morning'
-        )
-        self.assertEqual(
-            data.get('artist'),
-            'Edvard Grieg'
-        )
-        self.assertEqual(
-            data.get('album'),
-            'Peer Gynt Suite no. 1, op. 46'
-        )
-        self.assertEqual(
-            data.get('date'),
-            datetime.date(2012, 8, 15),
-        )
-        self.assertEqual(
-            data.get('track_number'),
-            1
-        )
-
-        self.assertEqual(
-            data.get('musicbrainz_albumid'),
-            'a766da8b-8336-47aa-a3ee-371cc41ccc75')
-        self.assertEqual(
-            data.get('musicbrainz_recordingid'),
-            'bd21ac48-46d8-4e78-925f-d9cc2a294656')
-        self.assertEqual(
-            data.get('musicbrainz_artistid'),
-            '013c8e5b-d72a-4cd3-8dee-6c64d6125823')
-
-    def test_can_get_metadata_from_id3_mp3_file(self, *mocks):
-        path = os.path.join(DATA_DIR, 'test.mp3')
-        data = metadata.Metadata(path)
-
-        self.assertEqual(
-            data.get('title'),
-            'Bend'
-        )
-        self.assertEqual(
-            data.get('artist'),
-            'Binärpilot'
-        )
-        self.assertEqual(
-            data.get('album'),
-            'You Can\'t Stop Da Funk'
-        )
-        self.assertEqual(
-            data.get('date'),
-            datetime.date(2006, 2, 7),
-        )
-        self.assertEqual(
-            data.get('track_number'),
-            1
-        )
-
-        self.assertEqual(
-            data.get('musicbrainz_albumid'),
-            'ce40cdb1-a562-4fd8-a269-9269f98d4124')
-        self.assertEqual(
-            data.get('musicbrainz_recordingid'),
-            'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')
-        self.assertEqual(
-            data.get('musicbrainz_artistid'),
-            '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')
diff --git a/api/funkwhale_api/music/tests/test_music.py b/api/funkwhale_api/music/tests/test_music.py
deleted file mode 100644
index 5cf9d0cf96d53c6060759b6a8c65cf1aaef6917f..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_music.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from test_plus.test import TestCase
-import unittest.mock
-from funkwhale_api.music import models
-import datetime
-
-from . import factories
-from . import data as api_data
-from .cover import binary_data
-
-
-class TestMusic(TestCase):
-
-    @unittest.mock.patch('musicbrainzngs.search_artists', return_value=api_data.artists['search']['adhesive_wombat'])
-    def test_can_create_artist_from_api(self, *mocks):
-        artist = models.Artist.create_from_api(query="Adhesive wombat")
-        data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
-
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(artist.mbid, data['id'])
-        self.assertEqual(artist.name, 'Adhesive Wombat')
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.search', return_value=api_data.albums['search']['hypnotize'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    def test_can_create_album_from_api(self, *mocks):
-        album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
-        data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
-
-        self.assertEqual(album.mbid, data['id'])
-        self.assertEqual(album.title, 'Hypnotize')
-        with self.assertRaises(ValueError):
-            self.assertFalse(album.cover.path is None)
-        self.assertEqual(album.release_date, datetime.date(2005, 1, 1))
-        self.assertEqual(album.artist.name, 'System of a Down')
-        self.assertEqual(album.artist.mbid, data['artist-credit'][0]['artist']['id'])
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
-    def test_can_create_track_from_api(self, *mocks):
-        track = models.Track.create_from_api(query="8-bit adventure")
-        data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
-        self.assertEqual(track.mbid, data['id'])
-        self.assertTrue(track.artist.pk is not None)
-        self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-        self.assertEqual(str(track.album.mbid), 'a50d2a81-2a50-484d-9cb4-b9f6833f583e')
-        self.assertEqual(track.album.title, 'Marsupial Madness')
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
-    def test_can_create_track_from_api_with_corresponding_tags(self, *mocks):
-        track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
-        expected_tags = ['techno', 'good-music']
-        track_tags = [tag.slug for tag in track.tags.all()]
-        for tag in expected_tags:
-            self.assertIn(tag, track_tags)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
-    def test_can_get_or_create_track_from_api(self, *mocks):
-        track = models.Track.create_from_api(query="8-bit adventure")
-        data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
-        self.assertEqual(track.mbid, data['id'])
-        self.assertTrue(track.artist.pk is not None)
-        self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-
-        track2, created = models.Track.get_or_create_from_api(mbid=data['id'])
-        self.assertFalse(created)
-        self.assertEqual(track, track2)
-
-    def test_album_tags_deduced_from_tracks_tags(self):
-        tag = factories.TagFactory()
-        album = factories.AlbumFactory()
-        tracks = factories.TrackFactory.create_batch(album=album, size=5)
-
-        for track in tracks:
-            track.tags.add(tag)
-
-        album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk)
-
-        with self.assertNumQueries(0):
-            self.assertIn(tag, album.tags)
-
-    def test_artist_tags_deduced_from_album_tags(self):
-        tag = factories.TagFactory()
-        artist = factories.ArtistFactory()
-        album = factories.AlbumFactory(artist=artist)
-        tracks = factories.TrackFactory.create_batch(album=album, size=5)
-
-        for track in tracks:
-            track.tags.add(tag)
-
-        artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk)
-
-        with self.assertNumQueries(0):
-            self.assertIn(tag, artist.tags)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=binary_data)
-    def test_can_download_image_file_for_album(self, *mocks):
-        # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
-        album = factories.AlbumFactory(mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
-        album.get_image()
-        album.save()
-
-        self.assertEqual(album.cover.file.read(), binary_data)
diff --git a/api/funkwhale_api/music/tests/test_works.py b/api/funkwhale_api/music/tests/test_works.py
deleted file mode 100644
index 55714bce2c142c36b86f52c9361292df79689695..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_works.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.music.tests import factories
-from funkwhale_api.users.models import User
-
-from . import data as api_data
-
-
-class TestWorks(TestCase):
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    def test_can_import_work(self, *mocks):
-        recording = factories.TrackFactory(
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-        work = models.Work.create_from_api(id=mbid)
-
-        self.assertEqual(work.title, 'Chop Suey!')
-        self.assertEqual(work.nature, 'song')
-        self.assertEqual(work.language, 'eng')
-        self.assertEqual(work.mbid, mbid)
-
-        # a imported work should also be linked to corresponding recordings
-
-        recording.refresh_from_db()
-        self.assertEqual(recording.work, work)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
-                         return_value=api_data.tracks['get']['chop_suey'])
-    def test_can_get_work_from_recording(self, *mocks):
-        recording = factories.TrackFactory(
-            work=None,
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-
-        self.assertEqual(recording.work, None)
-
-        work = recording.get_work()
-
-        self.assertEqual(work.title, 'Chop Suey!')
-        self.assertEqual(work.nature, 'song')
-        self.assertEqual(work.language, 'eng')
-        self.assertEqual(work.mbid, mbid)
-
-        recording.refresh_from_db()
-        self.assertEqual(recording.work, work)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    def test_works_import_lyrics_if_any(self, *mocks):
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-        work = models.Work.create_from_api(id=mbid)
-
-        lyrics = models.Lyrics.objects.latest('id')
-        self.assertEqual(lyrics.work, work)
-        self.assertEqual(
-            lyrics.url, 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
diff --git a/api/funkwhale_api/musicbrainz/tests/__init__.py b/api/funkwhale_api/musicbrainz/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/musicbrainz/tests/test_api.py b/api/funkwhale_api/musicbrainz/tests/test_api.py
deleted file mode 100644
index b0911f1c54983fd0f41eda12027ea0c836d587a0..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/musicbrainz/tests/test_api.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.musicbrainz import api
-from . import data as api_data
-
-
-class TestAPI(TestCase):
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.recordings['search']['brontide matador'])
-    def test_can_search_recording_in_musicbrainz_api(self, *mocks):
-        query = 'brontide matador'
-        url = reverse('api:v1:providers:musicbrainz:search-recordings')
-        expected = api_data.recordings['search']['brontide matador']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.releases.search',
-        return_value=api_data.releases['search']['brontide matador'])
-    def test_can_search_release_in_musicbrainz_api(self, *mocks):
-        query = 'brontide matador'
-        url = reverse('api:v1:providers:musicbrainz:search-releases')
-        expected = api_data.releases['search']['brontide matador']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.artists.search',
-        return_value=api_data.artists['search']['lost fingers'])
-    def test_can_search_artists_in_musicbrainz_api(self, *mocks):
-        query = 'lost fingers'
-        url = reverse('api:v1:providers:musicbrainz:search-artists')
-        expected = api_data.artists['search']['lost fingers']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['lost fingers'])
-    def test_can_get_artist_in_musicbrainz_api(self, *mocks):
-        uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
-        url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
-            'uuid': uuid,
-        })
-        response = self.client.get(url)
-        expected = api_data.artists['get']['lost fingers']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.release_groups.browse',
-        return_value=api_data.release_groups['browse']['lost fingers'])
-    def test_can_broswe_release_group_using_musicbrainz_api(self, *mocks):
-        uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
-        url = reverse(
-            'api:v1:providers:musicbrainz:release-group-browse',
-            kwargs={
-                'artist_uuid': uuid,
-            }
-        )
-        response = self.client.get(url)
-        expected = api_data.release_groups['browse']['lost fingers']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.releases.browse',
-        return_value=api_data.releases['browse']['Lost in the 80s'])
-    def test_can_broswe_releases_using_musicbrainz_api(self, *mocks):
-        uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
-        url = reverse(
-            'api:v1:providers:musicbrainz:release-browse',
-            kwargs={
-                'release_group_uuid': uuid,
-            }
-        )
-        response = self.client.get(url)
-        expected = api_data.releases['browse']['Lost in the 80s']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
diff --git a/api/funkwhale_api/musicbrainz/tests/test_cache.py b/api/funkwhale_api/musicbrainz/tests/test_cache.py
deleted file mode 100644
index d2d1260ecb1cb36dc95e5268d269b0d8dbcf774c..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/musicbrainz/tests/test_cache.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import unittest
-from test_plus.test import TestCase
-
-from funkwhale_api.musicbrainz import client
-
-
-class TestAPI(TestCase):
-    def test_can_search_recording_in_musicbrainz_api(self, *mocks):
-        r = {'hello': 'world'}
-        mocked = 'funkwhale_api.musicbrainz.client._api.search_artists'
-        with unittest.mock.patch(mocked, return_value=r) as m:
-            self.assertEqual(client.api.artists.search('test'), r)
-            # now call from cache
-            self.assertEqual(client.api.artists.search('test'), r)
-            self.assertEqual(client.api.artists.search('test'), r)
-
-            self.assertEqual(m.call_count, 1)
diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..19e4770cfae15fd6d790063026dbdb04770b2e1b
--- /dev/null
+++ b/api/funkwhale_api/playlists/factories.py
@@ -0,0 +1,13 @@
+import factory
+
+from funkwhale_api.factories import registry
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class PlaylistFactory(factory.django.DjangoModelFactory):
+    name = factory.Faker('name')
+    user = factory.SubFactory(UserFactory)
+
+    class Meta:
+        model = 'playlists.Playlist'
diff --git a/api/funkwhale_api/playlists/tests/__init__.py b/api/funkwhale_api/playlists/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/playlists/tests/test_playlists.py b/api/funkwhale_api/playlists/tests/test_playlists.py
deleted file mode 100644
index 2f61889ee13d5afbc949ba36a06e9b30b6dbfad7..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/playlists/tests/test_playlists.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-from django.utils import timezone
-
-from funkwhale_api.music.tests import factories
-from funkwhale_api.users.models import User
-from funkwhale_api.playlists import models
-from funkwhale_api.playlists.serializers import PlaylistSerializer
-
-
-class TestPlayLists(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_create_playlist(self):
-        tracks = factories.TrackFactory.create_batch(size=5)
-        playlist = models.Playlist.objects.create(user=self.user, name="test")
-
-        previous = None
-        for i in range(len(tracks)):
-            previous = playlist.add_track(tracks[i], previous=previous)
-
-        playlist_tracks = list(playlist.playlist_tracks.all())
-
-        previous = None
-        for idx, track in enumerate(tracks):
-            plt = playlist_tracks[idx]
-            self.assertEqual(plt.position, idx)
-            self.assertEqual(plt.track, track)
-            if previous:
-                self.assertEqual(playlist_tracks[idx + 1], previous)
-            self.assertEqual(plt.playlist, playlist)
-
-    def test_can_create_playlist_via_api(self):
-        self.client.login(username=self.user.username, password='test')
-        url = reverse('api:v1:playlists-list')
-        data = {
-            'name': 'test',
-        }
-
-        response = self.client.post(url, data)
-
-        playlist = self.user.playlists.latest('id')
-        self.assertEqual(playlist.name, 'test')
-
-    def test_can_add_playlist_track_via_api(self):
-        tracks = factories.TrackFactory.create_batch(size=5)
-        playlist = models.Playlist.objects.create(user=self.user, name="test")
-
-        self.client.login(username=self.user.username, password='test')
-
-        url = reverse('api:v1:playlist-tracks-list')
-        data = {
-            'playlist': playlist.pk,
-            'track': tracks[0].pk
-        }
-
-        response = self.client.post(url, data)
-        plts = self.user.playlists.latest('id').playlist_tracks.all()
-        self.assertEqual(plts.first().track, tracks[0])
diff --git a/api/funkwhale_api/providers/audiofile/tests/__init__.py b/api/funkwhale_api/providers/audiofile/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py b/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py
deleted file mode 100644
index f8d36986a3170fea19b6be1b9030b354fddcba29..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import os
-import datetime
-import unittest
-from test_plus.test import TestCase
-
-from funkwhale_api.providers.audiofile import tasks
-
-DATA_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-class TestAudioFile(TestCase):
-    def test_can_import_single_audio_file(self, *mocks):
-        metadata = {
-            'artist': ['Test artist'],
-            'album': ['Test album'],
-            'title': ['Test track'],
-            'TRACKNUMBER': ['4'],
-            'date': ['2012-08-15'],
-            'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
-            'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
-            'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
-        }
-
-        m1 = unittest.mock.patch('mutagen.File', return_value=metadata)
-        m2 = unittest.mock.patch(
-            'funkwhale_api.music.metadata.Metadata.get_file_type',
-            return_value='OggVorbis',
-        )
-        with m1, m2:
-            track_file = tasks.from_path(
-                os.path.join(DATA_DIR, 'dummy_file.ogg'))
-
-        self.assertEqual(
-            track_file.track.title, metadata['title'][0])
-        self.assertEqual(
-            track_file.track.mbid, metadata['musicbrainz_trackid'][0])
-        self.assertEqual(
-            track_file.track.position, 4)
-        self.assertEqual(
-            track_file.track.album.title, metadata['album'][0])
-        self.assertEqual(
-            track_file.track.album.mbid,
-            metadata['musicbrainz_albumid'][0])
-        self.assertEqual(
-            track_file.track.album.release_date, datetime.date(2012, 8, 15))
-        self.assertEqual(
-            track_file.track.artist.name, metadata['artist'][0])
-        self.assertEqual(
-            track_file.track.artist.mbid,
-            metadata['musicbrainz_artistid'][0])
diff --git a/api/funkwhale_api/providers/youtube/tests/__init__.py b/api/funkwhale_api/providers/youtube/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/providers/youtube/tests/test_youtube.py b/api/funkwhale_api/providers/youtube/tests/test_youtube.py
deleted file mode 100644
index 8a1dd1eb70a601e56082ec7dce8fe3cae32c7095..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/tests/test_youtube.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import json
-from collections import OrderedDict
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-from funkwhale_api.providers.youtube.client import client
-
-from . import data as api_data
-
-class TestAPI(TestCase):
-    maxDiff = None
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'])
-    def test_can_get_search_results_from_youtube(self, *mocks):
-        query = '8 bit adventure'
-
-        results = client.search(query)
-        self.assertEqual(results[0]['id']['videoId'], '0HxZn6CzOIo')
-        self.assertEqual(results[0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
-        self.assertEqual(results[0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'])
-    def test_can_get_search_results_from_funkwhale(self, *mocks):
-        query = '8 bit adventure'
-        url = self.reverse('api:v1:providers:youtube:search')
-        response = self.client.get(url + '?query={0}'.format(query))
-        # we should cast the youtube result to something more generic
-        expected = {
-            "id": "0HxZn6CzOIo",
-            "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-            "type": "youtube#video",
-            "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
-            "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-            "title": "AdhesiveWombat - 8 Bit Adventure",
-            "channelTitle": "AdhesiveWombat",
-            "publishedAt": "2012-08-22T18:41:03.000Z",
-            "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
-        }
-
-        self.assertEqual(
-            json.loads(response.content.decode('utf-8'))[0], expected)
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        side_effect=[
-            api_data.search['8 bit adventure'],
-            api_data.search['system of a down toxicity'],
-        ]
-    )
-    def test_can_send_multiple_queries_at_once(self, *mocks):
-        queries = OrderedDict()
-        queries['1'] = {
-            'q': '8 bit adventure',
-        }
-        queries['2'] = {
-            'q': 'system of a down toxicity',
-        }
-
-        results = client.search_multiple(queries)
-
-        self.assertEqual(results['1'][0]['id']['videoId'], '0HxZn6CzOIo')
-        self.assertEqual(results['1'][0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
-        self.assertEqual(results['1'][0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
-        self.assertEqual(results['2'][0]['id']['videoId'], 'BorYwGi2SJc')
-        self.assertEqual(results['2'][0]['snippet']['title'], 'System of a Down: Toxicity')
-        self.assertEqual(results['2'][0]['full_url'], 'https://www.youtube.com/watch?v=BorYwGi2SJc')
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'],
-    )
-    def test_can_send_multiple_queries_at_once_from_funwkhale(self, *mocks):
-        queries = OrderedDict()
-        queries['1'] = {
-            'q': '8 bit adventure',
-        }
-
-        expected = {
-            "id": "0HxZn6CzOIo",
-            "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-            "type": "youtube#video",
-            "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
-            "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-            "title": "AdhesiveWombat - 8 Bit Adventure",
-            "channelTitle": "AdhesiveWombat",
-            "publishedAt": "2012-08-22T18:41:03.000Z",
-            "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
-        }
-
-        url = self.reverse('api:v1:providers:youtube:searchs')
-        response = self.client.post(
-            url, json.dumps(queries), content_type='application/json')
-
-        self.assertEqual(
-            expected,
-            json.loads(response.content.decode('utf-8'))['1'][0])
diff --git a/api/funkwhale_api/radios/tests/__init__.py b/api/funkwhale_api/radios/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/radios/tests/test_radios.py b/api/funkwhale_api/radios/tests/test_radios.py
deleted file mode 100644
index ab27d45166a343c3bb731608db750312b934d241..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/radios/tests/test_radios.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import random
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-
-
-from funkwhale_api.radios import radios
-from funkwhale_api.radios import models
-from funkwhale_api.favorites.models import TrackFavorite
-from funkwhale_api.users.models import User
-from funkwhale_api.music.models import Artist
-from funkwhale_api.music.tests import factories
-from funkwhale_api.history.tests.factories import ListeningFactory
-
-
-class TestRadios(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_pick_track_from_choices(self):
-        choices = [1, 2, 3, 4, 5]
-
-        radio = radios.SimpleRadio()
-
-        first_pick = radio.pick(choices=choices)
-
-        self.assertIn(first_pick, choices)
-
-        previous_choices = [first_pick]
-        for remaining_choice in choices:
-            pick = radio.pick(choices=choices, previous_choices=previous_choices)
-            self.assertIn(pick, set(choices).difference(previous_choices))
-
-    def test_can_pick_by_weight(self):
-        choices_with_weight = [
-            # choice, weight
-            (1, 1),
-            (2, 2),
-            (3, 3),
-            (4, 4),
-            (5, 5),
-        ]
-
-        picks = {choice: 0 for choice, weight in choices_with_weight}
-
-        for i in range(1000):
-            radio = radios.SimpleRadio()
-            pick = radio.weighted_pick(choices=choices_with_weight)
-            picks[pick] = picks[pick] + 1
-
-        self.assertTrue(picks[5] > picks[4])
-        self.assertTrue(picks[4] > picks[3])
-        self.assertTrue(picks[3] > picks[2])
-        self.assertTrue(picks[2] > picks[1])
-
-    def test_can_get_choices_for_favorites_radio(self):
-        tracks = factories.TrackFactory.create_batch(size=100)
-
-        for i in range(20):
-            TrackFavorite.add(track=random.choice(tracks), user=self.user)
-
-        radio = radios.FavoritesRadio()
-        choices = radio.get_choices(user=self.user)
-
-        self.assertEqual(choices.count(), self.user.track_favorites.all().count())
-
-        for favorite in self.user.track_favorites.all():
-            self.assertIn(favorite.track, choices)
-
-        for i in range(20):
-            pick = radio.pick(user=self.user)
-            self.assertIn(pick, choices)
-
-    def test_can_use_radio_session_to_filter_choices(self):
-        tracks = factories.TrackFactory.create_batch(size=30)
-        radio = radios.RandomRadio()
-        session = radio.start_session(self.user)
-
-        for i in range(30):
-            p = radio.pick()
-
-        # ensure 30 differents tracks have been suggested
-        tracks_id = [session_track.track.pk for session_track in session.session_tracks.all()]
-        self.assertEqual(len(set(tracks_id)), 30)
-
-    def test_can_restore_radio_from_previous_session(self):
-        tracks = factories.TrackFactory.create_batch(size=30)
-
-        radio = radios.RandomRadio()
-        session = radio.start_session(self.user)
-
-        restarted_radio = radios.RandomRadio(session)
-        self.assertEqual(radio.session, restarted_radio.session)
-
-    def test_can_get_start_radio_from_api(self):
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'random')
-        self.assertEqual(session.user, None)
-
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'random')
-        self.assertEqual(session.user, self.user)
-
-    def test_can_start_radio_for_anonymous_user(self):
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-
-        self.assertIsNone(session.user)
-        self.assertIsNotNone(session.session_key)
-
-    def test_can_get_track_for_session_from_api(self):
-        tracks = factories.TrackFactory.create_batch(size=1)
-
-        self.client.login(username=self.user.username, password='test')
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-
-        url = reverse('api:v1:radios:tracks-list')
-        response = self.client.post(url, {'session': session.pk})
-        data = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(data['track']['id'], tracks[0].id)
-        self.assertEqual(data['position'], 1)
-
-        next_track = factories.TrackFactory()
-        response = self.client.post(url, {'session': session.pk})
-        data = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(data['track']['id'], next_track.id)
-        self.assertEqual(data['position'], 2)
-
-    def test_related_object_radio_validate_related_object(self):
-        # cannot start without related object
-        radio = radios.ArtistRadio()
-        with self.assertRaises(ValidationError):
-            radio.start_session(self.user)
-
-        # cannot start with bad related object type
-        radio = radios.ArtistRadio()
-        with self.assertRaises(ValidationError):
-            radio.start_session(self.user, related_object=self.user)
-
-    def test_can_start_artist_radio(self):
-        artist = factories.ArtistFactory()
-        wrong_tracks = factories.TrackFactory.create_batch(size=30)
-        good_tracks = factories.TrackFactory.create_batch(
-            artist=artist, size=5)
-
-        radio = radios.ArtistRadio()
-        session = radio.start_session(self.user, related_object=artist)
-        self.assertEqual(session.radio_type, 'artist')
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
-
-    def test_can_start_tag_radio(self):
-        tag = factories.TagFactory()
-        wrong_tracks = factories.TrackFactory.create_batch(size=30)
-        good_tracks = factories.TrackFactory.create_batch(size=5)
-        for track in good_tracks:
-            track.tags.add(tag)
-
-        radio = radios.TagRadio()
-        session = radio.start_session(self.user, related_object=tag)
-        self.assertEqual(session.radio_type, 'tag')
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
-
-    def test_can_start_artist_radio_from_api(self):
-        artist = factories.ArtistFactory()
-        url = reverse('api:v1:radios:sessions-list')
-
-        response = self.client.post(url, {'radio_type': 'artist', 'related_object_id': artist.id})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'artist')
-        self.assertEqual(session.related_object, artist)
-
-    def test_can_start_less_listened_radio(self):
-        history = ListeningFactory.create_batch(size=5, user=self.user)
-        wrong_tracks = [h.track for h in history]
-
-        good_tracks = factories.TrackFactory.create_batch(size=30)
-
-        radio = radios.LessListenedRadio()
-        session = radio.start_session(self.user)
-        self.assertEqual(session.related_object, self.user)
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
diff --git a/api/funkwhale_api/users/tests/factories.py b/api/funkwhale_api/users/factories.py
similarity index 82%
rename from api/funkwhale_api/users/tests/factories.py
rename to api/funkwhale_api/users/factories.py
index 351884ff4543a6625cb24dba904ff6c2b415bae3..0af155e77339177323d46f32f2ad9a7e6cf99f5c 100644
--- a/api/funkwhale_api/users/tests/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,12 +1,14 @@
 import factory
 
+from funkwhale_api.factories import registry
 from django.contrib.auth.models import Permission
 
 
+@registry.register
 class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: 'user-{0}'.format(n))
     email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
-    password = factory.PostGenerationMethodCall('set_password', 'password')
+    password = factory.PostGenerationMethodCall('set_password', 'test')
 
     class Meta:
         model = 'users.User'
@@ -28,3 +30,9 @@ class UserFactory(factory.django.DjangoModelFactory):
             ]
             # A list of permissions were passed in, use them
             self.user_permissions.add(*perms)
+
+
+@registry.register(name='users.SuperUser')
+class SuperUserFactory(UserFactory):
+    is_staff = True
+    is_superuser = True
diff --git a/api/funkwhale_api/users/tests/__init__.py b/api/funkwhale_api/users/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/users/tests/test_admin.py b/api/funkwhale_api/users/tests/test_admin.py
deleted file mode 100644
index 10b07b749dc56476687502d86cb9fd6e99d71274..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_admin.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from test_plus.test import TestCase
-
-from ..admin import MyUserCreationForm
-
-
-class TestMyUserCreationForm(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-
-    def test_clean_username_success(self):
-        # Instantiate the form with a new username
-        form = MyUserCreationForm({
-            'username': 'alamode',
-            'password1': '123456',
-            'password2': '123456',
-        })
-        # Run is_valid() to trigger the validation
-        valid = form.is_valid()
-        self.assertTrue(valid)
-
-        # Run the actual clean_username method
-        username = form.clean_username()
-        self.assertEqual('alamode', username)
-
-    def test_clean_username_false(self):
-        # Instantiate the form with the same username as self.user
-        form = MyUserCreationForm({
-            'username': self.user.username,
-            'password1': '123456',
-            'password2': '123456',
-        })
-        # Run is_valid() to trigger the validation, which is going to fail
-        # because the username is already taken
-        valid = form.is_valid()
-        self.assertFalse(valid)
-
-        # The form.errors dict should contain a single error called 'username'
-        self.assertTrue(len(form.errors) == 1)
-        self.assertTrue('username' in form.errors)
diff --git a/api/funkwhale_api/users/tests/test_models.py b/api/funkwhale_api/users/tests/test_models.py
deleted file mode 100644
index fbc7eb5f9a944c82be63d3beb9fe4a2ef958afff..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_models.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from test_plus.test import TestCase
-
-
-class TestUser(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-
-    def test__str__(self):
-        self.assertEqual(
-            self.user.__str__(),
-            "testuser"  # This is the default username for self.make_user()
-        )
diff --git a/api/funkwhale_api/users/tests/test_views.py b/api/funkwhale_api/users/tests/test_views.py
deleted file mode 100644
index 52826cfa4b53da83487d86bea8ca0899d5e2e97c..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_views.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import json
-
-from django.test import RequestFactory
-
-from test_plus.test import TestCase
-from funkwhale_api.users.models import User
-
-from . factories import UserFactory
-
-
-class UserTestCase(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-        self.factory = RequestFactory()
-
-    def test_can_create_user_via_api(self):
-        url = self.reverse('rest_register')
-        data = {
-            'username': 'test1',
-            'email': 'test1@test.com',
-            'password1': 'testtest',
-            'password2': 'testtest',
-        }
-        with self.settings(REGISTRATION_MODE="public"):
-            response = self.client.post(url, data)
-        self.assertEqual(response.status_code, 201)
-
-        u = User.objects.get(email='test1@test.com')
-        self.assertEqual(u.username, 'test1')
-
-    def test_can_disable_registration_view(self):
-        url = self.reverse('rest_register')
-        data = {
-            'username': 'test1',
-            'email': 'test1@test.com',
-            'password1': 'testtest',
-            'password2': 'testtest',
-        }
-        with self.settings(REGISTRATION_MODE="disabled"):
-            response = self.client.post(url, data)
-        self.assertEqual(response.status_code, 403)
-
-    def test_can_fetch_data_from_api(self):
-        url = self.reverse('api:v1:users:users-me')
-        response = self.client.get(url)
-        # login required
-        self.assertEqual(response.status_code, 401)
-
-        user = UserFactory(
-            is_staff=True,
-            perms=[
-                'music.add_importbatch',
-                'dynamic_preferences.change_globalpreferencemodel',
-            ]
-        )
-        self.assertTrue(user.has_perm('music.add_importbatch'))
-        self.login(user)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-        payload = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(payload['username'], user.username)
-        self.assertEqual(payload['is_staff'], user.is_staff)
-        self.assertEqual(payload['is_superuser'], user.is_superuser)
-        self.assertEqual(payload['email'], user.email)
-        self.assertEqual(payload['name'], user.name)
-        self.assertEqual(
-            payload['permissions']['import.launch']['status'], True)
-        self.assertEqual(
-            payload['permissions']['settings.change']['status'], True)
diff --git a/api/funkwhale_api/utils/tests.py b/api/funkwhale_api/utils/tests.py
deleted file mode 100644
index 2605d3b4c9ff0c5b78393ca515afa9efde6b5308..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/utils/tests.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import tempfile
-import shutil
-
-
-class TMPDirTestCaseMixin(object):
-    def setUp(self):
-        super().tearDown()
-        self.download_dir = tempfile.mkdtemp()
-
-    def tearDown(self):
-        super().tearDown()
-        shutil.rmtree(self.download_dir)
diff --git a/api/requirements/local.txt b/api/requirements/local.txt
index d8a1561e0a31705150d00bdf48ffeaf3431ee4da..b466b20fdfc20c5815fb2f7be8daedccc3f360ac 100644
--- a/api/requirements/local.txt
+++ b/api/requirements/local.txt
@@ -5,7 +5,6 @@ django_coverage_plugin>=1.5,<1.6
 Sphinx>=1.6,<1.7
 django-extensions>=1.9,<1.10
 Werkzeug>=0.13,<0.14
-django-test-plus>=1.0.20
 factory_boy>=2.8.1
 
 # django-debug-toolbar that works with Django 1.5+
diff --git a/api/requirements/test.txt b/api/requirements/test.txt
index bde5a2df97b8b21f2a1d7dfe20ad4e18d38eea1c..c12b44827ebbf9440e180de7d686b8f2e190118c 100644
--- a/api/requirements/test.txt
+++ b/api/requirements/test.txt
@@ -2,7 +2,10 @@
 
 flake8
 pytest
-pytest-django
+# pytest-django until a new release containing django_assert_num_queries
+# is deployed to pypi
+git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb368992a0834692378
+
 pytest-mock
 pytest-sugar
 pytest-xdist
diff --git a/api/funkwhale_api/downloader/tests/__init__.py b/api/tests/__init__.py
similarity index 100%
rename from api/funkwhale_api/downloader/tests/__init__.py
rename to api/tests/__init__.py
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..37f3dae3afd72f88db2d7f1c2a9c5a40a6f43cec
--- /dev/null
+++ b/api/tests/conftest.py
@@ -0,0 +1,42 @@
+import tempfile
+import shutil
+import pytest
+
+
+@pytest.fixture(scope="session", autouse=True)
+def factories_autodiscover():
+    from django.apps import apps
+    from funkwhale_api import factories
+    app_names = [app.name for app in apps.app_configs.values()]
+    factories.registry.autodiscover(app_names)
+
+
+@pytest.fixture
+def factories(db):
+    from funkwhale_api import factories
+    yield factories.registry
+
+
+@pytest.fixture
+def tmpdir():
+    d = tempfile.mkdtemp()
+    yield d
+    shutil.rmtree(d)
+
+
+@pytest.fixture
+def logged_in_client(db, factories, client):
+    user = factories['users.User']()
+    assert client.login(username=user.username, password='test')
+    setattr(client, 'user', user)
+    yield client
+    delattr(client, 'user')
+
+
+@pytest.fixture
+def superuser_client(db, factories, client):
+    user = factories['users.SuperUser']()
+    assert client.login(username=user.username, password='test')
+    setattr(client, 'user', user)
+    yield client
+    delattr(client, 'user')
diff --git a/api/funkwhale_api/providers/youtube/tests/data.py b/api/tests/data/youtube.py
similarity index 100%
rename from api/funkwhale_api/providers/youtube/tests/data.py
rename to api/tests/data/youtube.py
diff --git a/api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg b/api/tests/files/dummy_file.ogg
similarity index 100%
rename from api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg
rename to api/tests/files/dummy_file.ogg
diff --git a/api/funkwhale_api/favorites/tests/__init__.py b/api/tests/music/__init__.py
similarity index 100%
rename from api/funkwhale_api/favorites/tests/__init__.py
rename to api/tests/music/__init__.py
diff --git a/api/funkwhale_api/music/tests/cover.py b/api/tests/music/cover.py
similarity index 100%
rename from api/funkwhale_api/music/tests/cover.py
rename to api/tests/music/cover.py
diff --git a/api/funkwhale_api/music/tests/data.py b/api/tests/music/data.py
similarity index 100%
rename from api/funkwhale_api/music/tests/data.py
rename to api/tests/music/data.py
diff --git a/api/funkwhale_api/music/tests/mocking/lyricswiki.py b/api/tests/music/mocking/lyricswiki.py
similarity index 100%
rename from api/funkwhale_api/music/tests/mocking/lyricswiki.py
rename to api/tests/music/mocking/lyricswiki.py
diff --git a/api/funkwhale_api/music/tests/test.mp3 b/api/tests/music/test.mp3
similarity index 100%
rename from api/funkwhale_api/music/tests/test.mp3
rename to api/tests/music/test.mp3
diff --git a/api/funkwhale_api/music/tests/test.ogg b/api/tests/music/test.ogg
similarity index 100%
rename from api/funkwhale_api/music/tests/test.ogg
rename to api/tests/music/test.ogg
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..e29aaf107c922f117e63c2f67c5ca613bce6733b
--- /dev/null
+++ b/api/tests/music/test_api.py
@@ -0,0 +1,253 @@
+import json
+import pytest
+from django.urls import reverse
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+
+from . import data as api_data
+
+
+def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['8bitadventures'])
+    mocker.patch(
+        'funkwhale_api.music.models.TrackFile.download_file',
+        return_value=None)
+    mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    video_id = 'tPEE9ZwTmy0'
+    url = reverse('api:v1:submit-single')
+    response = superuser_client.post(
+        url,
+        {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+         'mbid': mbid})
+    track = models.Track.objects.get(mbid=mbid)
+    assert track.artist.name == 'Adhesive Wombat'
+    assert track.album.title == 'Marsupial Madness'
+
+
+def test_import_creates_an_import_with_correct_data(superuser_client, settings):
+    mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    video_id = 'tPEE9ZwTmy0'
+    url = reverse('api:v1:submit-single')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url,
+        {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+         'mbid': mbid})
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 1
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+    job = batch.jobs.first()
+    assert str(job.mbid) == mbid
+    assert job.status == 'pending'
+    assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
+
+
+def test_can_import_whole_album(mocker, superuser_client, settings):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['soad'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.images.get_front',
+        return_value=b'')
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get_with_includes']['hypnotize'])
+    payload = {
+        'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+        'tracks': [
+            {
+            'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=1111111111',
+            },
+            {
+            'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=2222222222',
+            },
+            {
+            'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=3333333333',
+            },
+        ]
+    }
+    url = reverse('api:v1:submit-album')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url, json.dumps(payload), content_type="application/json")
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 3
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+
+    album = models.Album.objects.latest('id')
+    assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
+    medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+    assert int(medium_data['track-count']) == album.tracks.all().count()
+
+    for track in medium_data['track-list']:
+        instance = models.Track.objects.get(mbid=track['recording']['id'])
+        assert instance.title == track['recording']['title']
+        assert instance.position == int(track['position'])
+        assert instance.title == track['recording']['title']
+
+    for row in payload['tracks']:
+        job = models.ImportJob.objects.get(mbid=row['mbid'])
+        assert str(job.mbid) == row['mbid']
+        assert job.status == 'pending'
+        assert job.source == row['source']
+
+
+def test_can_import_whole_artist(mocker, superuser_client, settings):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['soad'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.images.get_front',
+        return_value=b'')
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get_with_includes']['hypnotize'])
+    payload = {
+        'artistId': 'mbid',
+        'albums': [
+            {
+                'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+                'tracks': [
+                    {
+                    'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=1111111111',
+                    },
+                    {
+                    'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=2222222222',
+                    },
+                    {
+                    'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=3333333333',
+                    },
+                ]
+            }
+        ]
+    }
+    url = reverse('api:v1:submit-artist')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url, json.dumps(payload), content_type="application/json")
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 3
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+
+    album = models.Album.objects.latest('id')
+    assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
+    medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+    assert int(medium_data['track-count']) == album.tracks.all().count()
+
+    for track in medium_data['track-list']:
+        instance = models.Track.objects.get(mbid=track['recording']['id'])
+        assert instance.title == track['recording']['title']
+        assert instance.position == int(track['position'])
+        assert instance.title == track['recording']['title']
+
+    for row in payload['albums'][0]['tracks']:
+        job = models.ImportJob.objects.get(mbid=row['mbid'])
+        assert str(job.mbid) == row['mbid']
+        assert job.status == 'pending'
+        assert job.source == row['source']
+
+
+def test_user_can_query_api_for_his_own_batches(client, factories):
+    user1 = factories['users.SuperUser']()
+    user2 = factories['users.SuperUser']()
+
+    job = factories['music.ImportJob'](batch__submitted_by=user1)
+    url = reverse('api:v1:import-batches-list')
+
+    client.login(username=user2.username, password='test')
+    response2 = client.get(url)
+    results = json.loads(response2.content.decode('utf-8'))
+    assert results['count'] == 0
+    client.logout()
+
+    client.login(username=user1.username, password='test')
+    response1 = client.get(url)
+    results = json.loads(response1.content.decode('utf-8'))
+    assert results['count'] == 1
+    assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
+
+
+def test_can_search_artist(factories, client):
+    artist1 = factories['music.Artist']()
+    artist2 = factories['music.Artist']()
+    expected = [serializers.ArtistSerializerNested(artist1).data]
+    url = reverse('api:v1:artists-search')
+    response = client.get(url, {'query': artist1.name})
+    assert json.loads(response.content.decode('utf-8')) == expected
+
+
+def test_can_search_artist_by_name_start(factories, client):
+    artist1 = factories['music.Artist'](name='alpha')
+    artist2 = factories['music.Artist'](name='beta')
+    expected = {
+        'next': None,
+        'previous': None,
+        'count': 1,
+        'results': [serializers.ArtistSerializerNested(artist1).data]
+    }
+    url = reverse('api:v1:artists-list')
+    response = client.get(url, {'name__startswith': 'a'})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_tracks(factories, client):
+    track1 = factories['music.Track'](title="test track 1")
+    track2 = factories['music.Track']()
+    query = 'test track 1'
+    expected = [serializers.TrackSerializerNested(track1).data]
+    url = reverse('api:v1:tracks-search')
+    response = client.get(url, {'query': query})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+@pytest.mark.parametrize('route,method', [
+    ('api:v1:tags-list', 'get'),
+    ('api:v1:tracks-list', 'get'),
+    ('api:v1:artists-list', 'get'),
+    ('api:v1:albums-list', 'get'),
+])
+def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client):
+    url = reverse(route)
+    settings.API_AUTHENTICATION_REQUIRED = True
+    response = getattr(client, method)(url)
+    assert response.status_code == 401
+
+
+def test_track_file_url_is_restricted_to_authenticated_users(client, factories, settings):
+    settings.API_AUTHENTICATION_REQUIRED = True
+    f = factories['music.TrackFile']()
+    assert f.audio_file is not None
+    url = f.path
+    response = client.get(url)
+    assert response.status_code == 401
+
+    user = factories['users.SuperUser']()
+    client.login(username=user.username, password='test')
+    response = client.get(url)
+
+    assert response.status_code == 200
+    assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url)
diff --git a/api/tests/music/test_lyrics.py b/api/tests/music/test_lyrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..3670a2e5c1bfb1e24b673c86613fe16073f0d201
--- /dev/null
+++ b/api/tests/music/test_lyrics.py
@@ -0,0 +1,73 @@
+import json
+from django.urls import reverse
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+from funkwhale_api.music import lyrics as lyrics_utils
+
+from .mocking import lyricswiki
+from . import data as api_data
+
+
+
+def test_works_import_lyrics_if_any(mocker, factories):
+    mocker.patch(
+        'funkwhale_api.music.lyrics._get_html',
+        return_value=lyricswiki.content)
+    lyrics = factories['music.Lyrics'](
+        url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
+
+    lyrics.fetch_content()
+    self.assertIn(
+        'Grab a brush and put on a little makeup',
+        lyrics.content,
+    )
+
+
+def test_clean_content():
+    c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
+    d = lyrics_utils.extract_content(c)
+    d = lyrics_utils.clean_content(d)
+
+    expected = """Hello
+Is it me you're looking for?
+"""
+    assert d == expected
+
+
+def test_markdown_rendering(factories):
+    content = """Hello
+Is it me you're looking for?"""
+
+    l = factories['music.Lyrics'](content=content)
+
+    expected = "<p>Hello<br />\nIs it me you're looking for?</p>"
+    assert expected == l.content_rendered
+
+
+def test_works_import_lyrics_if_any(mocker, factories, logged_in_client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.music.lyrics._get_html',
+        return_value=lyricswiki.content)
+    track = factories['music.Track'](
+        work=None,
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+
+    url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk})
+    response = logged_in_client.get(url)
+
+    assert response.status_code == 200
+
+    track.refresh_from_db()
+    lyrics = models.Lyrics.objects.latest('id')
+    work = models.Work.objects.latest('id')
+
+    assert track.work == work
+    assert lyrics.work == work
diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..5df2dbcf117965cf41563a6d028cbe804c3e0fd8
--- /dev/null
+++ b/api/tests/music/test_metadata.py
@@ -0,0 +1,41 @@
+import datetime
+import os
+import pytest
+
+from funkwhale_api.music import metadata
+
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+@pytest.mark.parametrize('field,value', [
+    ('title', 'Peer Gynt Suite no. 1, op. 46: I. Morning'),
+    ('artist', 'Edvard Grieg'),
+    ('album', 'Peer Gynt Suite no. 1, op. 46'),
+    ('date', datetime.date(2012, 8, 15)),
+    ('track_number', 1),
+    ('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'),
+    ('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'),
+    ('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'),
+])
+def test_can_get_metadata_from_ogg_file(field, value):
+    path = os.path.join(DATA_DIR, 'test.ogg')
+    data = metadata.Metadata(path)
+
+    assert data.get(field) == value
+
+
+@pytest.mark.parametrize('field,value', [
+    ('title', 'Bend'),
+    ('artist', 'Binärpilot'),
+    ('album', 'You Can\'t Stop Da Funk'),
+    ('date', datetime.date(2006, 2, 7)),
+    ('track_number', 1),
+    ('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'),
+    ('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'),
+    ('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'),
+])
+def test_can_get_metadata_from_id3_mp3_file(field, value):
+    path = os.path.join(DATA_DIR, 'test.mp3')
+    data = metadata.Metadata(path)
+
+    assert data.get(field) == value
diff --git a/api/funkwhale_api/music/tests/test_models.py b/api/tests/music/test_models.py
similarity index 78%
rename from api/funkwhale_api/music/tests/test_models.py
rename to api/tests/music/test_models.py
index 4b43e46382ad28d4bbf382fef854c39b8cae5d48..2ec192517bbf7d060baf37bbcbb5670f1bb2635a 100644
--- a/api/funkwhale_api/music/tests/test_models.py
+++ b/api/tests/music/test_models.py
@@ -2,16 +2,14 @@ import pytest
 
 from funkwhale_api.music import models
 from funkwhale_api.music import importers
-from . import factories
 
 
-def test_can_store_release_group_id_on_album(db):
-    album = factories.AlbumFactory()
+def test_can_store_release_group_id_on_album(factories):
+    album = factories['music.Album']()
     assert album.release_group_id is not None
 
 
-def test_import_album_stores_release_group(db):
-
+def test_import_album_stores_release_group(factories):
     album_data = {
         "artist-credit": [
             {
@@ -31,7 +29,7 @@ def test_import_album_stores_release_group(db):
         "title": "Marsupial Madness",
         'release-group': {'id': '447b4979-2178-405c-bfe6-46bf0b09e6c7'}
     }
-    artist = factories.ArtistFactory(
+    artist = factories['music.Artist'](
         mbid=album_data['artist-credit'][0]['artist']['id']
     )
     cleaned_data = models.Album.clean_musicbrainz_data(album_data)
@@ -41,9 +39,9 @@ def test_import_album_stores_release_group(db):
     assert album.artist == artist
 
 
-def test_import_job_is_bound_to_track_file(db, mocker):
-    track = factories.TrackFactory()
-    job = factories.ImportJobFactory(mbid=track.mbid)
+def test_import_job_is_bound_to_track_file(factories, mocker):
+    track = factories['music.Track']()
+    job = factories['music.ImportJob'](mbid=track.mbid)
 
     mocker.patch('funkwhale_api.music.models.TrackFile.download_file')
     job.run()
diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py
new file mode 100644
index 0000000000000000000000000000000000000000..076ad2bd05cb714c6436592666dffeeae61396ba
--- /dev/null
+++ b/api/tests/music/test_music.py
@@ -0,0 +1,138 @@
+import pytest
+from funkwhale_api.music import models
+import datetime
+
+from . import data as api_data
+from .cover import binary_data
+
+
+def test_can_create_artist_from_api(mocker, db):
+    mocker.patch(
+        'musicbrainzngs.search_artists',
+        return_value=api_data.artists['search']['adhesive_wombat'])
+    artist = models.Artist.create_from_api(query="Adhesive wombat")
+    data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
+
+    assert int(data['ext:score']), 100
+    assert data['id'], '62c3befb-6366-4585-b256-809472333801'
+    assert artist.mbid, data['id']
+    assert artist.name, 'Adhesive Wombat'
+
+
+def test_can_create_album_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.search',
+        return_value=api_data.albums['search']['hypnotize'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['soad'])
+    album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
+    data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
+
+    assert album.mbid, data['id']
+    assert album.title, 'Hypnotize'
+    with pytest.raises(ValueError):
+        assert album.cover.path is not None
+    assert album.release_date, datetime.date(2005, 1, 1)
+    assert album.artist.name, 'System of a Down'
+    assert album.artist.mbid, data['artist-credit'][0]['artist']['id']
+
+
+def test_can_create_track_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.tracks['search']['8bitadventures'])
+    track = models.Track.create_from_api(query="8-bit adventure")
+    data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
+    assert int(data['ext:score']) == 100
+    assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    assert track.mbid == data['id']
+    assert track.artist.pk is not None
+    assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801'
+    assert track.artist.name == 'Adhesive Wombat'
+    assert str(track.album.mbid) == 'a50d2a81-2a50-484d-9cb4-b9f6833f583e'
+    assert track.album.title == 'Marsupial Madness'
+
+
+def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['8bitadventures'])
+    track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
+    expected_tags = ['techno', 'good-music']
+    track_tags = [tag.slug for tag in track.tags.all()]
+    for tag in expected_tags:
+        assert tag in track_tags
+
+
+def test_can_get_or_create_track_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.tracks['search']['8bitadventures'])
+    track = models.Track.create_from_api(query="8-bit adventure")
+    data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
+    assert int(data['ext:score']) == 100
+    assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    assert track.mbid == data['id']
+    assert track.artist.pk is not None
+    assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801'
+    assert track.artist.name == 'Adhesive Wombat'
+
+    track2, created = models.Track.get_or_create_from_api(mbid=data['id'])
+    assert not created
+    assert track == track2
+
+
+def test_album_tags_deduced_from_tracks_tags(factories, django_assert_num_queries):
+    tag = factories['taggit.Tag']()
+    album = factories['music.Album']()
+    tracks = factories['music.Track'].create_batch(
+        5, album=album, tags=[tag])
+
+    album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk)
+
+    with django_assert_num_queries(0):
+        assert tag in album.tags
+
+
+def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_queries):
+    tag = factories['taggit.Tag']()
+    album = factories['music.Album']()
+    artist = album.artist
+    tracks = factories['music.Track'].create_batch(
+        5, album=album, tags=[tag])
+
+    artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk)
+
+    with django_assert_num_queries(0):
+        assert tag in artist.tags
+
+
+def test_can_download_image_file_for_album(mocker, factories):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.images.get_front',
+        return_value=binary_data)
+    # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
+    album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
+    album.get_image()
+    album.save()
+
+    assert album.cover.file.read() == binary_data
diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b72768ad07bf05adae848eef73784866de83c7d
--- /dev/null
+++ b/api/tests/music/test_works.py
@@ -0,0 +1,65 @@
+import json
+from django.urls import reverse
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+
+from . import data as api_data
+
+
+def test_can_import_work(factories, mocker):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    recording = factories['music.Track'](
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+    work = models.Work.create_from_api(id=mbid)
+
+    assert work.title == 'Chop Suey!'
+    assert work.nature == 'song'
+    assert work.language == 'eng'
+    assert work.mbid == mbid
+
+    # a imported work should also be linked to corresponding recordings
+
+    recording.refresh_from_db()
+    assert recording.work == work
+
+
+def test_can_get_work_from_recording(factories, mocker):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['chop_suey'])
+    recording = factories['music.Track'](
+        work=None,
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+
+    assert recording.work == None
+
+    work = recording.get_work()
+
+    assert work.title == 'Chop Suey!'
+    assert work.nature == 'song'
+    assert work.language == 'eng'
+    assert work.mbid == mbid
+
+    recording.refresh_from_db()
+    assert recording.work == work
+
+
+def test_works_import_lyrics_if_any(db, mocker):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+    work = models.Work.create_from_api(id=mbid)
+
+    lyrics = models.Lyrics.objects.latest('id')
+    assert lyrics.work == work
+    assert lyrics.url == 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!'
diff --git a/api/funkwhale_api/history/tests/__init__.py b/api/tests/musicbrainz/__init__.py
similarity index 100%
rename from api/funkwhale_api/history/tests/__init__.py
rename to api/tests/musicbrainz/__init__.py
diff --git a/api/funkwhale_api/musicbrainz/tests/data.py b/api/tests/musicbrainz/data.py
similarity index 100%
rename from api/funkwhale_api/musicbrainz/tests/data.py
rename to api/tests/musicbrainz/data.py
diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbade340060dae4cbc4f2a64d296eb86e6c59e79
--- /dev/null
+++ b/api/tests/musicbrainz/test_api.py
@@ -0,0 +1,90 @@
+import json
+from django.urls import reverse
+
+from funkwhale_api.musicbrainz import api
+from . import data as api_data
+
+
+
+def test_can_search_recording_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.recordings['search']['brontide matador'])
+    query = 'brontide matador'
+    url = reverse('api:v1:providers:musicbrainz:search-recordings')
+    expected = api_data.recordings['search']['brontide matador']
+    response = client.get(url, data={'query': query})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_release_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.search',
+        return_value=api_data.releases['search']['brontide matador'])
+    query = 'brontide matador'
+    url = reverse('api:v1:providers:musicbrainz:search-releases')
+    expected = api_data.releases['search']['brontide matador']
+    response = client.get(url, data={'query': query})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_artists_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.search',
+        return_value=api_data.artists['search']['lost fingers'])
+    query = 'lost fingers'
+    url = reverse('api:v1:providers:musicbrainz:search-artists')
+    expected = api_data.artists['search']['lost fingers']
+    response = client.get(url, data={'query': query})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_get_artist_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['lost fingers'])
+    uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
+    url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
+        'uuid': uuid,
+    })
+    response = client.get(url)
+    expected = api_data.artists['get']['lost fingers']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.release_groups.browse',
+        return_value=api_data.release_groups['browse']['lost fingers'])
+    uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
+    url = reverse(
+        'api:v1:providers:musicbrainz:release-group-browse',
+        kwargs={
+            'artist_uuid': uuid,
+        }
+    )
+    response = client.get(url)
+    expected = api_data.release_groups['browse']['lost fingers']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.browse',
+        return_value=api_data.releases['browse']['Lost in the 80s'])
+    uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
+    url = reverse(
+        'api:v1:providers:musicbrainz:release-browse',
+        kwargs={
+            'release_group_uuid': uuid,
+        }
+    )
+    response = client.get(url)
+    expected = api_data.releases['browse']['Lost in the 80s']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
diff --git a/api/tests/musicbrainz/test_cache.py b/api/tests/musicbrainz/test_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe0d5677302b08bb3f75c45a31f23f3238d034ff
--- /dev/null
+++ b/api/tests/musicbrainz/test_cache.py
@@ -0,0 +1,13 @@
+from funkwhale_api.musicbrainz import client
+
+
+def test_can_search_recording_in_musicbrainz_api(mocker):
+    r = {'hello': 'world'}
+    m = mocker.patch(
+        'funkwhale_api.musicbrainz.client._api.search_artists',
+        return_value=r)
+    assert client.api.artists.search('test') == r
+    # now call from cache
+    assert client.api.artists.search('test') == r
+    assert client.api.artists.search('test') == r
+    assert m.call_count == 1
diff --git a/api/tests/test_disk_import.py b/api/tests/test_disk_import.py
new file mode 100644
index 0000000000000000000000000000000000000000..9aaf399750e3c756c9087d58e6e61931834ab19a
--- /dev/null
+++ b/api/tests/test_disk_import.py
@@ -0,0 +1,39 @@
+import os
+import datetime
+
+from funkwhale_api.providers.audiofile import tasks
+
+DATA_DIR = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)),
+    'files'
+)
+
+
+def test_can_import_single_audio_file(db, mocker):
+    metadata = {
+        'artist': ['Test artist'],
+        'album': ['Test album'],
+        'title': ['Test track'],
+        'TRACKNUMBER': ['4'],
+        'date': ['2012-08-15'],
+        'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
+        'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
+        'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
+    }
+
+    m1 = mocker.patch('mutagen.File', return_value=metadata)
+    m2 = mocker.patch(
+        'funkwhale_api.music.metadata.Metadata.get_file_type',
+        return_value='OggVorbis',
+    )
+    track_file = tasks.from_path(os.path.join(DATA_DIR, 'dummy_file.ogg'))
+    track = track_file.track
+
+    assert track.title == metadata['title'][0]
+    assert track.mbid == metadata['musicbrainz_trackid'][0]
+    assert track.position == 4
+    assert track.album.title == metadata['album'][0]
+    assert track.album.mbid == metadata['musicbrainz_albumid'][0]
+    assert track.album.release_date == datetime.date(2012, 8, 15)
+    assert track.artist.name == metadata['artist'][0]
+    assert track.artist.mbid == metadata['musicbrainz_artistid'][0]
diff --git a/api/tests/test_downloader.py b/api/tests/test_downloader.py
new file mode 100644
index 0000000000000000000000000000000000000000..ede7bb16cc9571607f0b3c73114010f02e411349
--- /dev/null
+++ b/api/tests/test_downloader.py
@@ -0,0 +1,11 @@
+import os
+
+from funkwhale_api import downloader
+
+
+def test_can_download_audio_from_youtube_url_to_vorbis(tmpdir):
+    data = downloader.download(
+        'https://www.youtube.com/watch?v=tPEE9ZwTmy0',
+        target_directory=tmpdir)
+    assert data['audio_file_path'] == os.path.join(tmpdir, 'tPEE9ZwTmy0.ogg')
+    assert os.path.exists(data['audio_file_path'])
diff --git a/api/tests/test_favorites.py b/api/tests/test_favorites.py
new file mode 100644
index 0000000000000000000000000000000000000000..418166d8e0c11aa133e87a44a55f7b1713a3ad2f
--- /dev/null
+++ b/api/tests/test_favorites.py
@@ -0,0 +1,92 @@
+import json
+import pytest
+from django.urls import reverse
+
+from funkwhale_api.music.models import Track, Artist
+from funkwhale_api.favorites.models import TrackFavorite
+
+
+
+def test_user_can_add_favorite(factories):
+    track = factories['music.Track']()
+    user = factories['users.User']()
+    f = TrackFavorite.add(track, user)
+
+    assert f.track == track
+    assert f.user == user
+
+
+def test_user_can_get_his_favorites(factories, logged_in_client, client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+    url = reverse('api:v1:favorites:tracks-list')
+    response = logged_in_client.get(url)
+
+    expected = [
+        {
+            'track': favorite.track.pk,
+            'id': favorite.id,
+            'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+        }
+    ]
+    parsed_json = json.loads(response.content.decode('utf-8'))
+
+    assert expected == parsed_json['results']
+
+
+def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
+    track = factories['music.Track']()
+    url = reverse('api:v1:favorites:tracks-list')
+    response = logged_in_client.post(url, {'track': track.pk})
+
+    favorite = TrackFavorite.objects.latest('id')
+    expected = {
+        'track': track.pk,
+        'id': favorite.id,
+        'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+    }
+    parsed_json = json.loads(response.content.decode('utf-8'))
+
+    assert expected == parsed_json
+    assert favorite.track == track
+    assert favorite.user == logged_in_client.user
+
+
+def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+    url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
+    response = client.delete(url, {'track': favorite.track.pk})
+    assert response.status_code == 204
+    assert TrackFavorite.objects.count() == 0
+
+def test_user_can_remove_favorite_via_api_using_track_id(factories, logged_in_client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+
+    url = reverse('api:v1:favorites:tracks-remove')
+    response = logged_in_client.delete(
+        url, json.dumps({'track': favorite.track.pk}),
+        content_type='application/json'
+    )
+
+    assert response.status_code == 204
+    assert TrackFavorite.objects.count() == 0
+
+
+@pytest.mark.parametrize('url,method', [
+    ('api:v1:favorites:tracks-list', 'get'),
+])
+def test_url_require_auth(url, method, db, settings, client):
+    settings.API_AUTHENTICATION_REQUIRED = True
+    url = reverse(url)
+    response = getattr(client, method)(url)
+    assert response.status_code == 401
+
+
+def test_can_filter_tracks_by_favorites(factories, logged_in_client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+
+    url = reverse('api:v1:tracks-list')
+    response = logged_in_client.get(url, data={'favorites': True})
+
+    parsed_json = json.loads(response.content.decode('utf-8'))
+    assert parsed_json['count'] == 1
+    assert parsed_json['results'][0]['id'] == favorite.track.id
diff --git a/api/tests/test_history.py b/api/tests/test_history.py
new file mode 100644
index 0000000000000000000000000000000000000000..113e5ff6405f60665305f04b379997e8b9343d9c
--- /dev/null
+++ b/api/tests/test_history.py
@@ -0,0 +1,42 @@
+import random
+import json
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from funkwhale_api.history import models
+
+
+def test_can_create_listening(factories):
+    track = factories['music.Track']()
+    user = factories['users.User']()
+    now = timezone.now()
+    l = models.Listening.objects.create(user=user, track=track)
+
+
+def test_anonymous_user_can_create_listening_via_api(client, factories, settings):
+    settings.API_AUTHENTICATION_REQUIRED = False
+    track = factories['music.Track']()
+    url = reverse('api:v1:history:listenings-list')
+    response = client.post(url, {
+        'track': track.pk,
+    })
+
+    listening = models.Listening.objects.latest('id')
+
+    assert listening.track == track
+    assert listening.session_key == client.session.session_key
+
+
+def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
+    track = factories['music.Track']()
+
+    url = reverse('api:v1:history:listenings-list')
+    response = logged_in_client.post(url, {
+        'track': track.pk,
+    })
+
+    listening = models.Listening.objects.latest('id')
+
+    assert listening.track == track
+    assert listening.user == logged_in_client.user
diff --git a/api/tests/test_jwt_querystring.py b/api/tests/test_jwt_querystring.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd07e1dc3212476f01dd7c60f341e8718c60351c
--- /dev/null
+++ b/api/tests/test_jwt_querystring.py
@@ -0,0 +1,21 @@
+from django.urls import reverse
+from rest_framework_jwt.settings import api_settings
+
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+
+
+def test_can_authenticate_using_token_param_in_url(factories, settings, client):
+    user = factories['users.User']()
+    settings.API_AUTHENTICATION_REQUIRED = True
+    url = reverse('api:v1:tracks-list')
+    response = client.get(url)
+
+    assert response.status_code == 401
+
+    payload = jwt_payload_handler(user)
+    token = jwt_encode_handler(payload)
+    response = client.get(url, data={
+        'jwt': token
+    })
+    assert response.status_code == 200
diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py
new file mode 100644
index 0000000000000000000000000000000000000000..f496a64cb5d93a14aacc8bb73ddc2a58e3c7b50e
--- /dev/null
+++ b/api/tests/test_playlists.py
@@ -0,0 +1,54 @@
+import json
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from funkwhale_api.playlists import models
+from funkwhale_api.playlists.serializers import PlaylistSerializer
+
+
+
+def test_can_create_playlist(factories):
+    tracks = factories['music.Track'].create_batch(5)
+    playlist = factories['playlists.Playlist']()
+
+    previous = None
+    for track in tracks:
+        previous = playlist.add_track(track, previous=previous)
+
+    playlist_tracks = list(playlist.playlist_tracks.all())
+
+    previous = None
+    for idx, track in enumerate(tracks):
+        plt = playlist_tracks[idx]
+        assert plt.position == idx
+        assert plt.track == track
+        if previous:
+            assert playlist_tracks[idx + 1] == previous
+        assert plt.playlist == playlist
+
+
+def test_can_create_playlist_via_api(logged_in_client):
+    url = reverse('api:v1:playlists-list')
+    data = {
+        'name': 'test',
+    }
+
+    response = logged_in_client.post(url, data)
+
+    playlist = logged_in_client.user.playlists.latest('id')
+    assert playlist.name == 'test'
+
+
+def test_can_add_playlist_track_via_api(factories, logged_in_client):
+    tracks = factories['music.Track'].create_batch(5)
+    playlist = factories['playlists.Playlist'](user=logged_in_client.user)
+    url = reverse('api:v1:playlist-tracks-list')
+    data = {
+        'playlist': playlist.pk,
+        'track': tracks[0].pk
+    }
+
+    response = logged_in_client.post(url, data)
+    plts = logged_in_client.user.playlists.latest('id').playlist_tracks.all()
+    assert plts.first().track == tracks[0]
diff --git a/api/tests/test_radios.py b/api/tests/test_radios.py
new file mode 100644
index 0000000000000000000000000000000000000000..d67611123ce0febb2623345d4fe388304b86f7bf
--- /dev/null
+++ b/api/tests/test_radios.py
@@ -0,0 +1,195 @@
+import json
+import random
+import pytest
+
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+
+
+from funkwhale_api.radios import radios
+from funkwhale_api.radios import models
+from funkwhale_api.favorites.models import TrackFavorite
+
+
+def test_can_pick_track_from_choices():
+    choices = [1, 2, 3, 4, 5]
+
+    radio = radios.SimpleRadio()
+
+    first_pick = radio.pick(choices=choices)
+
+    assert first_pick in choices
+
+    previous_choices = [first_pick]
+    for remaining_choice in choices:
+        pick = radio.pick(choices=choices, previous_choices=previous_choices)
+        assert pick in set(choices).difference(previous_choices)
+
+
+def test_can_pick_by_weight():
+    choices_with_weight = [
+        # choice, weight
+        (1, 1),
+        (2, 2),
+        (3, 3),
+        (4, 4),
+        (5, 5),
+    ]
+
+    picks = {choice: 0 for choice, weight in choices_with_weight}
+
+    for i in range(1000):
+        radio = radios.SimpleRadio()
+        pick = radio.weighted_pick(choices=choices_with_weight)
+        picks[pick] = picks[pick] + 1
+
+    assert picks[5] > picks[4]
+    assert picks[4] > picks[3]
+    assert picks[3] > picks[2]
+    assert picks[2] > picks[1]
+
+
+def test_can_get_choices_for_favorites_radio(factories):
+    tracks = factories['music.Track'].create_batch(100)
+    user = factories['users.User']()
+    for i in range(20):
+        TrackFavorite.add(track=random.choice(tracks), user=user)
+
+    radio = radios.FavoritesRadio()
+    choices = radio.get_choices(user=user)
+
+    assert choices.count() == user.track_favorites.all().count()
+
+    for favorite in user.track_favorites.all():
+        assert favorite.track in choices
+
+    for i in range(20):
+        pick = radio.pick(user=user)
+        assert pick in choices
+
+
+def test_can_use_radio_session_to_filter_choices(factories):
+    tracks = factories['music.Track'].create_batch(30)
+    user = factories['users.User']()
+    radio = radios.RandomRadio()
+    session = radio.start_session(user)
+
+    for i in range(30):
+        p = radio.pick()
+
+    # ensure 30 differents tracks have been suggested
+    tracks_id = [
+        session_track.track.pk
+        for session_track in session.session_tracks.all()]
+    assert len(set(tracks_id)) == 30
+
+
+def test_can_restore_radio_from_previous_session(factories):
+    user = factories['users.User']()
+    radio = radios.RandomRadio()
+    session = radio.start_session(user)
+
+    restarted_radio = radios.RandomRadio(session)
+    assert radio.session == restarted_radio.session
+
+
+def test_can_start_radio_for_logged_in_user(logged_in_client):
+    url = reverse('api:v1:radios:sessions-list')
+    response = logged_in_client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+    assert session.radio_type == 'random'
+    assert session.user == logged_in_client.user
+
+
+def test_can_start_radio_for_anonymous_user(client, db):
+    url = reverse('api:v1:radios:sessions-list')
+    response = client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+
+    assert session.radio_type == 'random'
+    assert session.user is None
+    assert session.session_key == client.session.session_key
+
+
+def test_can_get_track_for_session_from_api(factories, logged_in_client):
+    tracks = factories['music.Track'].create_batch(size=1)
+
+    url = reverse('api:v1:radios:sessions-list')
+    response = logged_in_client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+
+    url = reverse('api:v1:radios:tracks-list')
+    response = logged_in_client.post(url, {'session': session.pk})
+    data = json.loads(response.content.decode('utf-8'))
+
+    assert data['track']['id'] == tracks[0].id
+    assert data['position'] == 1
+
+    next_track = factories['music.Track']()
+    response = logged_in_client.post(url, {'session': session.pk})
+    data = json.loads(response.content.decode('utf-8'))
+
+    assert data['track']['id'] == next_track.id
+    assert data['position'] == 2
+
+
+def test_related_object_radio_validate_related_object(factories):
+    user = factories['users.User']()
+    # cannot start without related object
+    radio = radios.ArtistRadio()
+    with pytest.raises(ValidationError):
+        radio.start_session(user)
+
+    # cannot start with bad related object type
+    radio = radios.ArtistRadio()
+    with pytest.raises(ValidationError):
+        radio.start_session(user, related_object=user)
+
+
+def test_can_start_artist_radio(factories):
+    user = factories['users.User']()
+    artist = factories['music.Artist']()
+    wrong_tracks = factories['music.Track'].create_batch(5)
+    good_tracks = factories['music.Track'].create_batch(5, artist=artist)
+
+    radio = radios.ArtistRadio()
+    session = radio.start_session(user, related_object=artist)
+    assert session.radio_type == 'artist'
+    for i in range(5):
+        assert radio.pick() in good_tracks
+
+
+def test_can_start_tag_radio(factories):
+    user = factories['users.User']()
+    tag = factories['taggit.Tag']()
+    wrong_tracks = factories['music.Track'].create_batch(5)
+    good_tracks = factories['music.Track'].create_batch(5, tags=[tag])
+
+    radio = radios.TagRadio()
+    session = radio.start_session(user, related_object=tag)
+    assert session.radio_type =='tag'
+    for i in range(5):
+        assert radio.pick() in good_tracks
+
+
+def test_can_start_artist_radio_from_api(client, factories):
+    artist = factories['music.Artist']()
+    url = reverse('api:v1:radios:sessions-list')
+
+    response = client.post(
+        url, {'radio_type': 'artist', 'related_object_id': artist.id})
+    session = models.RadioSession.objects.latest('id')
+    assert session.radio_type, 'artist'
+    assert session.related_object, artist
+
+
+def test_can_start_less_listened_radio(factories):
+    user = factories['users.User']()
+    history = factories['history.Listening'].create_batch(5, user=user)
+    wrong_tracks = [h.track for h in history]
+    good_tracks = factories['music.Track'].create_batch(size=5)
+    radio = radios.LessListenedRadio()
+    session = radio.start_session(user)
+    assert session.related_object == user
+    for i in range(5):
+        assert radio.pick() in good_tracks
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
new file mode 100644
index 0000000000000000000000000000000000000000..017d742ef834562f33425cbc84046fdf7d580bb4
--- /dev/null
+++ b/api/tests/test_youtube.py
@@ -0,0 +1,95 @@
+import json
+from collections import OrderedDict
+from django.urls import reverse
+from funkwhale_api.providers.youtube.client import client
+
+from .data import youtube as api_data
+
+
+def test_can_get_search_results_from_youtube(mocker):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        return_value=api_data.search['8 bit adventure'])
+    query = '8 bit adventure'
+    results = client.search(query)
+    assert results[0]['id']['videoId'] == '0HxZn6CzOIo'
+    assert results[0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure'
+    assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
+
+
+def test_can_get_search_results_from_funkwhale(mocker, client, db):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        return_value=api_data.search['8 bit adventure'])
+    query = '8 bit adventure'
+    url = reverse('api:v1:providers:youtube:search')
+    response = client.get(url, {'query': query})
+    # we should cast the youtube result to something more generic
+    expected = {
+        "id": "0HxZn6CzOIo",
+        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
+        "type": "youtube#video",
+        "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
+        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
+        "title": "AdhesiveWombat - 8 Bit Adventure",
+        "channelTitle": "AdhesiveWombat",
+        "publishedAt": "2012-08-22T18:41:03.000Z",
+        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
+    }
+
+    assert json.loads(response.content.decode('utf-8'))[0] == expected
+
+
+def test_can_send_multiple_queries_at_once(mocker):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        side_effect=[
+            api_data.search['8 bit adventure'],
+            api_data.search['system of a down toxicity'],
+        ]
+    )
+
+    queries = OrderedDict()
+    queries['1'] = {
+        'q': '8 bit adventure',
+    }
+    queries['2'] = {
+        'q': 'system of a down toxicity',
+    }
+
+    results = client.search_multiple(queries)
+
+    assert results['1'][0]['id']['videoId'] == '0HxZn6CzOIo'
+    assert results['1'][0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure'
+    assert results['1'][0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
+    assert results['2'][0]['id']['videoId'] == 'BorYwGi2SJc'
+    assert results['2'][0]['snippet']['title'] == 'System of a Down: Toxicity'
+    assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc'
+
+
+def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        return_value=api_data.search['8 bit adventure'])
+    queries = OrderedDict()
+    queries['1'] = {
+        'q': '8 bit adventure',
+    }
+
+    expected = {
+        "id": "0HxZn6CzOIo",
+        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
+        "type": "youtube#video",
+        "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
+        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
+        "title": "AdhesiveWombat - 8 Bit Adventure",
+        "channelTitle": "AdhesiveWombat",
+        "publishedAt": "2012-08-22T18:41:03.000Z",
+        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
+    }
+
+    url = reverse('api:v1:providers:youtube:searchs')
+    response = client.post(
+        url, json.dumps(queries), content_type='application/json')
+
+    assert expected == json.loads(response.content.decode('utf-8'))['1'][0]
diff --git a/api/funkwhale_api/music/tests/__init__.py b/api/tests/users/__init__.py
similarity index 100%
rename from api/funkwhale_api/music/tests/__init__.py
rename to api/tests/users/__init__.py
diff --git a/api/tests/users/test_admin.py b/api/tests/users/test_admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..7645a02953b1caa44b6002a741fadf48c406449f
--- /dev/null
+++ b/api/tests/users/test_admin.py
@@ -0,0 +1,35 @@
+from funkwhale_api.users.admin import MyUserCreationForm
+
+
+def test_clean_username_success(db):
+    # Instantiate the form with a new username
+    form = MyUserCreationForm({
+        'username': 'alamode',
+        'password1': '123456',
+        'password2': '123456',
+    })
+    # Run is_valid() to trigger the validation
+    valid = form.is_valid()
+    assert valid
+
+    # Run the actual clean_username method
+    username = form.clean_username()
+    assert 'alamode' == username
+
+
+def test_clean_username_false(factories):
+    user = factories['users.User']()
+    # Instantiate the form with the same username as self.user
+    form = MyUserCreationForm({
+        'username': user.username,
+        'password1': '123456',
+        'password2': '123456',
+    })
+    # Run is_valid() to trigger the validation, which is going to fail
+    # because the username is already taken
+    valid = form.is_valid()
+    assert not valid
+
+    # The form.errors dict should contain a single error called 'username'
+    assert len(form.errors) == 1
+    assert 'username' in form.errors
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..57793f494bcc59a6994d2f014cf9ae7090495cd6
--- /dev/null
+++ b/api/tests/users/test_models.py
@@ -0,0 +1,4 @@
+
+def test__str__(factories):
+    user = factories['users.User'](username='hello')
+    assert user.__str__() == 'hello'
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..42be77b7c18a2cd4b25829b1b567d044b0d9c6b9
--- /dev/null
+++ b/api/tests/users/test_views.py
@@ -0,0 +1,64 @@
+import json
+
+from django.test import RequestFactory
+from django.urls import reverse
+
+from funkwhale_api.users.models import User
+
+
+def test_can_create_user_via_api(settings, client, db):
+    url = reverse('rest_register')
+    data = {
+        'username': 'test1',
+        'email': 'test1@test.com',
+        'password1': 'testtest',
+        'password2': 'testtest',
+    }
+    settings.REGISTRATION_MODE = "public"
+    response = client.post(url, data)
+    assert response.status_code == 201
+
+    u = User.objects.get(email='test1@test.com')
+    assert u.username == 'test1'
+
+
+def test_can_disable_registration_view(settings, client, db):
+    url = reverse('rest_register')
+    data = {
+        'username': 'test1',
+        'email': 'test1@test.com',
+        'password1': 'testtest',
+        'password2': 'testtest',
+    }
+    settings.REGISTRATION_MODE = "disabled"
+    response = client.post(url, data)
+    assert response.status_code == 403
+
+
+def test_can_fetch_data_from_api(client, factories):
+    url = reverse('api:v1:users:users-me')
+    response = client.get(url)
+    # login required
+    assert response.status_code == 401
+
+    user = factories['users.User'](
+        is_staff=True,
+        perms=[
+            'music.add_importbatch',
+            'dynamic_preferences.change_globalpreferencemodel',
+        ]
+    )
+    assert user.has_perm('music.add_importbatch')
+    client.login(username=user.username, password='test')
+    response = client.get(url)
+    assert response.status_code == 200
+
+    payload = json.loads(response.content.decode('utf-8'))
+
+    assert payload['username'] == user.username
+    assert payload['is_staff'] == user.is_staff
+    assert payload['is_superuser'] == user.is_superuser
+    assert payload['email'] == user.email
+    assert payload['name'] == user.name
+    assert payload['permissions']['import.launch']['status']
+    assert payload['permissions']['settings.change']['status']