mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**Replace Flake8 with Ruff, refactor type hints, and enhance archive processing logic**
- Removed `flake8` and `flake8-annotations` from dependencies, replaced by `ruff` for linting. - Introduced cleaner type hinting and modern Python annotations (PEP 604 style) across multiple modules. - Moved archive processing to a dedicated helper (`comic.archives`) for improved maintainability. - Updated and simplified logic for archive file retrieval, directory cleaning, and user access validation. - Improved error handling and added support for user classification restrictions in API responses.
This commit is contained in:
1 parent
1148f4072b
commit
a7e9bd0743
16 files changed
+553
-4
No files matched your search
@@ -96,3 +96,10 @@ node_modules
|
|||||||
localhost-key.pem
|
localhost-key.pem
|
||||||
localhost.pem
|
localhost.pem
|
||||||
webpack-stats.json
|
webpack-stats.json
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
test_comics/
|
||||||
|
test_media/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
@@ -247,6 +247,10 @@ SIMPLE_JWT = {
|
|||||||
'ISSUER': 'cbwebreader',
|
'ISSUER': 'cbwebreader',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Renderer formats no longer include a leading dot, so the URL pattern in
|
||||||
|
# cbreader/urls.py matches the dot separately. See cbreader/urls.py.
|
||||||
|
SWAGGER_USE_COMPAT_RENDERERS = False
|
||||||
|
|
||||||
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Settings used by the test suite.
|
||||||
|
|
||||||
|
In-memory SQLite so the tests do not need a running Postgres, and a scratch comic
|
||||||
|
volume that the fixtures fill with generated archives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cbreader.settings.base import * # noqa: F403
|
||||||
|
from cbreader.settings.base import BASE_DIR
|
||||||
|
|
||||||
|
SECRET_KEY = 'test-secret-key'
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': ':memory:',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
COMIC_BOOK_VOLUME = Path(os.environ.get('COMIC_TEST_VOLUME', BASE_DIR / 'test_comics'))
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'test_media'
|
||||||
+1
-1
@@ -58,7 +58,7 @@ router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
|
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
|
||||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
re_path(r'^swagger\.(?P<format>json|yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||||
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||||
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Shared fixtures for the test suite."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
from comic.tests.factories import make_cbz
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def comic_volume(settings, tmp_path) -> Path:
|
||||||
|
"""Point COMIC_BOOK_VOLUME at an empty temporary directory."""
|
||||||
|
settings.COMIC_BOOK_VOLUME = tmp_path
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_user(db) -> models.User:
|
||||||
|
return models.User.objects.create_superuser('admin', 'admin@example.com', 'password12345')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def reader_user(db) -> models.User:
|
||||||
|
"""A normal user, whose reading allowance defaults to the highest rating."""
|
||||||
|
return models.User.objects.create_user('reader', 'reader@example.com', 'password12345')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def restricted_user(db) -> models.User:
|
||||||
|
"""A user whose allowance is G, so anything rated above G is blocked."""
|
||||||
|
user = models.User.objects.create_user('restricted', 'restricted@example.com', 'password12345')
|
||||||
|
models.UserMisc.objects.create(user=user, allowed_to_read=models.Directory.Classification.C_G)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def rated_directory(db) -> models.Directory:
|
||||||
|
return models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def book(db, comic_volume: Path, rated_directory: models.Directory) -> models.ComicBook:
|
||||||
|
"""A book with a three page archive inside an 18 rated directory."""
|
||||||
|
directory_path = comic_volume / rated_directory.name
|
||||||
|
directory_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
make_cbz(directory_path / 'issue.cbz', pages=3)
|
||||||
|
return models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=3)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Helpers for creating comic files on disk."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def make_cbz(path: Path, pages: int = 3, metadata: bool = True) -> Path:
|
||||||
|
"""Write a .cbz containing PNG pages and, optionally, a ComicInfo.xml."""
|
||||||
|
with zipfile.ZipFile(path, 'w') as archive:
|
||||||
|
for page in range(pages):
|
||||||
|
image = Image.new('RGB', (10, 10))
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format='PNG')
|
||||||
|
archive.writestr(f'page{page:02}.png', buffer.getvalue())
|
||||||
|
if metadata:
|
||||||
|
archive.writestr('ComicInfo.xml', '<ComicInfo><Title>Test</Title></ComicInfo>')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def make_pdf(path: Path, pages: int = 2) -> Path:
|
||||||
|
"""Write a PDF with the given number of pages."""
|
||||||
|
document = pymupdf.open()
|
||||||
|
for _ in range(pages):
|
||||||
|
document.new_page(width=10, height=10)
|
||||||
|
document.save(path)
|
||||||
|
document.close()
|
||||||
|
return path
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for the API behaviour of the comic viewsets."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
|
||||||
|
UNKNOWN = '00000000-0000-0000-0000-000000000000'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unknown_directory_returns_404(client, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
assert client.get(f'/api/browse/{UNKNOWN}/').status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unknown_selector_returns_404_in_breadcrumbs(client, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
assert client.get(f'/api/browse/{UNKNOWN}/breadcrumbs/').status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unknown_book_returns_404_for_an_image(client, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
assert client.get(f'/api/read/{UNKNOWN}/image/1/').status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_image_requires_authentication(client, book) -> None:
|
||||||
|
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_image_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
|
||||||
|
client.force_login(restricted_user)
|
||||||
|
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_image_returns_the_page_for_an_allowed_user(client, book, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
response = client.get(f'/api/read/{book.selector}/image/1/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response['Content-Type'] == 'image/png'
|
||||||
|
assert b''.join(response.streaming_content)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_read_view_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
|
||||||
|
client.force_login(restricted_user)
|
||||||
|
assert client.get(f'/api/read/{book.selector}/').status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_read_view_returns_progress_for_an_allowed_user(client, book, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
response = client.get(f'/api/read/{book.selector}/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['pages'] == 3
|
||||||
|
assert response.json()['title'] == 'issue.cbz'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_set_page_creates_the_status_and_marks_the_book_finished(client, book, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
response = client.put(
|
||||||
|
f'/api/read/{book.selector}/set_page/',
|
||||||
|
data={'page': book.page_count - 1},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||||
|
assert status.last_read_page == 2
|
||||||
|
assert status.finished
|
||||||
|
assert not status.unread
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_set_page_leaves_the_book_unfinished_before_the_last_page(client, book, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
response = client.put(
|
||||||
|
f'/api/read/{book.selector}/set_page/',
|
||||||
|
data={'page': 1},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||||
|
assert not status.finished
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_mark_read_records_the_last_page(client, book, reader_user) -> None:
|
||||||
|
client.force_login(reader_user)
|
||||||
|
response = client.put(
|
||||||
|
'/api/action/mark_read/',
|
||||||
|
data={'selectors': [str(book.selector)]},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||||
|
assert status.last_read_page == book.page_count - 1
|
||||||
|
assert status.finished
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_directory_update_cascades_to_children(client, admin_user) -> None:
|
||||||
|
client.force_login(admin_user)
|
||||||
|
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
|
||||||
|
child = models.Directory.objects.create(name='Child', parent=parent)
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f'/api/directory/{parent.selector}/',
|
||||||
|
data={'classification': models.Directory.Classification.C_PG},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert sorted(item['classification'] for item in response.json()) == [1, 1]
|
||||||
|
child.refresh_from_db()
|
||||||
|
assert child.classification == models.Directory.Classification.C_PG
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_directory_partial_update_does_not_cascade(client, admin_user) -> None:
|
||||||
|
client.force_login(admin_user)
|
||||||
|
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
|
||||||
|
child = models.Directory.objects.create(name='Child', parent=parent)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f'/api/directory/{child.selector}/',
|
||||||
|
data={'classification': models.Directory.Classification.C_15},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {'selector': str(child.selector), 'classification': 3}
|
||||||
|
parent.refresh_from_db()
|
||||||
|
assert parent.classification == models.Directory.Classification.C_18
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_directory_partial_update_rejects_an_unknown_classification(client, admin_user) -> None:
|
||||||
|
client.force_login(admin_user)
|
||||||
|
directory = models.Directory.objects.create(name='Parent')
|
||||||
|
response = client.patch(
|
||||||
|
f'/api/directory/{directory.selector}/',
|
||||||
|
data={'classification': 9},
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests for the shared archive page listing in comic.archives."""
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from comic.archives import get_archive_files
|
||||||
|
from comic.tests.factories import make_cbz
|
||||||
|
|
||||||
|
|
||||||
|
def names(archive: zipfile.ZipFile) -> list[str]:
|
||||||
|
return [entry.file_name for entry in get_archive_files(archive)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_entries_are_not_pages(tmp_path: Path) -> None:
|
||||||
|
path = make_cbz(tmp_path / 'issue.cbz', pages=2, metadata=True)
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
assert names(archive) == ['page00.png', 'page01.png']
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_are_returned_in_page_order(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / 'issue.cbz'
|
||||||
|
with zipfile.ZipFile(path, 'w') as archive:
|
||||||
|
archive.writestr('page10.png', b'x')
|
||||||
|
archive.writestr('page02.png', b'x')
|
||||||
|
archive.writestr('page01.png', b'x')
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
assert names(archive) == ['page01.png', 'page02.png', 'page10.png']
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_entries_are_ignored(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / 'issue.cbz'
|
||||||
|
with zipfile.ZipFile(path, 'w') as archive:
|
||||||
|
archive.writestr('cover.png', b'x')
|
||||||
|
archive.writestr('images/', b'')
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
assert names(archive) == ['cover.png']
|
||||||
|
|
||||||
|
|
||||||
|
def test_mime_type_is_guessed_from_the_file_name(tmp_path: Path) -> None:
|
||||||
|
path = make_cbz(tmp_path / 'issue.cbz', pages=1, metadata=False)
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
assert get_archive_files(archive)[0].mime_type == 'image/png'
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests for the comic models."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
from comic.tests.factories import make_cbz, make_pdf
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_page_count_excludes_metadata(comic_volume: Path) -> None:
|
||||||
|
make_cbz(comic_volume / 'issue.cbz', pages=3, metadata=True)
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||||
|
assert book.get_page_count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_pdf_page_count(comic_volume: Path) -> None:
|
||||||
|
make_pdf(comic_volume / 'issue.pdf', pages=2)
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.pdf')
|
||||||
|
assert book.get_page_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unreadable_archive_is_indexed_without_pages(comic_volume: Path) -> None:
|
||||||
|
path = comic_volume / 'broken.cbz'
|
||||||
|
path.write_bytes(b'this is not an archive')
|
||||||
|
result = models.ComicBook.process_comic_book(path)
|
||||||
|
assert isinstance(result, Path)
|
||||||
|
assert models.ComicBook.objects.get(file_name='broken.cbz').page_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_archive_path_uses_the_directory_tree(comic_volume: Path) -> None:
|
||||||
|
parent = models.Directory.objects.create(name='Series')
|
||||||
|
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
|
||||||
|
assert book.get_archive_path == comic_volume / 'Series' / 'Volume 1' / 'issue.cbz'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_book_without_a_directory_uses_the_volume_root(comic_volume: Path) -> None:
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||||
|
assert book.get_archive_path == comic_volume / 'issue.cbz'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_directory_path_is_built_from_its_parents() -> None:
|
||||||
|
parent = models.Directory.objects.create(name='Series')
|
||||||
|
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||||
|
assert child.get_path() == Path('Series') / 'Volume 1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_image_returns_the_requested_page(comic_volume: Path) -> None:
|
||||||
|
make_cbz(comic_volume / 'issue.cbz', pages=2, metadata=False)
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||||
|
stream, mime_type = book.get_image(1)
|
||||||
|
assert mime_type == 'image/png'
|
||||||
|
assert stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_classification_comparison() -> None:
|
||||||
|
directory = models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
|
||||||
|
misc = models.UserMisc.objects.create(
|
||||||
|
user=models.User.objects.create_user('pg', 'pg@example.com', 'password12345'),
|
||||||
|
allowed_to_read=models.Directory.Classification.C_PG,
|
||||||
|
)
|
||||||
|
assert directory.classification > misc.allowed_to_read
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Tests for the filesystem reconciliation in comic.processing."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
from comic.processing import clean_directories, clean_files, generate_directory
|
||||||
|
from comic.tests.factories import make_cbz
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_clean_directories_adds_new_and_removes_stale(comic_volume, reader_user) -> None:
|
||||||
|
parent = models.Directory.objects.create(name='Series')
|
||||||
|
models.Directory.objects.create(name='Gone', parent=parent)
|
||||||
|
(comic_volume / 'Series' / 'New').mkdir(parents=True)
|
||||||
|
|
||||||
|
clean_directories(models.Directory.objects.filter(parent=parent), comic_volume / 'Series', parent)
|
||||||
|
|
||||||
|
names = set(models.Directory.objects.filter(parent=parent).values_list('name', flat=True))
|
||||||
|
assert names == {'New'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_clean_files_ignores_unsupported_extensions(comic_volume, reader_user) -> None:
|
||||||
|
directory = models.Directory.objects.create(name='Series')
|
||||||
|
target = comic_volume / 'Series'
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
make_cbz(target / 'issue.cbz', pages=2)
|
||||||
|
(target / 'notes.txt').write_text('not a comic')
|
||||||
|
|
||||||
|
clean_files(models.ComicBook.objects.filter(directory=directory), reader_user, target, directory)
|
||||||
|
|
||||||
|
books = list(models.ComicBook.objects.filter(directory=directory))
|
||||||
|
assert [book.file_name for book in books] == ['issue.cbz']
|
||||||
|
assert books[0].page_count == 2
|
||||||
|
assert models.ComicStatus.objects.filter(user=reader_user).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_generate_directory_creates_missing_statuses(comic_volume, reader_user) -> None:
|
||||||
|
directory = models.Directory.objects.create(name='Series')
|
||||||
|
target = comic_volume / 'Series'
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
make_cbz(target / 'issue.cbz', pages=1)
|
||||||
|
|
||||||
|
entries = generate_directory(reader_user, directory)
|
||||||
|
|
||||||
|
assert [entry.title for entry in entries] == ['issue.cbz']
|
||||||
|
assert models.ComicStatus.objects.filter(user=reader_user, comic=entries[0]).exists()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Tests for the scan_comics management command."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
from comic.tests.factories import make_cbz
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> None:
|
||||||
|
nested = comic_volume / 'Series' / 'Volume 1'
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
make_cbz(nested / 'issue.cbz', pages=2)
|
||||||
|
|
||||||
|
call_command('scan_comics')
|
||||||
|
|
||||||
|
directory = models.Directory.objects.get(name='Volume 1', parent__name='Series')
|
||||||
|
book = models.ComicBook.objects.get(file_name='issue.cbz', directory=directory)
|
||||||
|
assert book.page_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_scan_without_a_user_stops_before_reading_the_volume(comic_volume: Path) -> None:
|
||||||
|
make_cbz(comic_volume / 'issue.cbz', pages=1)
|
||||||
|
|
||||||
|
call_command('scan_comics')
|
||||||
|
|
||||||
|
assert models.ComicBook.objects.count() == 0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Tests for URL resolution that depends on settings."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', ['/swagger.json', '/swagger.yaml', '/swagger/', '/redoc/'])
|
||||||
|
def test_schema_urls_resolve(client: Client, url: str) -> None:
|
||||||
|
"""The schema URLs must match the renderer formats, which no longer include a dot.
|
||||||
|
|
||||||
|
cbreader/urls.py captures the format after the dot because
|
||||||
|
SWAGGER_USE_COMPAT_RENDERERS is False.
|
||||||
|
"""
|
||||||
|
assert client.get(url).status_code == 200
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Tests for breadcrumb generation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from comic import models
|
||||||
|
from comic.util import generate_breadcrumbs_from_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_breadcrumbs_run_from_home_to_the_book() -> None:
|
||||||
|
parent = models.Directory.objects.create(name='Series')
|
||||||
|
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||||
|
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
|
||||||
|
assert [crumb.name for crumb in generate_breadcrumbs_from_path(child, book)] == [
|
||||||
|
'Home', 'Series', 'Volume 1', 'issue.cbz',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_breadcrumbs_without_a_book_stop_at_the_directory() -> None:
|
||||||
|
directory = models.Directory.objects.create(name='Series')
|
||||||
|
assert [crumb.name for crumb in generate_breadcrumbs_from_path(directory)] == ['Home', 'Series']
|
||||||
@@ -45,6 +45,8 @@ dev = [
|
|||||||
"pylint>=4.0.5",
|
"pylint>=4.0.5",
|
||||||
"pylint-django>=2.7.0",
|
"pylint-django>=2.7.0",
|
||||||
"pyopenssl>=26.2.0",
|
"pyopenssl>=26.2.0",
|
||||||
|
"pytest>=8.4",
|
||||||
|
"pytest-django>=4.8",
|
||||||
"ruff>=0.17.0",
|
"ruff>=0.17.0",
|
||||||
"werkzeug>=3.1.8",
|
"werkzeug>=3.1.8",
|
||||||
]
|
]
|
||||||
@@ -66,3 +68,11 @@ ignore = ["ANN001", "ANN002", "ANN003"]
|
|||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
combine-as-imports = true
|
combine-as-imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "cbreader.settings.test"
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
filterwarnings = [
|
||||||
|
# drf_yasg still uses jsonschema.RefResolver, which is deprecated upstream.
|
||||||
|
"ignore::DeprecationWarning:swagger_spec_validator",
|
||||||
|
]
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
pep8maxlinelength = 119
|
|
||||||
python_files = tests.py test_*.py *_tests.py
|
|
||||||
@@ -135,6 +135,8 @@ dev = [
|
|||||||
{ name = "pylint" },
|
{ name = "pylint" },
|
||||||
{ name = "pylint-django" },
|
{ name = "pylint-django" },
|
||||||
{ name = "pyopenssl" },
|
{ name = "pyopenssl" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-django" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "werkzeug" },
|
{ name = "werkzeug" },
|
||||||
]
|
]
|
||||||
@@ -178,6 +180,8 @@ dev = [
|
|||||||
{ name = "pylint", specifier = ">=4.0.5" },
|
{ name = "pylint", specifier = ">=4.0.5" },
|
||||||
{ name = "pylint-django", specifier = ">=2.7.0" },
|
{ name = "pylint-django", specifier = ">=2.7.0" },
|
||||||
{ name = "pyopenssl", specifier = ">=26.2.0" },
|
{ name = "pyopenssl", specifier = ">=26.2.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.4" },
|
||||||
|
{ name = "pytest-django", specifier = ">=4.8" },
|
||||||
{ name = "ruff", specifier = ">=0.17.0" },
|
{ name = "ruff", specifier = ">=0.17.0" },
|
||||||
{ name = "werkzeug", specifier = ">=3.1.8" },
|
{ name = "werkzeug", specifier = ">=3.1.8" },
|
||||||
]
|
]
|
||||||
@@ -650,6 +654,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipython"
|
name = "ipython"
|
||||||
version = "9.13.0"
|
version = "9.13.0"
|
||||||
@@ -1004,6 +1017,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -1201,6 +1223,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-django"
|
||||||
|
version = "4.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
|
|||||||
Reference in new issue
Block a user