Files
NoN-Site/nonpacks/library/tests.py
T

1145 lines
49 KiB
Python

import io
import json
import os
import secrets
import shutil
import tempfile
import zipfile
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from profiles.models import ApiToken, UserProfile
from .models import (
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
VersionFile,
)
class UGCMediaTestCase(TestCase):
"""Base class that redirects uploaded media to a temp directory."""
@classmethod
def setUpClass(cls):
cls._media_root = tempfile.mkdtemp(prefix='packs_ugc_test_media_')
cls.override = override_settings(MEDIA_ROOT=cls._media_root)
cls.override.enable()
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.override.disable()
shutil.rmtree(cls._media_root, ignore_errors=True)
class UGCGatedTestCase(TestCase):
"""Base class with a helper to authorize the client past the gate."""
def gate(self):
session = self.client.session
session['authorized'] = True
session.save()
def make_png_bytes(size=(8, 8), color='red'):
import PIL.Image
buf = io.BytesIO()
PIL.Image.new('RGB', size, color).save(buf, format='PNG')
return buf.getvalue()
def make_zip_bytes():
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('pack.mcmeta', '{"pack": {"pack_format": 15}}')
return buf.getvalue()
class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
def _upload_temp(self, kind, name, content, content_type):
return self.client.post(
reverse('library:api_upload_temp'),
{'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'):
self.gate()
self.client.login(username=user.username, password='pw')
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png')
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip')
data = {
'title': title,
'summary': 'A test pack.',
'category': 'mod',
'description': '# Hello\n\nWorld.',
'tags': tags,
'version_name': '1.0.0',
'changelog': 'First release.',
}
return self.client.post(reverse('library:project_create'), data)
def test_create_requires_login(self):
self.gate()
resp = self.client.get(reverse('library:project_create'))
self.assertEqual(resp.status_code, 302)
def test_create_project(self):
self.gate()
resp = self._create_project(self.alice)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
project = Project.objects.get(slug='test-pack')
self.assertEqual(project.owner, self.alice)
self.assertEqual(project.category, 'mod')
# First version + thumbnail stored and adopted (moved under project_<pk>/).
self.assertEqual(project.versions.count(), 1)
self.assertIsNotNone(project.thumbnail)
self.assertTrue(project.thumbnail.stored_path.startswith('project_'))
self.assertTrue(project.versions.first().files.first().file.stored_path.startswith('project_'))
# Temp uploads marked used.
self.assertEqual(
TempUpload.objects.filter(user=self.alice, status='used').count(), 2
)
# Tags auto-created and linked.
names = set(TagList.objects.filter(project=project).values_list('tag__name', flat=True))
self.assertIn('furry', names)
self.assertIn('threesome', names)
self.assertIn('alice', names) # creator tag auto-synced
# Creator tag exists with category creator.
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
def test_create_requires_version_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_create'), {
'title': 'No File',
'category': 'mod',
'version_name': '1.0.0',
})
self.assertEqual(resp.status_code, 200)
self.assertFalse(Project.objects.filter(slug='no-file').exists())
def test_create_adopts_media_and_captions(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png')
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip')
self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png')
self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png')
media = TempUpload.objects.filter(kind='media').order_by('created_at')
caption_payload = {
f'caption_{media[0].uuid}': 'Showcase',
f'caption_{media[1].uuid}': 'Second angle',
}
resp = self.client.post(reverse('library:project_create'), {
'title': 'Media Pack',
'category': 'skin',
'version_name': '1.0.0',
**caption_payload,
})
self.assertRedirects(resp, reverse('library:project_detail', args=['media-pack']))
project = Project.objects.get(slug='media-pack')
self.assertEqual(project.assets.count(), 2)
# Per-file captions stored individually.
captions = dict(project.assets.values_list('file__original_filename', 'caption'))
self.assertEqual(captions['pic1.png'], 'Showcase')
self.assertEqual(captions['pic2.png'], 'Second angle')
for asset in project.assets.all():
self.assertTrue(asset.file.stored_path.startswith('project_'))
def test_detail_requires_gate(self):
self._create_project(self.alice)
self.client.logout()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 302) # gate redirects HTML pages
def test_detail_gated_ok(self):
self._create_project(self.alice)
self.client.logout()
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Test Pack')
self.assertContains(resp, '<h1>Hello</h1>', html=True)
def test_detail_unknown_404(self):
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['nope']))
self.assertEqual(resp.status_code, 404)
class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
self.project.sync_creator_tags()
def test_upload_version_owner(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0', 'changelog': 'More stuff.'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(self.project.versions.count(), 1)
version = self.project.versions.first()
self.assertEqual(version.version_name, '2.0.0')
self.assertEqual(version.files.count(), 1)
# Adopted file moved under project_<pk>/versions/ and temp marked used.
self.assertTrue(version.files.first().file.stored_path.startswith('project_'))
self.assertTrue(
TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists()
)
def test_upload_version_multiple_files(self):
self.gate()
self.client.login(username='Alice', password='pw')
for name in ('skin1.png', 'skin2.png'):
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile(name, make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '3.0.0'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
version = self.project.versions.first()
self.assertEqual(version.version_name, '3.0.0')
# One version, several files, PNGs classified as png kind.
self.assertEqual(version.files.count(), 2)
self.assertEqual(
set(version.files.values_list('kind', flat=True)), {'png'}
)
self.assertEqual(
set(version.files.values_list('file__original_filename', flat=True)),
{'skin1.png', 'skin2.png'},
)
def test_upload_version_requires_pending_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(self.project.versions.count(), 0)
def test_upload_version_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(self.project.versions.count(), 0)
def test_contributor_can_upload(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
self.gate()
self.client.login(username='Bob', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(self.project.versions.count(), 1)
def test_download_bumps_counter_and_serves(self):
from library.storage import store_file
index = store_file(
self.alice, 'version',
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
self.project.pk, subdir='versions',
)
version = Version.objects.create(project=self.project, version_name='1.0.0')
VersionFile.objects.create(version=version, file=index, kind='release')
self.gate()
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
# Follow redirect → file served.
resp = self.client.get(resp.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())
def test_per_file_download(self):
from library.storage import store_file
index = store_file(
self.alice, 'version',
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
self.project.pk, subdir='versions',
)
version = Version.objects.create(project=self.project, version_name='1.0.0')
vf = VersionFile.objects.create(version=version, file=index, kind='release')
self.gate()
resp = self.client.get(
reverse('library:version_file_download', args=['test-pack', version.pk, index.uuid])
)
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
self.assertEqual(resp.url, reverse('library:file_request', args=[index.uuid]) + '?download=1')
# The parent version_download route still works (first file).
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
self.assertTrue(vf)
class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
)
def test_upload_image_asset_with_caption(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
temp = TempUpload.objects.get(kind='media')
resp = self.client.post(
reverse('library:asset_upload', args=['test-pack']),
{f'caption_{temp.uuid}': 'Preview'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
asset = ProjectAsset.objects.get()
self.assertEqual(asset.caption, 'Preview')
self.assertEqual(asset.uploaded_by, self.alice)
self.assertFalse(asset.is_video)
def test_asset_captions_are_per_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(color='red'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
temps = list(TempUpload.objects.filter(kind='media').order_by('created_at'))
self.client.post(reverse('library:asset_upload', args=['test-pack']), {
f'caption_{temps[0].uuid}': 'First',
f'caption_{temps[1].uuid}': 'Second',
})
captions = dict(ProjectAsset.objects.values_list('file__original_filename', 'caption'))
self.assertEqual(captions, {'a.png': 'First', 'b.png': 'Second'})
def test_reject_non_media_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('evil.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_gallery_upload_requires_pending_media(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_removes_index(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(ProjectAsset.objects.count(), 0)
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
def test_asset_delete_ajax_returns_json(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:asset_delete', args=['test-pack', asset.pk]),
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, {'success': True})
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_requires_post(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 405)
self.assertEqual(ProjectAsset.objects.count(), 1)
def test_asset_delete_non_contributor_denied(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectAsset.objects.count(), 1)
class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _post_json(self, url, payload):
import json
return self.client.post(url, json.dumps(payload), content_type='application/json')
def test_upload_api_requires_auth(self):
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 401)
def test_temp_upload_media_accepts_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()['uuid'])
upload = TempUpload.objects.get()
self.assertEqual(upload.kind, 'media')
self.assertEqual(upload.status, 'pending')
self.assertTrue(upload.file.stored_path.startswith('uploads/user_'))
def test_temp_upload_thumbnail_rejects_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(TempUpload.objects.count(), 0)
def test_thumbnail_single_slot_version_multi(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v1.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
# Thumbnail is a single slot; version accepts multiple files.
self.assertEqual(
TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
)
self.assertEqual(
TempUpload.objects.filter(kind='version', status='pending').count(), 2
)
def test_temp_upload_delete(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(TempUpload.objects.count(), 0)
def test_delete_others_upload_forbidden(self):
User = get_user_model()
bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=bob)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
self.client.logout()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(TempUpload.objects.count(), 1)
def test_draft_save_and_restore(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {
'title': 'Draft Title',
'summary': 'Draft summary',
'category': 'mod',
'description': 'Hello draft',
'tags': 'species:dragon',
'version_name': '2.0.0',
}})
draft = ProjectDraft.objects.get(user=self.alice)
self.assertEqual(draft.data['title'], 'Draft Title')
resp = self.client.get(reverse('library:api_draft'))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['data']['title'], 'Draft Title')
def test_create_form_prefills_draft(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}})
resp = self.client.get(reverse('library:project_create'))
self.assertContains(resp, 'value="My Draft Pack"')
def test_create_clears_draft(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}})
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(reverse('library:project_create'), {
'title': 'My Draft Pack', 'category': 'mod', 'version_name': '1.0.0',
})
self.assertRedirects(resp, reverse('library:project_detail', args=['my-draft-pack']))
self.assertFalse(ProjectDraft.objects.filter(user=self.alice).exists())
class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.carol = User.objects.create_user(username='Carol', password='pw')
UserProfile.objects.get_or_create(user=self.carol)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
def test_edit_owner_ok(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Renamed Pack',
'summary': 'New summary',
'description': 'Updated.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Renamed Pack')
def test_edit_replaces_thumbnail_via_temp(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Test Pack', 'category': 'mod', 'description': 'x'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertIsNotNone(self.project.thumbnail)
self.assertTrue(self.project.thumbnail.stored_path.startswith('project_'))
self.assertTrue(
TempUpload.objects.filter(user=self.alice, kind='thumbnail', status='used').exists()
)
def test_edit_shows_existing_thumbnail_preview(self):
from library.storage import store_file
index = store_file(
self.alice, 'thumbnail',
SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='',
)
self.project.thumbnail = index
self.project.save()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, str(index.uuid))
def test_edit_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Hijacked', 'category': 'mod'},
)
self.assertEqual(resp.status_code, 403)
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Test Pack')
def test_edit_contributor_ok(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'summary': 'Edits by contributor.',
'description': 'Changed.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.summary, 'Edits by contributor.')
def test_delete_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertEqual(resp.status_code, 403)
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertRedirects(resp, reverse('library:browse'))
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists())
def test_contributor_management_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectContributor.objects.count(), 0)
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertRedirects(resp, reverse('library:contributors', args=['test-pack']))
self.assertTrue(
ProjectContributor.objects.filter(project=self.project, user=self.carol).exists()
)
def test_contributor_creator_tag_sync_and_detach(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
self.assertTrue(Tag.objects.filter(name='bob', category=creator_cat).exists())
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 2
)
# Remove Bob as a contributor → his creator tag detaches.
ProjectContributor.objects.filter(project=self.project, user=self.bob).delete()
self.project.detach_creator_tag(self.bob)
self.project.sync_creator_tags(actor=self.alice)
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 1
)
self.assertFalse(
TagList.objects.filter(project=self.project, tag__category=creator_cat, tag__name='bob').exists()
)
def test_edit_syncs_creator_tags(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'description': 'x',
'category': 'mod',
'tags': 'species:dragon',
},
)
creator_cat = TagCategory.objects.get(slug='creator')
names = set(TagList.objects.filter(project=self.project, tag__category=creator_cat).values_list('tag__name', flat=True))
self.assertIn('alice', names)
self.assertIn('bob', names)
# Manual species tag applied.
self.assertTrue(TagList.objects.filter(project=self.project, tag__name='dragon').exists())
def _add_manual_creator_tag(self, name='lizo'):
creator_cat = TagCategory.objects.get(slug='creator')
tag, _ = Tag.objects.get_or_create(name=name, category=creator_cat, created_by=self.alice)
TagList.objects.create(project=self.project, tag=tag, added_by=self.alice)
return tag
def test_edit_shows_manual_creator_tag(self):
self.project.sync_creator_tags()
self._add_manual_creator_tag('lizo')
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
# The manual creator tag (no matching account) is serialized into the editor.
self.assertContains(resp, 'creator:lizo')
def test_edit_hides_account_backed_creator_tags(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
self._add_manual_creator_tag('lizo')
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:project_edit', args=['test-pack']))
# Manual tag visible; account-backed creator tags (alice, bob) hidden.
self.assertContains(resp, 'creator:lizo')
self.assertNotContains(resp, 'creator:alice')
self.assertNotContains(resp, 'creator:bob')
def test_edit_removing_manual_creator_tag_deletes_it(self):
self.project.sync_creator_tags()
self._add_manual_creator_tag('lizo')
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Test Pack', 'category': 'mod', 'description': 'x', 'tags': ''},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
# Manual creator tag removed from the form is now deleted...
self.assertFalse(
TagList.objects.filter(project=self.project, tag__name='lizo').exists()
)
# ...while the account-backed owner creator tag survives.
self.assertTrue(
TagList.objects.filter(project=self.project, tag__name='alice').exists()
)
def test_edit_keeps_manual_creator_tag_when_resubmitted(self):
self.project.sync_creator_tags()
self._add_manual_creator_tag('lizo')
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Test Pack', 'category': 'mod', 'description': 'x',
'tags': 'creator:lizo species:dragon'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertTrue(
TagList.objects.filter(project=self.project, tag__name='lizo').exists()
)
self.assertTrue(
TagList.objects.filter(project=self.project, tag__name='dragon').exists()
)
class UGCBrowseTests(UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
def _seed(self):
p1 = Project.objects.create(slug='furry-mod', title='Furry Mod', category='mod', owner=self.alice, summary='Dragon fun')
p2 = Project.objects.create(slug='skin-pack', title='Skin Pack', category='skin', owner=self.bob)
p1.sync_creator_tags()
# Tag p1: species:dragon, content:threesome. Tag p2: species:dragon only.
for cat_slug, name in [('species', 'dragon'), ('content', 'threesome')]:
cat = TagCategory.objects.get(slug=cat_slug)
tag = Tag.objects.get_or_create(name=name, category=cat)[0]
TagList.objects.create(project=p1, tag=tag, added_by=self.alice)
cat = TagCategory.objects.get(slug='species')
tag = Tag.objects.get_or_create(name='dragon', category=cat)[0]
TagList.objects.create(project=p2, tag=tag, added_by=self.bob)
return p1, p2
def test_browse_gated(self):
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 302)
def test_browse_lists_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_search(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'q': 'Furry'})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_category_filter(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'category': 'skin'})
self.assertContains(resp, 'Skin Pack')
self.assertNotContains(resp, 'Furry Mod')
def test_browse_tag_filter_and(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon', 'threesome']})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_tag_filter_single(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon']})
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_sort_recent(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
# Skin Pack created last → first card.
html = resp.content.decode()
self.assertLess(html.find('Skin Pack'), html.find('Furry Mod'))
def test_home_shows_real_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('landing:home'))
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
cat = TagCategory.objects.get(slug='species')
Tag.objects.create(name='dragon', category=cat, created_by=self.alice)
Tag.objects.create(name='dragoness', category=cat, created_by=self.alice)
self.token = secrets.token_urlsafe(48)
ApiToken.objects.create(user=self.alice, token=self.token, key_prefix=self.token[:8], label='test')
def test_tags_autocomplete_gated(self):
resp = self.client.get(reverse('library:api_tags_autocomplete'), {'q': 'dra'})
self.assertEqual(resp.status_code, 401)
def test_tags_autocomplete_with_token(self):
resp = self.client.get(
reverse('library:api_tags_autocomplete'), {'q': 'dra'},
HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
)
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [
{'name': 'dragon', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
{'name': 'dragoness', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
])
def test_users_autocomplete(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
def _zip_bytes(entries):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
for name, content in entries.items():
zf.writestr(name, content)
return buf.getvalue()
def _pack_zip():
return _zip_bytes({
'pack.mcmeta': json.dumps({
'pack': {'pack_format': 64, 'description': 'A test pack'},
'animationframework': {'id': 'jakebreath:test'},
}),
})
class UGCZipParsingTests(TestCase):
def _write_zip(self, data, suffix='.zip'):
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(data)
return f.name
def test_read_pack_mcmeta(self):
from library.zips import read_pack_mcmeta
path = self._write_zip(_pack_zip())
try:
fmt, desc = read_pack_mcmeta(path)
finally:
os.unlink(path)
self.assertEqual(fmt, 64)
self.assertEqual(desc, 'A test pack')
def test_read_pack_mcmeta_missing(self):
from library.zips import read_pack_mcmeta
path = self._write_zip(_zip_bytes({'assets/x.txt': 'x'}))
try:
fmt, desc = read_pack_mcmeta(path)
finally:
os.unlink(path)
self.assertIsNone(fmt)
self.assertEqual(desc, '')
def test_parse_mods_folder(self):
from library.zips import parse_mods_manifest
path = self._write_zip(_zip_bytes({
'minecraft/mods/a.jar': 'x', 'minecraft/mods/b.jar': 'y',
'pack.mcmeta': '{}',
}))
try:
manifest = parse_mods_manifest(path, 'pack.zip')
finally:
os.unlink(path)
self.assertEqual(manifest['source'], 'folder')
self.assertEqual([m['name'] for m in manifest['files']], ['a.jar', 'b.jar'])
def test_parse_mods_mrpack(self):
from library.zips import parse_mods_manifest
path = self._write_zip(_zip_bytes({
'modrinth.index.json': json.dumps({
'dependencies': [
{'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'},
],
}),
}), suffix='.mrpack')
try:
manifest = parse_mods_manifest(path, 'pack.mrpack')
finally:
os.unlink(path)
self.assertEqual(manifest['source'], 'modrinth')
self.assertEqual(manifest['files'][0]['name'], 'mod-a.jar')
def test_parse_mods_curseforge(self):
from library.zips import parse_mods_manifest
path = self._write_zip(_zip_bytes({
'manifest.json': json.dumps({
'files': [{'projectID': 123, 'fileID': 456, 'fileName': 'cool-mod.jar'}],
}),
}))
try:
manifest = parse_mods_manifest(path, 'pack.zip')
finally:
os.unlink(path)
self.assertEqual(manifest['source'], 'curseforge')
self.assertEqual(manifest['files'][0]['name'], 'cool-mod.jar')
class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _upload_version(self, name, content, content_type='application/zip'):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type=content_type)},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
def _upload_version_form(self, version_name='1.0.0'):
resp = self.client.post(
reverse('library:version_upload', args=[self.project.slug]),
{'version_name': version_name},
)
self.assertRedirects(resp, reverse('library:project_detail', args=[self.project.slug]))
def test_non_pack_captures_pack_mcmeta(self):
self.project = Project.objects.create(
slug='non-pack', title='NoN Pack', category='non_pack', owner=self.alice,
)
self._upload_version('pack.zip', _pack_zip())
self._upload_version_form()
version = self.project.versions.first()
self.assertEqual(version.pack_format, 64)
self.assertEqual(version.pack_description, 'A test pack')
def test_modpack_captures_mods_manifest(self):
self.project = Project.objects.create(
slug='modpack', title='Modpack', category='modpack', owner=self.alice,
)
self._upload_version('pack.zip', _zip_bytes({'minecraft/mods/a.jar': 'x'}))
self._upload_version_form()
version = self.project.versions.first()
self.assertEqual(version.mods_manifest['source'], 'folder')
self.assertEqual(version.mods_manifest['files'][0]['name'], 'a.jar')
def test_guide_uploads_md_and_zip_and_renders(self):
self.project = Project.objects.create(
slug='guide', title='Guide', category='guide', owner=self.alice,
)
self._upload_version('README.md', b'# Title\n\nBody text.', content_type='text/markdown')
self._upload_version('assets.zip', _zip_bytes({'data/x.json': '{}'}))
self._upload_version_form()
version = self.project.versions.first()
self.assertEqual(
set(version.files.values_list('kind', flat=True)), {'markdown', 'release'}
)
md_vf = version.files.get(kind='markdown')
# guide_doc endpoint renders the markdown server-side.
self.gate()
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, '<h1>Title</h1>', html=True)
# Detail page exposes the guide switcher.
resp = self.client.get(reverse('library:project_detail', args=['guide']))
self.assertContains(resp, 'guide-switcher')
self.assertContains(resp, 'README.md')
def test_guide_doc_rejects_non_markdown(self):
self.project = Project.objects.create(
slug='guide', title='Guide', category='guide', owner=self.alice,
)
self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'}))
self._upload_version_form()
version = self.project.versions.first()
vf = version.files.get(kind='release')
self.gate()
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
self.assertEqual(resp.status_code, 404)
def test_regular_category_has_no_extra_metadata(self):
self.project = Project.objects.create(
slug='mod', title='Mod', category='mod', owner=self.alice,
)
self._upload_version('pack.zip', _pack_zip())
self._upload_version_form()
version = self.project.versions.first()
self.assertIsNone(version.pack_format)
self.assertEqual(version.mods_manifest, {})