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, draft_uuid=None): data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)} if draft_uuid is not None: data['draft_uuid'] = str(draft_uuid) return self.client.post( reverse('library:api_upload_temp'), data, 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') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid) self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) 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', args=[uuid]), data) def test_create_requires_login(self): self.gate() resp = self.client.get(reverse('library:project_create_new')) 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') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] resp = self.client.post(reverse('library:project_create', args=[uuid]), { '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') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png', draft_uuid=uuid) self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png', draft_uuid=uuid) self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png', draft_uuid=uuid) media = TempUpload.objects.filter(kind='media', draft_uuid=uuid).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', args=[uuid]), { '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 UGCProjectDraftTests(UGCMediaTestCase, UGCGatedTestCase): """Multiple per-user drafts, keyed by UUID, with upload scoping.""" 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 _auth(self, user): self.client.force_login(user) self.gate() def _upload_temp(self, kind, name, content, content_type, draft_uuid=None): data = {'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)} if draft_uuid is not None: data['draft_uuid'] = str(draft_uuid) return self.client.post( reverse('library:api_upload_temp'), data, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) def _new_draft(self, user=None): self._auth(user or self.alice) resp = self.client.get(reverse('library:project_create_new')) uuid = resp.url.rstrip('/').split('/')[-1] return uuid, resp def test_new_project_creates_draft_and_redirects(self): uuid, resp = self._new_draft() self.assertRedirects( resp, reverse('library:project_create', args=[uuid]), fetch_redirect_response=False, ) self.assertTrue(ProjectDraft.objects.filter(uuid=uuid, user=self.alice).exists()) def test_my_projects_lists_projects_and_drafts(self): project = Project.objects.create( slug='mine', title='Mine', category='mod', owner=self.alice, ) uuid, _ = self._new_draft() self._auth(self.alice) resp = self.client.get(reverse('library:my_projects')) self.assertContains(resp, 'Mine') self.assertContains(resp, 'Untitled draft') self.assertEqual(Project.objects.get(pk=project.pk).title, 'Mine') def test_wrong_user_cannot_access_draft(self): uuid, _ = self._new_draft() self._auth(self.bob) resp = self.client.get(reverse('library:project_create', args=[uuid])) self.assertEqual(resp.status_code, 302) self.assertEqual(resp.url, reverse('library:my_projects')) # API save also refused. resp = self.client.post( reverse('library:api_draft'), data=json.dumps({'draft': uuid, 'data': {'title': 'hack'}}), content_type='application/json', ) self.assertEqual(resp.status_code, 404) draft = ProjectDraft.objects.get(uuid=uuid) self.assertNotEqual(draft.data.get('title'), 'hack') def test_autosave_scopes_to_its_draft(self): uuid_a, _ = self._new_draft() uuid_b, _ = self._new_draft() self._auth(self.alice) self.client.post( reverse('library:api_draft'), data=json.dumps({'draft': uuid_a, 'data': {'title': 'Draft A'}}), content_type='application/json', ) self.assertEqual(ProjectDraft.objects.get(uuid=uuid_a).data.get('title'), 'Draft A') self.assertEqual(ProjectDraft.objects.get(uuid=uuid_b).data.get('title'), None) def test_uploads_scoped_per_draft(self): uuid_a, _ = self._new_draft() uuid_b, _ = self._new_draft() self._auth(self.alice) self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid_a) self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a).count(), 1) self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b).count(), 0) self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_a, status='pending').count(), 1) self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid_b, status='pending').count(), 0) def test_submit_consumes_draft_uploads_and_deletes_draft(self): uuid, _ = self._new_draft() self._auth(self.alice) self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) resp = self.client.post( reverse('library:project_create', args=[uuid]), { 'title': 'From Draft', 'summary': 's', 'description': 'd', 'category': 'mod', 'tags': 'general:test', 'version_name': '1.0.0', 'changelog': 'c', }, ) self.assertRedirects(resp, reverse('library:project_detail', args=['from-draft'])) self.assertTrue(Project.objects.filter(title='From Draft', owner=self.alice).exists()) self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists()) self.assertEqual(TempUpload.objects.filter(draft_uuid=uuid, status='used').count(), 1) def test_draft_delete_purges_uploads(self): uuid, _ = self._new_draft() self._auth(self.alice) self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip', draft_uuid=uuid) resp = self.client.post(reverse('library:project_draft_delete', args=[uuid])) self.assertEqual(resp.status_code, 302) self.assertFalse(ProjectDraft.objects.filter(uuid=uuid).exists()) self.assertFalse(TempUpload.objects.filter(draft_uuid=uuid).exists()) 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') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': { 'title': 'Draft Title', 'summary': 'Draft summary', 'category': 'mod', 'description': 'Hello draft', 'tags': 'species:dragon', 'version_name': '2.0.0', }}) draft = ProjectDraft.objects.get(uuid=uuid, user=self.alice) self.assertEqual(draft.data['title'], 'Draft Title') resp = self.client.get(reverse('library:api_draft'), {'draft': uuid}) 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') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self._post_json(reverse('library:api_draft'), {'draft': uuid, 'data': {'title': 'My Draft Pack'}}) resp = self.client.get(reverse('library:project_create', args=[uuid])) self.assertContains(resp, 'value="My Draft Pack"') def test_create_clears_draft(self): self.gate() self.client.login(username='Alice', password='pw') uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1] self._post_json(reverse('library:api_draft'), {'draft': uuid, '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'), 'draft_uuid': uuid}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) resp = self.client.post(reverse('library:project_create', args=[uuid]), { '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(uuid=uuid).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_guide_rewrites_links_to_separately_uploaded_files(self): self.project = Project.objects.create( slug='guide', title='Guide', category='guide', owner=self.alice, ) self._upload_version( 'README.md', b'# Title\n\n![logo](images/logo.png)\n', content_type='text/markdown', ) self._upload_version('images/logo.png', b'PNGDATA', content_type='image/png') self._upload_version_form() version = self.project.versions.first() md_vf = version.files.get(kind='markdown') 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, '