Files
NoN-Site/nonpacks/library/tests.py
T
2026-08-05 17:31:15 -05:00

1984 lines
88 KiB
Python

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_<pk>/).
self.assertEqual(project.versions.count(), 1)
self.assertIsNotNone(project.thumbnail)
self.assertTrue(project.thumbnail.stored_path.startswith('project_'))
self.assertTrue(project.versions.first().files.first().file.stored_path.startswith('project_'))
# Temp uploads marked used.
self.assertEqual(
TempUpload.objects.filter(user=self.alice, status='used').count(), 2
)
# Tags auto-created and linked.
names = set(TagList.objects.filter(project=project).values_list('tag__name', flat=True))
self.assertIn('furry', names)
self.assertIn('threesome', names)
self.assertIn('alice', names) # creator tag auto-synced
# Creator tag exists with category creator.
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
def test_create_requires_version_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
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, '<h1>Hello</h1>', html=True)
def test_detail_unknown_404(self):
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['nope']))
self.assertEqual(resp.status_code, 404)
class 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())
def test_skin_individual_png_builds_manifest(self):
uuid, _ = self._new_draft()
self._auth(self.alice)
self._upload_temp('version', 'skin.png', make_png_bytes(size=(64, 64)), 'image/png', draft_uuid=uuid)
self.client.post(reverse('library:project_create', args=[uuid]), {
'title': 'My Skin', 'category': 'skin', 'version_name': '1.0.0',
})
version = Project.objects.get(slug='my-skin').versions.first()
manifest = version.skins_manifest
self.assertIn('skins', manifest)
self.assertEqual(manifest['skins'][0]['width'], 64)
self.assertEqual(manifest['skins'][0]['height'], 64)
self.assertTrue(manifest['skins'][0]['url'].startswith('/api/files/'))
def test_skin_rejects_non_skin_sized_png(self):
uuid, _ = self._new_draft()
self._auth(self.alice)
self._upload_temp('version', 'small.png', make_png_bytes(size=(8, 8)), 'image/png', draft_uuid=uuid)
self.client.post(reverse('library:project_create', args=[uuid]), {
'title': 'Bad Skin', 'category': 'skin', 'version_name': '1.0.0',
})
version = Project.objects.get(slug='bad-skin').versions.first()
self.assertEqual(version.skins_manifest, {})
class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
self.project.sync_creator_tags()
def test_upload_version_owner(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0', 'changelog': 'More stuff.'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(self.project.versions.count(), 1)
version = self.project.versions.first()
self.assertEqual(version.version_name, '2.0.0')
self.assertEqual(version.files.count(), 1)
# Adopted file moved under project_<pk>/versions/ and temp marked used.
self.assertTrue(version.files.first().file.stored_path.startswith('project_'))
self.assertTrue(
TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists()
)
def test_upload_version_multiple_files(self):
self.gate()
self.client.login(username='Alice', password='pw')
for name in ('skin1.png', 'skin2.png'):
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile(name, make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '3.0.0'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
version = self.project.versions.first()
self.assertEqual(version.version_name, '3.0.0')
# One version, several files, PNGs classified as png kind.
self.assertEqual(version.files.count(), 2)
self.assertEqual(
set(version.files.values_list('kind', flat=True)), {'png'}
)
self.assertEqual(
set(version.files.values_list('file__original_filename', flat=True)),
{'skin1.png', 'skin2.png'},
)
def test_upload_version_requires_pending_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(self.project.versions.count(), 0)
def test_upload_version_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(self.project.versions.count(), 0)
def test_contributor_can_upload(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
self.gate()
self.client.login(username='Bob', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{'version_name': '2.0.0'},
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(self.project.versions.count(), 1)
def test_download_bumps_counter_and_serves(self):
from library.storage import store_file
index = store_file(
self.alice, 'version',
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
self.project.pk, subdir='versions',
)
version = Version.objects.create(project=self.project, version_name='1.0.0')
VersionFile.objects.create(version=version, file=index, kind='release')
self.gate()
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
# Follow redirect → file served.
resp = self.client.get(resp.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())
def test_per_file_download(self):
from library.storage import store_file
index = store_file(
self.alice, 'version',
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
self.project.pk, subdir='versions',
)
version = Version.objects.create(project=self.project, version_name='1.0.0')
vf = VersionFile.objects.create(version=version, file=index, kind='release')
self.gate()
resp = self.client.get(
reverse('library:version_file_download', args=['test-pack', version.pk, index.uuid])
)
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
self.assertEqual(resp.url, reverse('library:file_request', args=[index.uuid]) + '?download=1')
# The parent version_download route still works (first file).
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
self.assertTrue(vf)
class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
)
def test_upload_image_asset_with_caption(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
temp = TempUpload.objects.get(kind='media')
resp = self.client.post(
reverse('library:asset_upload', args=['test-pack']),
{f'caption_{temp.uuid}': 'Preview'},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
asset = ProjectAsset.objects.get()
self.assertEqual(asset.caption, 'Preview')
self.assertEqual(asset.uploaded_by, self.alice)
self.assertFalse(asset.is_video)
def test_asset_captions_are_per_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(color='red'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
temps = list(TempUpload.objects.filter(kind='media').order_by('created_at'))
self.client.post(reverse('library:asset_upload', args=['test-pack']), {
f'caption_{temps[0].uuid}': 'First',
f'caption_{temps[1].uuid}': 'Second',
})
captions = dict(ProjectAsset.objects.values_list('file__original_filename', 'caption'))
self.assertEqual(captions, {'a.png': 'First', 'b.png': 'Second'})
def test_reject_non_media_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('evil.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_gallery_upload_requires_pending_media(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_removes_index(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(ProjectAsset.objects.count(), 0)
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
def test_asset_delete_ajax_returns_json(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:asset_delete', args=['test-pack', asset.pk]),
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, {'success': True})
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_requires_post(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 405)
self.assertEqual(ProjectAsset.objects.count(), 1)
def test_asset_delete_non_contributor_denied(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectAsset.objects.count(), 1)
class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _post_json(self, url, payload):
import json
return self.client.post(url, json.dumps(payload), content_type='application/json')
def test_upload_api_requires_auth(self):
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 401)
def test_temp_upload_media_accepts_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()['uuid'])
upload = TempUpload.objects.get()
self.assertEqual(upload.kind, 'media')
self.assertEqual(upload.status, 'pending')
self.assertTrue(upload.file.stored_path.startswith('uploads/user_'))
def test_temp_upload_thumbnail_rejects_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(TempUpload.objects.count(), 0)
def test_thumbnail_single_slot_version_multi(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v1.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
# Thumbnail is a single slot; version accepts multiple files.
self.assertEqual(
TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
)
self.assertEqual(
TempUpload.objects.filter(kind='version', status='pending').count(), 2
)
def test_temp_upload_delete(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(TempUpload.objects.count(), 0)
def test_delete_others_upload_forbidden(self):
User = get_user_model()
bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=bob)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
self.client.logout()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(TempUpload.objects.count(), 1)
def test_draft_save_and_restore(self):
self.gate()
self.client.login(username='Alice', password='pw')
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, '<h1>Title</h1>', html=True)
# Detail page exposes the guide switcher.
resp = self.client.get(reverse('library:project_detail', args=['guide']))
self.assertContains(resp, 'guide-switcher')
self.assertContains(resp, 'README.md')
def test_guide_doc_rejects_non_markdown(self):
self.project = Project.objects.create(
slug='guide', title='Guide', category='guide', owner=self.alice,
)
self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'}))
self._upload_version_form()
version = self.project.versions.first()
vf = version.files.get(kind='release')
self.gate()
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
self.assertEqual(resp.status_code, 404)
def test_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, '<img src="/api/files/', count=1)
self.assertNotContains(resp, 'images/logo.png')
def test_guide_zip_docs_listed_and_links_rewritten(self):
self.project = Project.objects.create(
slug='guide', title='Guide', category='guide', owner=self.alice,
)
self._upload_version('guide.zip', _zip_bytes({
'README.md': '# Read\n\n![pic](images/a.png)\n\nNext: [part two](part2.md)\n',
'images/a.png': b'PNG2',
'part2.md': '# Two\n',
}))
self._upload_version_form()
version = self.project.versions.first()
self.gate()
# Both .md members appear in the guide switcher.
resp = self.client.get(reverse('library:project_detail', args=['guide']))
self.assertContains(resp, 'README.md')
self.assertContains(resp, 'part2.md')
# Zip doc renders with image + cross-doc link rewritten.
resp = self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'README.md']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, '<img src="/packs/guide/versions/%d/asset/images/a.png"' % version.pk, count=1)
self.assertContains(resp, 'href="/packs/guide/guide/zip/%d/part2.md"' % version.pk, count=1)
def test_guide_zip_doc_rejects_non_guide_or_missing(self):
self.project = Project.objects.create(
slug='guide', title='Guide', category='guide', owner=self.alice,
)
self._upload_version('guide.zip', _zip_bytes({'README.md': '# Read\n'}))
self._upload_version_form()
version = self.project.versions.first()
self.gate()
self.assertEqual(
self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'missing.md'])).status_code,
404,
)
self.assertEqual(
self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'data.json'])).status_code,
404,
)
def test_guide_links_not_rewritten_outside_guide_category(self):
self.project = Project.objects.create(
slug='pack', title='Pack', category='non_pack', owner=self.alice,
)
self._upload_version(
'notes.md', b'# Notes\n\n![x](images/x.png)\n', content_type='text/markdown',
)
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=['pack', version.pk, md_vf.file.uuid]))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'src="images/x.png"')
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')
uuid = self.client.get(reverse('library:project_create_new')).url.rstrip('/').split('/')[-1]
self.client.post(reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip'), 'draft_uuid': uuid},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
resp = self.client.post(reverse('library:project_create', args=[uuid]), {
'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}')