# Standard library imports. import os import tempfile from pathlib import Path from unittest.mock import patch # External library imports. from PIL import Image # Django imports. from django.test import Client, RequestFactory, TestCase, override_settings from django.contrib.auth.models import User # Project imports. from viewer.views import SORT_LABELS, do_recursive_search, gallery_view class GalleryBaseTests(TestCase): def setUp(self): self.tmp_gallery = tempfile.TemporaryDirectory() self.tmp_thumbs = tempfile.TemporaryDirectory() self.gallery_root = Path(self.tmp_gallery.name) self.thumb_root = Path(self.tmp_thumbs.name) self.settings_override = override_settings( GALLERY_ROOT=self.gallery_root, THUMBNAILS_ROOT=self.thumb_root ) self.settings_override.enable() self.user = User.objects.create_user( "tester", "tester@example.com", "password123" ) self.client = Client() self.client.force_login(self.user) self.factory = RequestFactory() self._build_fixture_tree() def tearDown(self): self.settings_override.disable() self.tmp_gallery.cleanup() self.tmp_thumbs.cleanup() def _create_image(self, image_path, color): image_path.parent.mkdir(parents=True, exist_ok=True) img = Image.new("RGB", (300, 200), color) img.save(image_path) def _set_mtime(self, file_path, timestamp): os.utime(file_path, (timestamp, timestamp)) def _build_fixture_tree(self): self._create_image(self.gallery_root / "alpha.jpg", "red") self._create_image(self.gallery_root / "beta.jpg", "green") self._create_image(self.gallery_root / "gamma.jpg", "blue") self._create_image(self.gallery_root / "match-root.jpg", "yellow") self._set_mtime(self.gallery_root / "alpha.jpg", 1000) self._set_mtime(self.gallery_root / "beta.jpg", 2000) self._set_mtime(self.gallery_root / "gamma.jpg", 3000) self._set_mtime(self.gallery_root / "match-root.jpg", 4000) self.sub_a = self.gallery_root / "sub_a" self.sub_b = self.gallery_root / "sub_b" self.empty_sub = self.gallery_root / "empty_sub" self.nested = self.gallery_root / "nested" self.sub_a.mkdir(parents=True, exist_ok=True) self.sub_b.mkdir(parents=True, exist_ok=True) self.empty_sub.mkdir(parents=True, exist_ok=True) self.nested.mkdir(parents=True, exist_ok=True) self._create_image(self.sub_a / "cover.jpg", "orange") self._create_image(self.sub_a / "same.jpg", "purple") self._create_image(self.sub_b / "same.jpg", "pink") self._create_image(self.nested / "match-nested.jpg", "cyan") self.symlink_supported = hasattr(os, "symlink") self.symlink_dir = self.gallery_root / "symlink_sub" self.symlink_file = self.gallery_root / "symlink_file.jpg" if self.symlink_supported: try: os.symlink(self.sub_a, self.symlink_dir) os.symlink(self.gallery_root / "alpha.jpg", self.symlink_file) except OSError: self.symlink_supported = False class GalleryViewTests(GalleryBaseTests): def test_gallery_rejects_out_of_root_path(self): request = self.factory.get("/gallery/../") request.user = self.user response = gallery_view(request, "../") self.assertEqual(response.status_code, 404) def test_gallery_missing_path_returns_404(self): response = self.client.get("/gallery/does-not-exist/") self.assertEqual(response.status_code, 404) def test_invalid_sort_defaults_to_abc(self): response = self.client.get("/gallery/?sort=invalid") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["sort_key"], "abc") def test_invalid_theme_defaults_to_dark(self): response = self.client.get("/gallery/?theme=invalid") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["theme"], "dark") def test_root_path_context_is_empty_string(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["path"], "") def test_context_has_no_pagination_keys(self): response = self.client.get("/gallery/") context = response.context self.assertNotIn("page", context) self.assertNotIn("pages", context) self.assertNotIn("num_pages", context) self.assertNotIn("prev_page", context) self.assertNotIn("next_page", context) def test_sort_accepts_all_required_keys(self): for sort_key in ["abc", "cba", "old", "new", "recent", "tnecer"]: response = self.client.get("/gallery/?sort=" + sort_key) self.assertEqual(response.status_code, 200) self.assertEqual(response.context["sort_key"], sort_key) def test_sort_labels_match_required_text(self): response = self.client.get("/gallery/?sort=recent") self.assertEqual(response.status_code, 200) options = { item["key"]: item["label"] for item in response.context["sort_options"] } self.assertEqual(options, SORT_LABELS) self.assertEqual(response.context["sort_label"], SORT_LABELS["recent"]) def test_sort_tie_breaker_filename_then_relative_path(self): response = self.client.get("/gallery/?search=same&sort=abc") self.assertEqual(response.status_code, 200) image_paths = [item["path"] for item in response.context["images"]] self.assertEqual(len(image_paths), 2) self.assertTrue(image_paths[0].find("/gallery/sub_a/same.jpg") != -1) self.assertTrue(image_paths[1].find("/gallery/sub_b/same.jpg") != -1) def test_sort_options_shape_and_is_active(self): response = self.client.get("/gallery/?sort=cba&theme=light") self.assertEqual(response.status_code, 200) options = response.context["sort_options"] self.assertEqual(len(options), 6) for option in options: self.assertIn("key", option) self.assertIn("label", option) self.assertIn("url", option) self.assertIn("is_active", option) active = [option for option in options if option["is_active"]] self.assertEqual(len(active), 1) self.assertEqual(active[0]["key"], "cba") def test_breadcrumb_links_preserve_query(self): response = self.client.get( "/gallery/sub_a/?search=match&sort=recent&theme=light" ) self.assertEqual(response.status_code, 200) breadcrumbs = response.context["breadcrumbs"] self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0) self.assertIn("search=match", breadcrumbs[0]["path"]) self.assertIn("sort=recent", breadcrumbs[0]["path"]) self.assertIn("theme=light", breadcrumbs[0]["path"]) def test_subdir_links_preserve_query(self): response = self.client.get("/gallery/?search=match&sort=recent&theme=light") self.assertEqual(response.status_code, 200) subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]] self.assertTrue(any(path.find("search=match") != -1 for path in subdir_paths)) self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths)) self.assertTrue(any(path.find("theme=light") != -1 for path in subdir_paths)) def test_image_links_preserve_query(self): response = self.client.get("/gallery/?search=match&sort=recent&theme=light") self.assertEqual(response.status_code, 200) image_paths = [image["path"] for image in response.context["images"]] self.assertTrue(any(path.find("search=match") != -1 for path in image_paths)) self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths)) self.assertTrue(any(path.find("theme=light") != -1 for path in image_paths)) def test_theme_toggle_url_preserves_query(self): response = self.client.get("/gallery/?search=match&sort=recent&theme=dark") self.assertEqual(response.status_code, 200) url = response.context["theme_toggle_url"] self.assertIn("search=match", url) self.assertIn("sort=recent", url) self.assertIn("theme=light", url) def test_search_action_url_preserves_query(self): response = self.client.get("/gallery/?sort=recent&theme=light") self.assertEqual(response.status_code, 200) url = response.context["search_action_url"] self.assertTrue(url.find("/gallery/?") == 0) self.assertIn("sort=recent", url) self.assertIn("theme=light", url) def test_search_action_url_uses_nested_path(self): response = self.client.get("/gallery/sub_a/?sort=recent&theme=light") self.assertEqual(response.status_code, 200) url = response.context["search_action_url"] self.assertTrue(url.find("/gallery/sub_a/?") == 0) def test_subdirs_use_immediate_children_only(self): response = self.client.get("/gallery/?search=match") self.assertEqual(response.status_code, 200) names = [subdir["name"] for subdir in response.context["subdirs"]] self.assertIn("nested", names) self.assertNotIn("match-nested.jpg", names) def test_subdir_order_is_deterministic(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) names = [subdir["name"] for subdir in response.context["subdirs"]] self.assertEqual(names, sorted(names, key=lambda item: item.lower())) def test_subdir_without_images_uses_fallback(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) empty_sub = [ subdir for subdir in response.context["subdirs"] if subdir["name"] == "empty_sub" ][0] self.assertIsNone(empty_sub["thumbnail"]) def test_symlink_entries_ignored_in_gallery(self): if not self.symlink_supported: self.skipTest("Symlinks not supported in this environment") response = self.client.get("/gallery/?search=symlink") self.assertEqual(response.status_code, 200) names = [image["name"] for image in response.context["images"]] self.assertNotIn("symlink_file.jpg", names) def test_recursive_search_ignores_symlink_directories(self): if not self.symlink_supported: self.skipTest("Symlinks not supported in this environment") subdirs, images = do_recursive_search(self.gallery_root, "cover") subdir_names = [subdir.name for subdir in subdirs] image_names = [image.name for image in images] self.assertNotIn("symlink_sub", subdir_names) self.assertEqual(image_names.count("cover.jpg"), 1) def test_sidebar_subdirs_ignore_symlink_dirs(self): if not self.symlink_supported: self.skipTest("Symlinks not supported in this environment") response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) names = [subdir["name"] for subdir in response.context["subdirs"]] self.assertNotIn("symlink_sub", names) def test_thumbnail_failure_does_not_break_render(self): with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) self.assertIn("images", response.context) self.assertIn("subdirs", response.context) def test_login_required_for_gallery(self): anon = Client() response = anon.get("/gallery/") self.assertEqual(response.status_code, 302) def test_image_view_still_renders_prev_next(self): response = self.client.get("/gallery/beta.jpg/", follow=True) self.assertEqual(response.status_code, 200) self.assertEqual(response.context["image_path"].name, "beta.jpg") self.assertEqual(response.context["prev"], "alpha.jpg") self.assertEqual(response.context["next"], "gamma.jpg") # New context keys for image view should be present self.assertIn("prev_url", response.context) self.assertIn("next_url", response.context) self.assertIsNone(response.context.get("prev_url")) if response.context.get( "prev" ) is None else self.assertTrue( "/gallery/alpha.jpg" in response.context.get("prev_url") ) self.assertIsNone(response.context.get("next_url")) if response.context.get( "next" ) is None else self.assertTrue( "/gallery/gamma.jpg" in response.context.get("next_url") ) # Thumbnails (if available) should be provided as /thumbs/... or be None prev_thumb = response.context.get("prev_thumb") next_thumb = response.context.get("next_thumb") if prev_thumb is not None: self.assertTrue(prev_thumb.startswith("/thumbs/")) if next_thumb is not None: self.assertTrue(next_thumb.startswith("/thumbs/")) # Breadcrumbs should include filename as last label and it must be non-clickable (path None) breadcrumbs = response.context.get("breadcrumbs") self.assertIsNotNone(breadcrumbs) self.assertEqual(breadcrumbs[-1]["label"], "beta.jpg") self.assertIsNone(breadcrumbs[-1]["path"]) # Back and Home URLs should exist and preserve query state back_url = response.context.get("back_url") home_url = response.context.get("home_url") self.assertIsNotNone(back_url) self.assertIsNotNone(home_url) # For images in root both should be the gallery root with query params # normalize any './' segments to compare reliably norm = lambda u: u.replace("/./", "/") if u is not None else u self.assertEqual(norm(back_url), norm(home_url)) self.assertIn("/gallery/", norm(back_url)) self.assertIn("sort=abc", back_url) self.assertIn("theme=dark", back_url) # Back and Home thumbnails should be available and point to /thumbs/ back_thumb = response.context.get("back_thumb") home_thumb = response.context.get("home_thumb") self.assertIsNotNone(back_thumb) self.assertIsNotNone(home_thumb) self.assertTrue(back_thumb.startswith("/thumbs/")) self.assertTrue(home_thumb.startswith("/thumbs/")) self.assertEqual(back_thumb, home_thumb) # Image metadata should be present and sensible image_meta = response.context.get("image_meta") self.assertIsNotNone(image_meta) self.assertEqual(image_meta.get("filename"), "beta.jpg") self.assertEqual(image_meta.get("width"), 300) self.assertEqual(image_meta.get("height"), 200) self.assertIsNotNone(image_meta.get("filesize")) self.assertTrue( "KB" in image_meta.get("filesize") or "MB" in image_meta.get("filesize") ) self.assertIsNotNone(image_meta.get("created")) self.assertIsNotNone(image_meta.get("modified")) def test_sort_modes_recent_and_tnecer_use_mtime(self): recent = self.client.get("/gallery/?sort=recent") least_recent = self.client.get("/gallery/?sort=tnecer") self.assertEqual(recent.status_code, 200) self.assertEqual(least_recent.status_code, 200) self.assertEqual(recent.context["images"][0]["name"], "match-root.jpg") self.assertEqual(least_recent.context["images"][0]["name"], "alpha.jpg") def test_sort_modes_old_and_new_use_creation_timestamp(self): creation_values = { "alpha.jpg": 30, "beta.jpg": 10, "gamma.jpg": 20, "match-root.jpg": 40, } def fake_creation(path_obj): return creation_values.get(path_obj.name, 0) with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation): old = self.client.get("/gallery/?sort=old") new = self.client.get("/gallery/?sort=new") self.assertEqual(old.status_code, 200) self.assertEqual(new.status_code, 200) self.assertEqual(old.context["images"][0]["name"], "beta.jpg") self.assertEqual(new.context["images"][0]["name"], "match-root.jpg") class GalleryTemplateTests(GalleryBaseTests): def test_template_includes_bootstrap_and_fontawesome(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) body = response.content.decode("utf-8") self.assertIn("bootstrap@5", body) self.assertIn("font-awesome/7.0.0", body) def test_template_has_sidebar_controls_and_mobile_buttons(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) body = response.content.decode("utf-8") self.assertIn("fa-circle-user", body) self.assertIn("fa-bars", body) self.assertIn("fa-xmark", body) self.assertIn("fa-arrow-up-from-bracket", body) self.assertIn("Favorites", body) self.assertIn("Most visited", body) self.assertIn("Recently visited", body) def test_template_shows_fallback_icon_for_empty_subdir(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) body = response.content.decode("utf-8") self.assertIn("fa-solid fa-image", body) def test_search_placeholder_contrast_in_dark_theme(self): response = self.client.get("/gallery/?theme=dark") self.assertEqual(response.status_code, 200) # Styles are served via static files; assert the rules exist in the stylesheet css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css" css = css_path.read_text(encoding="utf-8") self.assertIn(".search-input::placeholder", css) self.assertIn("color: var(--muted);", css) def test_sort_dropdown_active_option_underlined(self): response = self.client.get("/gallery/?sort=recent") self.assertEqual(response.status_code, 200) body = response.content.decode("utf-8") self.assertIn("active-sort", body) self.assertIn("Modification date most recent", body) def test_template_has_requested_drop_shadows(self): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) # The requested drop shadows should be defined in the stylesheet css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css" css = css_path.read_text(encoding="utf-8") self.assertIn("box-shadow: 0 5px 20px", css)