import io import json import os import secrets import shutil import tempfile import unittest 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_/). 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, '

Hello

', 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_/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({ 'files': [ {'path': 'mods/mod-a.jar', 'env': {'client': 'required', 'server': '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, '

Title

', 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, {}) def _animation_pack_zip(with_id=True, entity_x_player=False, problem=True): actor_a = { 'label': 'actor1', 'entity_types': ['minecraft:player'], 'actor_tags': ['gender.male'], 'activity': 'active', 'injector': 'V', } actor_b = { 'label': 'actor2', 'entity_types': ['minecraft:zombie'] if entity_x_player else ['minecraft:player'], 'actor_tags': [] if entity_x_player else ['gender.female'], 'activity': 'passive', 'receiver': True, } content_tags = ['missionary'] + (['bugged'] if problem else []) af = { 'id': 'jakebreath:testpack', 'name': 'Test Pack', 'author': 'Alice, Bob', 'version': '1.0.0', 'description': 'A test pack.', } if with_id else {'name': 'Test Pack', 'author': 'Alice'} return _zip_bytes({ 'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': af}), 'data/jakebreath/afw_animdefs/ground.json': json.dumps({ 'display_name': 'Ground', 'content_tags': content_tags, 'actors': [actor_a, actor_b], 'stages': [{'stage': 1, 'loop': True, 'cycle_seconds': 1.0}, {'stage': 2, 'loop': False, 'non_peak': True}], }), }) def _bom_pack_zip(): mcmeta = b'\xef\xbb\xbf' + json.dumps({ 'pack': {'pack_format': 64}, 'animationframework': {'id': 'needsofnature:default', 'name': 'Default', 'author': 'NoN Team'}, }).encode() return _zip_bytes({ 'pack.mcmeta': mcmeta, 'data/needsofnature/afw_animdefs/solo.json': json.dumps({ 'actors': [{'label': 'actor1', 'entity_types': ['minecraft:player'], 'activity': 'passive'}], 'content_tags': ['solo'], 'stages': [{'stage': 1, 'loop': True}], }), }) class UGCAnimationTests(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 _write_zip(self, data): with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f: f.write(data) return f.name def _upload_version(self, slug, name, content): 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='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) self.assertEqual(resp.status_code, 200) return self.client.post( reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'}, ) def test_read_animation_manifest_player_x_player(self): from library.zips import read_animation_manifest path = self._write_zip(_animation_pack_zip()) try: manifest = read_animation_manifest(path) finally: os.unlink(path) self.assertEqual(manifest['animation_id'], 'jakebreath:testpack') self.assertEqual(manifest['authors'], ['Alice', 'Bob']) anim = manifest['animations'][0] self.assertEqual(anim['name'], 'Ground') self.assertEqual(anim['type'], 'Pair') self.assertEqual(anim['content_tags'], ['missionary', 'bugged']) self.assertEqual(anim['problem_tags'], ['bugged']) self.assertEqual(anim['actors'][0]['injector'], 'V') self.assertEqual(anim['actors'][0]['injector_name'], 'Vaginal') self.assertEqual(anim['actors'][1]['gender'], 'female') self.assertEqual(anim['stages'][1]['climax'], True) def test_read_animation_manifest_entity_x_player(self): from library.zips import read_animation_manifest path = self._write_zip(_animation_pack_zip(entity_x_player=True)) try: manifest = read_animation_manifest(path) finally: os.unlink(path) anim = manifest['animations'][0] self.assertEqual(anim['type'], 'Entity x Player') self.assertEqual(anim['actors'][1]['entity'], 'Zombie') self.assertEqual(anim['entity_names'], ['Zombie']) def test_read_animation_manifest_bom_mcmeta(self): from library.zips import read_animation_manifest, read_pack_mcmeta path = self._write_zip(_bom_pack_zip()) try: fmt, desc = read_pack_mcmeta(path) manifest = read_animation_manifest(path) finally: os.unlink(path) self.assertEqual(fmt, 64) self.assertEqual(manifest['animation_id'], 'needsofnature:default') self.assertEqual(manifest['animations'][0]['type'], 'Solo') def test_read_animation_manifest_not_animation(self): from library.zips import read_animation_manifest path = self._write_zip(_zip_bytes({'data/x.txt': 'x'})) try: manifest = read_animation_manifest(path) finally: os.unlink(path) self.assertIsNone(manifest) def test_version_upload_captures_manifest_and_tags(self): self.project = Project.objects.create( slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice, ) resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip()) self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack'])) version = self.project.versions.first() self.assertEqual(version.animation_id, 'jakebreath:testpack') self.assertEqual(version.animation_manifest['animations'][0]['name'], 'Ground') # Auto-created content tags (including the problem tag). names = set(TagList.objects.filter(project=self.project).values_list('tag__name', flat=True)) self.assertIn('missionary', names) self.assertIn('bugged', names) def test_animation_pack_without_id_gets_injected(self): self.project = Project.objects.create( slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice, ) resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False)) self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']), fetch_redirect_response=False) version = self.project.versions.first() # The missing id is auto-assigned instead of warning. self.assertEqual(version.animation_id, f'alice:project_{self.project.pk}') self.assertTrue(version.animation_manifest) def test_latest_api_returns_version_and_url(self): self.project = Project.objects.create( slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice, ) self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip()) self.gate() self.client.login(username='Alice', password='pw') resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack'])) self.assertEqual(resp.status_code, 200) data = resp.json() self.assertEqual(data['version'], '1.0.0') self.assertEqual(data['pack_format'], 64) self.assertIn('/api/files/', data['download_url']) self.assertIn('?download=1', data['download_url']) def test_latest_api_404_unknown_pack(self): self.gate() self.client.login(username='Alice', password='pw') resp = self.client.get(reverse('library:api_packs_latest', args=['nope', 'missing'])) self.assertEqual(resp.status_code, 404) def test_latest_api_404_without_animation_id(self): self.project = Project.objects.create( slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice, ) self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False)) self.gate() self.client.login(username='Alice', password='pw') resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack'])) self.assertEqual(resp.status_code, 404) def _geo_model_zip(): geo = { 'format_version': '1.12.0', 'afw_bone_textures': {'body': 'needsofnature:textures/entity/zombie/zombie.png'}, 'minecraft:geometry': [{ 'description': {'identifier': 'geometry.unknown', 'texture_width': 64, 'texture_height': 64}, 'bones': [ {'name': 'root', 'pivot': [0, 0, 0]}, {'name': 'body', 'pivot': [0, 12, 0], 'cubes': [ {'origin': [-4, 0, -4], 'size': [8, 8, 8], 'uv': [0, 0]}, ]}, ], }], } return _zip_bytes({ 'pack.mcmeta': json.dumps({ 'pack': {'pack_format': 88}, 'animationframework': {'id': 'christmaspuffin:models_and_textures', 'name': 'Models', 'author': 'CP', 'version': '1.0'}, }), 'assets/animationframework/geckolib/models/entity/zombie.m.geo.json': json.dumps(geo), 'assets/needsofnature/textures/entity/zombie/zombie.png': b'PNG-DATA', }) def _logical_zip(): return _zip_bytes({ 'pack.mcmeta': json.dumps({'pack': {'pack_format': 61}}), 'assets/usefulcum/items/potion_gender.json': json.dumps( {'model': {'type': 'minecraft:model', 'model': 'usefulcum:item/potion_gender'}}), 'assets/usefulcum/textures/item/potion_gender.png': b'PNG', 'data/usefulcum/recipe/cum_combination.json': json.dumps({ 'type': 'minecraft:crafting_shapeless', 'ingredients': ['minecraft:potion', 'minecraft:potion'], 'result': {'id': 'minecraft:glass_bottle', 'count': 2}, }), 'data/usefulcum/advancement/recipe/blaze_cum.json': json.dumps( {'rewards': {'function': 'usefulcum:recipe/blaze_cum'}}), 'data/usefulcum/advancement/change_gender.json': json.dumps( {'rewards': {'function': 'usefulcum:change_gender'}}), 'data/usefulcum/function/change_gender.mcfunction': b'# x', 'data/usefulcum/function/tick.mcfunction': b'# y', }) class UGCContentTabsTests(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 _write_zip(self, data): with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f: f.write(data) return f.name def _upload_version(self, slug, name, content): 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='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) self.assertEqual(resp.status_code, 200) return self.client.post( reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'}, ) def test_read_pack_meta_without_animdefs(self): from library.zips import read_pack_meta path = self._write_zip(_geo_model_zip()) try: meta = read_pack_meta(path) finally: os.unlink(path) self.assertEqual(meta['animation_id'], 'christmaspuffin:models_and_textures') self.assertEqual(meta['authors'], ['CP']) def test_read_models_manifest(self): from library.zips import read_models_manifest path = self._write_zip(_geo_model_zip()) try: manifest = read_models_manifest(path) finally: os.unlink(path) self.assertIsNotNone(manifest) model = manifest['models'][0] self.assertEqual(model['name'], 'zombie.m.geo.json') self.assertEqual(model['bones'], 2) self.assertEqual(model['cubes'], 1) self.assertEqual( model['bone_textures']['body'], 'assets/needsofnature/textures/entity/zombie/zombie.png', ) def test_read_logical_manifest(self): from library.zips import read_logical_manifest path = self._write_zip(_logical_zip()) try: manifest = read_logical_manifest(path) finally: os.unlink(path) self.assertIsNotNone(manifest) self.assertEqual(manifest['items'][0]['name'], 'usefulcum:potion_gender') self.assertEqual(manifest['recipes'][0]['ingredients'], ['minecraft:potion']) self.assertEqual(manifest['recipes'][0]['result'], 'minecraft:glass_bottle x2') self.assertEqual(manifest['custom_recipes'][0]['name'], 'blaze_cum') self.assertEqual(manifest['advancements'][0]['name'], 'change_gender') self.assertEqual(len(manifest['functions']), 2) def test_models_pack_gets_id_and_models_tab(self): self.project = Project.objects.create( slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice, ) self._upload_version('models-pack', 'pack.zip', _geo_model_zip()) version = self.project.versions.first() self.assertEqual(version.animation_id, 'christmaspuffin:models_and_textures') self.assertTrue(version.models_manifest) self.assertFalse(version.animation_manifest) self.gate() resp = self.client.get(reverse('library:project_detail', args=['models-pack'])) self.assertContains(resp, 'tab-models') self.assertContains(resp, 'Render') self.assertNotContains(resp, 'tab-animations') def test_logical_pack_shows_only_logical_tab(self): self.project = Project.objects.create( slug='logical-pack', title='Logical Pack', category='non_pack', owner=self.alice, ) self._upload_version('logical-pack', 'pack.zip', _logical_zip()) version = self.project.versions.first() self.assertTrue(version.logical_manifest) self.assertFalse(version.models_manifest) self.assertFalse(version.animation_manifest) self.gate() resp = self.client.get(reverse('library:project_detail', args=['logical-pack'])) self.assertContains(resp, 'tab-logical') self.assertNotContains(resp, 'tab-models') self.assertNotContains(resp, 'tab-animations') self.assertContains(resp, 'usefulcum:potion_gender') def test_pack_asset_serves_member(self): self.project = Project.objects.create( slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice, ) self._upload_version('models-pack', 'pack.zip', _geo_model_zip()) version = self.project.versions.first() self.gate() resp = self.client.get(reverse( 'library:pack_asset', args=['models-pack', version.pk, 'assets/needsofnature/textures/entity/zombie/zombie.png'], )) self.assertEqual(resp.status_code, 200) self.assertEqual(resp['Content-Type'].split(';')[0], 'image/png') self.assertEqual(resp.content, b'PNG-DATA') def test_pack_asset_rejects_unknown_member(self): self.project = Project.objects.create( slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice, ) self._upload_version('models-pack', 'pack.zip', _geo_model_zip()) version = self.project.versions.first() self.gate() resp = self.client.get(reverse( 'library:pack_asset', args=['models-pack', version.pk, 'assets/nope/nothing.png'], )) self.assertEqual(resp.status_code, 404) def test_gallery_video_is_muted(self): from library.storage import store_file self.project = Project.objects.create( slug='muted', title='Muted', category='mod', owner=self.alice, ) index = store_file( self.alice, 'asset', SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm'), self.project.pk, subdir='gallery', ) ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice) self.gate() resp = self.client.get(reverse('library:project_detail', args=['muted'])) self.assertContains(resp, 'muted') def make_gif_bytes(): import PIL.Image frames = [PIL.Image.new('RGB', (8, 8), c) for c in ('red', 'green')] buf = io.BytesIO() frames[0].save(buf, format='GIF', save_all=True, append_images=frames[1:], duration=100, loop=0) return buf.getvalue() class UGCThumbnailTests(UGCMediaTestCase, UGCGatedTestCase): @unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg required') def test_video_and_gif_thumbnails(self): User = get_user_model() alice = User.objects.create_user(username='Alice', password='pw') UserProfile.objects.get_or_create(user=alice) project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice) from library.storage import store_file gif = ProjectAsset.objects.create( project=project, uploaded_by=alice, file=store_file( alice, 'asset', SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'), project.pk, subdir='gallery', ), ) png = ProjectAsset.objects.create( project=project, uploaded_by=alice, file=store_file( alice, 'asset', SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'), project.pk, subdir='gallery', ), ) self.gate() # Gif gets a cached JPEG thumbnail. resp = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk])) self.assertEqual(resp.status_code, 200) self.assertEqual(resp['Content-Type'].split(';')[0], 'image/jpeg') self.assertGreater(len(b''.join(resp.streaming_content)), 0) # Cached on the second request. resp2 = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk])) self.assertEqual(resp2.status_code, 200) # A plain image is not thumbnailed → 404. resp3 = self.client.get(reverse('library:asset_thumbnail', args=['media', png.pk])) self.assertEqual(resp3.status_code, 404) def test_gallery_grid_uses_thumbnail_for_gif(self): User = get_user_model() alice = User.objects.create_user(username='Alice', password='pw') UserProfile.objects.get_or_create(user=alice) project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice) from library.storage import store_file ProjectAsset.objects.create( project=project, uploaded_by=alice, file=store_file( alice, 'asset', SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'), project.pk, subdir='gallery', ), ) self.gate() resp = self.client.get(reverse('library:project_detail', args=['media'])) self.assertContains(resp, '/thumb/') def _stored_mcmeta(version): from django.core.files.storage import default_storage release = version.files.filter(kind='release').first() with default_storage.open(release.file.stored_path, 'rb') as f: with zipfile.ZipFile(f) as zf: return json.loads(zf.read('pack.mcmeta').decode('utf-8')) class UGCAnimationIdTests(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 _write_zip(self, data): with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f: f.write(data) return f.name def _upload_version(self, slug, name, content): 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='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) self.assertEqual(resp.status_code, 200) return self.client.post( reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'}, ) def test_inject_merge_and_create(self): from library.zips import inject_animationframework path = self._write_zip(_logical_zip()) try: ok = inject_animationframework(path, {'id': 'alice:project_1', 'name': 'X', 'author': 'Alice', 'version': '1.0', 'description': 'y'}) self.assertTrue(ok) with zipfile.ZipFile(path) as zf: mc = json.loads(zf.read('pack.mcmeta').decode('utf-8')) self.assertEqual(mc['animationframework']['id'], 'alice:project_1') self.assertEqual(mc['pack']['pack_format'], 61) # existing block preserved path2 = self._write_zip(_zip_bytes({'data/x.txt': 'x'})) inject_animationframework(path2, {'id': 'a:b'}) with zipfile.ZipFile(path2) as zf: mc2 = json.loads(zf.read('pack.mcmeta').decode('utf-8')) self.assertEqual(mc2['pack']['pack_format'], 64) self.assertEqual(mc2['pack']['supported_formats'], [64, 81]) self.assertEqual(mc2['pack']['min_format'], 64) self.assertEqual(mc2['pack']['max_format'], 81) self.assertEqual(mc2['animationframework']['id'], 'a:b') finally: os.unlink(path) def test_version_upload_injects_defaults(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) resp = self._upload_version('np', 'pack.zip', _logical_zip()) self.assertRedirects(resp, reverse('library:project_detail', args=['np'])) version = self.project.versions.first() expected = f'alice:project_{self.project.pk}' self.assertEqual(version.animation_id, expected) mc = _stored_mcmeta(version) self.assertEqual(mc['animationframework']['id'], expected) self.assertEqual(mc['animationframework']['name'], 'NoN Pack') # latest API works for the injected id. self.gate() resp = self.client.get(reverse('library:api_packs_latest', args=['alice', f'project_{self.project.pk}'])) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()['version'], '1.0.0') # Download is not truncated: FileIndex size refreshed after injection. release = version.files.first() resp = self.client.get(reverse('library:file_request', args=[release.file.uuid])) served = b''.join(resp.streaming_content) self.assertEqual(int(resp['Content-Length']), release.file.size) self.assertEqual(len(served), release.file.size) with zipfile.ZipFile(io.BytesIO(served)) as zf: self.assertIsNone(zf.testzip()) def test_version_upload_establishes_from_pack(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) self._upload_version('np', 'pack.zip', _animation_pack_zip()) version = self.project.versions.first() self.assertEqual(version.animation_id, 'jakebreath:testpack') def test_version_upload_reuses_project_id(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) self._upload_version('np', 'pack.zip', _animation_pack_zip()) # establishes jakebreath:testpack self._upload_version('np', 'pack2.zip', _logical_zip()) # no id → reuse versions = list(self.project.versions.order_by('created_at')) self.assertEqual(versions[-1].animation_id, 'jakebreath:testpack') def test_different_id_requires_confirm(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) self._upload_version('np', 'pack.zip', _animation_pack_zip()) # jakebreath:testpack other = _zip_bytes({ 'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': {'id': 'other:pack'}}), 'data/other/afw_animdefs/x.json': '{}', }) # Without confirmation → form error, no new version. self.gate() self.client.login(username='Alice', password='pw') self.client.post(reverse('library:api_upload_temp'), {'kind': 'version', 'file': SimpleUploadedFile('other.zip', other, content_type='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') resp = self.client.post(reverse('library:version_upload', args=['np']), {'version_name': '2.0.0'}) self.assertEqual(resp.status_code, 200) self.assertEqual(self.project.versions.count(), 1) # With confirmation → accepted. resp = self.client.post(reverse('library:version_upload', args=['np']), {'version_name': '2.0.0', 'confirm_replace_identity': '1'}) self.assertRedirects(resp, reverse('library:project_detail', args=['np'])) self.assertEqual(self.project.versions.count(), 2) self.assertEqual(self.project.versions.first().animation_id, 'other:pack') def test_non_non_pack_not_injected(self): self.project = Project.objects.create( slug='mod', title='A Mod', category='mod', owner=self.alice, ) self._upload_version('mod', 'pack.zip', _logical_zip()) version = self.project.versions.first() self.assertEqual(version.animation_id, '') self.assertNotIn('animationframework', _stored_mcmeta(version)) def test_regenerate_metadata(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) self._upload_version('np', 'pack.zip', _logical_zip()) version = self.project.versions.first() self.assertTrue(version.animation_id) # auto-injected at upload self.gate() self.client.login(username='Alice', password='pw') resp = self.client.post(reverse('library:regenerate_metadata', args=['np']), {'af_namespace': 'foo', 'af_pack_id': 'bar'}) self.assertRedirects(resp, reverse('library:project_edit', args=['np'])) version.refresh_from_db() self.assertEqual(version.animation_id, 'foo:bar') mc = _stored_mcmeta(version) self.assertEqual(mc['animationframework']['id'], 'foo:bar') resp = self.client.get(reverse('library:api_packs_latest', args=['foo', 'bar'])) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()['version'], '1.0.0') # Regenerated download is intact (FileIndex size refreshed). release = version.files.first() resp = self.client.get(reverse('library:file_request', args=[release.file.uuid])) served = b''.join(resp.streaming_content) self.assertEqual(int(resp['Content-Length']), release.file.size) self.assertEqual(len(served), release.file.size) with zipfile.ZipFile(io.BytesIO(served)) as zf: self.assertIsNone(zf.testzip()) def test_regenerate_validates_id(self): self.project = Project.objects.create( slug='np', title='NoN Pack', category='non_pack', owner=self.alice, ) self._upload_version('np', 'pack.zip', _logical_zip()) before = self.project.versions.first().animation_id self.gate() self.client.login(username='Alice', password='pw') resp = self.client.post(reverse('library:regenerate_metadata', args=['np']), {'af_namespace': 'bad id!', 'af_pack_id': 'bar'}) self.assertRedirects(resp, reverse('library:project_edit', args=['np'])) self.project.versions.first().refresh_from_db() self.assertEqual(self.project.versions.first().animation_id, before) def test_create_injects_defaults(self): self.gate() self.client.login(username='Alice', password='pw') self.client.post(reverse('library:api_upload_temp'), {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') resp = self.client.post(reverse('library:project_create'), { 'title': 'New Pack', 'category': 'non_pack', 'version_name': '1.0.0', }) self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack'])) project = Project.objects.get(slug='new-pack') version = project.versions.first() self.assertEqual(version.animation_id, f'alice:project_{project.pk}')