Skip to content

Commit 8459c62

Browse files
authored
Merge pull request #1339 from makeabilitylab/1278-test-coverage-backfill
Test backfill for high-risk code + two latent 500 fixes (#1278 item 5)
2 parents 399cedc + c75da2a commit 8459c62

11 files changed

Lines changed: 784 additions & 24 deletions

makeabilitylab/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8787

8888
# Makeability Lab Global Variables, including Makeability Lab version
89-
ML_WEBSITE_VERSION = "2.12.1" # Keep this updated with each release and also change the short description below
90-
ML_WEBSITE_VERSION_DESCRIPTION = "Patch: tighten meta descriptions (#1142/#1324). Home now uses a concise description mirroring the hero blurb; projects without a one-line summary fall back to a truncated About instead of the generic lab boilerplate; the last-resort default is trimmed to ~135 chars. Reduces duplicate/over-long descriptions flagged by social/OG inspectors. Template/view-only — no schema change."
89+
ML_WEBSITE_VERSION = "2.12.2" # Keep this updated with each release and also change the short description below
90+
ML_WEBSITE_VERSION_DESCRIPTION = "Patch: test backfill for high-risk untested code (#1278 item 5) surfaced and fixed two latent 500s — Artifact.save() crashed re-saving an artifact with no PDF, and a project with no start_date 500'd its page. Adds a public-view smoke-sweep plus coverage for delete_unused_files, Person.save() side effects, and the pure utils (timeutils/ml_utils/fileutils), lifting app coverage 59%→69%. Two one-line model guards; no schema change."
9191
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9292
MAX_BANNERS = 7 # Maximum number of banners on a page
9393

website/models/artifact.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -307,28 +307,32 @@ def save(self, *args, **kwargs):
307307
else:
308308
_logger.debug("No authors exist yet, so will wait for m2m authors_changed to rename files")
309309

310-
# Generate a thumbnail if one does not already exist
311-
pdf_filename = os.path.basename(self.pdf_file.name)
312-
pdf_filename_no_ext, ext = os.path.splitext(pdf_filename)
313-
thumbnail_filename = os.path.basename(pdf_filename_no_ext) + ".jpg"
314-
thumbnail_filename_with_local_path = self.get_upload_thumbnail_dir(thumbnail_filename)
315-
thumbnail_exists_in_storage = self.thumbnail.storage.exists(thumbnail_filename_with_local_path)
316-
if not self.thumbnail or not thumbnail_exists_in_storage:
317-
_logger.debug(f"The thumbnail for artifact.id={self.id} does not exist at {thumbnail_filename_with_local_path}, generating...")
318-
319-
# generate a thumbnail
320-
if self.pdf_file.storage.exists(self.pdf_file.name):
321-
thumbnail_local_path = os.path.dirname(thumbnail_filename_with_local_path)
322-
ml_fileutils.generate_thumbnail_for_pdf(self.pdf_file, self.thumbnail, thumbnail_local_path)
323-
324-
# If 'update_fields' does not exist in kwargs, all fields are saved
325-
# Add 'thumbnail' to the update_fields list so that it gets updated in the db
326-
if 'update_fields' in kwargs:
327-
kwargs.setdefault('update_fields', []).append('thumbnail')
328-
else:
329-
_logger.debug(f"Could not generate a thumbnail because the pdf {self.pdf_file.path} was not found in storage")
330-
elif thumbnail_exists_in_storage:
331-
_logger.debug(f"The thumbnail for artifact.id={self.id} already exists at {thumbnail_filename_with_local_path}, so not generating")
310+
# Generate a thumbnail if one does not already exist. Guard on
311+
# pdf_file: it's nullable, and self.pdf_file.name is None when empty,
312+
# which would crash os.path.basename below (#1278). No PDF simply
313+
# means there is no thumbnail to generate.
314+
if self.pdf_file:
315+
pdf_filename = os.path.basename(self.pdf_file.name)
316+
pdf_filename_no_ext, ext = os.path.splitext(pdf_filename)
317+
thumbnail_filename = os.path.basename(pdf_filename_no_ext) + ".jpg"
318+
thumbnail_filename_with_local_path = self.get_upload_thumbnail_dir(thumbnail_filename)
319+
thumbnail_exists_in_storage = self.thumbnail.storage.exists(thumbnail_filename_with_local_path)
320+
if not self.thumbnail or not thumbnail_exists_in_storage:
321+
_logger.debug(f"The thumbnail for artifact.id={self.id} does not exist at {thumbnail_filename_with_local_path}, generating...")
322+
323+
# generate a thumbnail
324+
if self.pdf_file.storage.exists(self.pdf_file.name):
325+
thumbnail_local_path = os.path.dirname(thumbnail_filename_with_local_path)
326+
ml_fileutils.generate_thumbnail_for_pdf(self.pdf_file, self.thumbnail, thumbnail_local_path)
327+
328+
# If 'update_fields' does not exist in kwargs, all fields are saved
329+
# Add 'thumbnail' to the update_fields list so that it gets updated in the db
330+
if 'update_fields' in kwargs:
331+
kwargs.setdefault('update_fields', []).append('thumbnail')
332+
else:
333+
_logger.debug(f"Could not generate a thumbnail because the pdf {self.pdf_file.path} was not found in storage")
334+
elif thumbnail_exists_in_storage:
335+
_logger.debug(f"The thumbnail for artifact.id={self.id} already exists at {thumbnail_filename_with_local_path}, so not generating")
332336

333337
_logger.debug(f"Calling super().save(*args, **kwargs)")
334338

website/models/project.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,12 @@ def get_project_dates_str(self):
613613
If the end date is None, it returns a string in the format 'start_year–Present'.
614614
Otherwise, it returns a string in the format 'start_year–end_year'.
615615
"""
616+
# start_date is nullable, so a project may have none. Without it there's
617+
# no range to format; return "" so the template renders nothing rather
618+
# than 500ing on self.start_date.year (#1278).
619+
if self.start_date is None:
620+
return ""
621+
616622
# If end_date is None, return 'start_year–Present'
617623
if self.end_date is None:
618624
return f"{self.start_date.year}–Present"

website/tests/test_artifact.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Tests for Artifact model methods (filename-drift check, raw-file label)."""
22

3+
from datetime import date
34
from unittest.mock import MagicMock, patch
45

56
from django.test import SimpleTestCase
67

8+
from website.models import Publication
9+
from website.tests.base import DatabaseTestCase
10+
711

812
# --- Artifact filename check regression -----------------------------------
913

@@ -113,3 +117,29 @@ def test_no_raw_file_returns_none(self):
113117

114118
def test_no_extension_returns_none(self):
115119
self.assertIsNone(self._label("talks/Doe2020Title"))
120+
121+
122+
# --- Artifact.save() with no PDF ------------------------------------------
123+
124+
125+
class ArtifactSaveNullPdfTests(DatabaseTestCase):
126+
"""
127+
Regression test for Artifact.save() when ``pdf_file`` is empty (#1278).
128+
129+
``pdf_file`` is nullable (``null=True, default=None``), so an artifact can
130+
legitimately exist without a PDF. But the thumbnail-generation block in
131+
Artifact.save() ran ``os.path.basename(self.pdf_file.name)`` unconditionally
132+
on every non-first save -- and ``self.pdf_file.name`` is ``None`` when the
133+
field is empty, raising ``TypeError: expected str ... not NoneType``.
134+
135+
Any second save of a PDF-less artifact triggered it: an admin edit, or the
136+
``authors_changed`` m2m signal re-saving to rename files. This pins the
137+
guard so a missing PDF simply means "no thumbnail to generate".
138+
"""
139+
140+
def test_resaving_artifact_without_pdf_does_not_crash(self):
141+
pub = Publication.objects.create(title="No PDF", date=date(2024, 1, 1))
142+
# First save (objects.create) is fine; the crash was on the *second*.
143+
pub.location = "Seattle, WA"
144+
pub.save() # must not raise
145+
self.assertFalse(bool(Publication.objects.get(pk=pub.pk).pdf_file))
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""
2+
Regression tests for the ``delete_unused_files`` management command (#1278, item 5).
3+
4+
This command runs on **every container start** (see ``docker-entrypoint.sh``) and
5+
**deletes files** off the media filesystem, so it is the single highest-risk
6+
untested code path in the app: a logic slip here silently destroys real
7+
publication / talk / poster PDFs and thumbnails in production. Until now it had
8+
zero tests.
9+
10+
The command globs ``MEDIA_ROOT/{publications,talks,posters}`` (and their
11+
``images/`` thumbnail subdirs) for files, removes from that set anything still
12+
referenced by a DB row, and deletes whatever is left over. These tests pin the
13+
three behaviors that matter:
14+
15+
1. **Orphans are deleted, referenced files are kept** — the core contract.
16+
2. **easy-thumbnails ``_detail`` cache files are never touched** — they are
17+
owned by ``thumbnail_cleanup``; deleting them here would fight that command.
18+
3. **It never crashes** on empty media dirs, zero DB rows, or a row whose
19+
``FileField`` is empty (the null-``.path`` crash class called out in #1278).
20+
21+
Every test runs against a throwaway ``MEDIA_ROOT`` (a temp dir wired in via
22+
``override_settings``) so no real media is ever at risk.
23+
"""
24+
25+
import os
26+
import shutil
27+
import tempfile
28+
from datetime import date
29+
30+
from django.core.management import call_command
31+
from django.test import override_settings
32+
33+
from website.models import Publication
34+
from website.tests.base import DatabaseTestCase
35+
36+
37+
class DeleteUnusedFilesTests(DatabaseTestCase):
38+
"""Exercise ``manage.py delete_unused_files`` against a temp MEDIA_ROOT."""
39+
40+
def setUp(self):
41+
super().setUp()
42+
# A disposable media root so model saves and the command's deletions
43+
# only ever touch files under here, never the developer's real media/.
44+
self.media_root = tempfile.mkdtemp(prefix="ml_media_test_")
45+
self.addCleanup(shutil.rmtree, self.media_root, ignore_errors=True)
46+
47+
override = override_settings(MEDIA_ROOT=self.media_root)
48+
override.enable()
49+
self.addCleanup(override.disable)
50+
51+
# The command globs these dirs; create them so an empty run has
52+
# something to glob (mirrors a freshly-deployed container).
53+
for sub in ("publications/images", "talks/images", "posters/images"):
54+
os.makedirs(os.path.join(self.media_root, sub), exist_ok=True)
55+
56+
def _write(self, relpath, content=b"unused"):
57+
"""Write a stray file under MEDIA_ROOT and return its absolute path."""
58+
full = os.path.join(self.media_root, relpath)
59+
os.makedirs(os.path.dirname(full), exist_ok=True)
60+
with open(full, "wb") as fh:
61+
fh.write(content)
62+
return full
63+
64+
def test_orphan_publication_pdf_is_deleted_referenced_is_kept(self):
65+
"""The whole point: drop the orphan, keep the file a DB row points at."""
66+
pub = self.make_publication(title="Kept Paper")
67+
referenced = pub.pdf_file.path
68+
self.assertTrue(os.path.exists(referenced))
69+
70+
orphan = self._write("publications/orphan_abandoned.pdf", b"%PDF-1.4 orphan")
71+
72+
call_command("delete_unused_files")
73+
74+
self.assertFalse(os.path.exists(orphan), "unreferenced PDF should be deleted")
75+
self.assertTrue(os.path.exists(referenced), "referenced PDF must be kept")
76+
77+
def test_easy_thumbnail_detail_files_are_preserved(self):
78+
"""``_detail`` cache files belong to thumbnail_cleanup, not this command."""
79+
detail = self._write(
80+
"publications/images/Foo_CHI2022.jpg.300x0_q85_detail.jpg"
81+
)
82+
orphan_thumb = self._write("publications/images/orphan_thumb.jpg")
83+
84+
call_command("delete_unused_files")
85+
86+
self.assertTrue(
87+
os.path.exists(detail),
88+
"_detail easy-thumbnail file must be preserved",
89+
)
90+
self.assertFalse(
91+
os.path.exists(orphan_thumb),
92+
"unreferenced thumbnail should be deleted",
93+
)
94+
95+
def test_orphan_talk_pdf_and_raw_files_are_deleted(self):
96+
"""Talks: orphan .pdf/.pptx/.key go; the referenced talk PDF stays."""
97+
talk = self.make_talk(title="Kept Talk")
98+
referenced = talk.pdf_file.path
99+
100+
orphan_pdf = self._write("talks/orphan_talk.pdf")
101+
orphan_pptx = self._write("talks/orphan_deck.pptx")
102+
orphan_key = self._write("talks/orphan_deck.key")
103+
104+
call_command("delete_unused_files")
105+
106+
self.assertTrue(os.path.exists(referenced), "referenced talk PDF must be kept")
107+
for stray in (orphan_pdf, orphan_pptx, orphan_key):
108+
self.assertFalse(os.path.exists(stray), f"{stray} should be deleted")
109+
110+
def test_orphan_poster_files_are_deleted(self):
111+
"""Posters: the raw set is .pptx/.key/.ai (note .ai, unlike talks)."""
112+
strays = [
113+
self._write("posters/orphan_poster.pdf"),
114+
self._write("posters/orphan_poster.ai"),
115+
self._write("posters/orphan_poster.key"),
116+
self._write("posters/orphan_poster.pptx"),
117+
]
118+
119+
call_command("delete_unused_files")
120+
121+
for stray in strays:
122+
self.assertFalse(os.path.exists(stray), f"{stray} should be deleted")
123+
124+
def test_delete_unused_files_helper_reports_count_and_bytes(self):
125+
"""The low-level helper returns an accurate (count, total_bytes) tally."""
126+
from website.management.commands.delete_unused_files import Command
127+
128+
f1 = self._write("publications/a.pdf", b"12345") # 5 bytes
129+
f2 = self._write("publications/b.pdf", b"678") # 3 bytes
130+
131+
count, total_bytes = Command().delete_unused_files([f1, f2])
132+
133+
self.assertEqual(count, 2)
134+
self.assertEqual(total_bytes, 8)
135+
self.assertFalse(os.path.exists(f1))
136+
self.assertFalse(os.path.exists(f2))
137+
138+
def test_runs_cleanly_on_empty_media_and_no_db_rows(self):
139+
"""Fresh deploy: empty media dirs, no DB rows -> no exception, no deletions."""
140+
call_command("delete_unused_files") # reaching the next line == no crash
141+
142+
for sub in ("publications", "talks", "posters"):
143+
self.assertTrue(os.path.isdir(os.path.join(self.media_root, sub)))
144+
145+
def test_artifact_with_empty_pdf_field_does_not_crash(self):
146+
"""A row whose pdf_file is empty must not crash the guarded .path access."""
147+
# Guards the null-FileField crash class flagged in #1278: the command's
148+
# `if pub.pdf_file:` check must short-circuit before touching `.path`.
149+
# Built via objects.create (a single, first-time save) so this test
150+
# isolates the *command's* guard; the separate Artifact.save() null-pdf
151+
# re-save crash is pinned in test_artifact.py.
152+
Publication.objects.create(title="No PDF", date=date(2024, 1, 1))
153+
self.assertFalse(bool(Publication.objects.get(title="No PDF").pdf_file))
154+
155+
call_command("delete_unused_files") # no AttributeError on empty .path

website/tests/test_fileutils.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Unit tests for the pure filename helpers in website.utils.fileutils (#1278, item 5).
2+
3+
These build the deterministic, archive-friendly filenames the artifact rename
4+
pipeline depends on (get_filename_for_artifact and friends). The Star Wars
5+
image helpers in this module are already covered by test_easter_egg_picker;
6+
the filesystem-touching helpers (thumbnail generation, PDF page count) are left
7+
to integration coverage. No DB.
8+
"""
9+
10+
import os
11+
from datetime import date
12+
13+
from django.test import SimpleTestCase
14+
15+
from website.utils.fileutils import (
16+
ensure_filename_is_unique,
17+
get_ckeditor_image_filename,
18+
get_filename_for_artifact,
19+
get_filename_no_ext,
20+
get_filename_without_ext_for_artifact,
21+
is_image,
22+
)
23+
24+
25+
class IsImageTests(SimpleTestCase):
26+
def test_known_image_extensions(self):
27+
for name in ("photo.jpg", "PHOTO.JPEG", "art.png", "anim.gif"):
28+
self.assertTrue(is_image(name), name)
29+
30+
def test_non_image_extensions(self):
31+
for name in ("doc.pdf", "deck.pptx", "noext"):
32+
self.assertFalse(is_image(name), name)
33+
34+
35+
class GetCkeditorImageFilenameTests(SimpleTestCase):
36+
def test_uppercases_filename(self):
37+
self.assertEqual(get_ckeditor_image_filename("My File.png"), "MY FILE.PNG")
38+
39+
40+
class GetFilenameNoExtTests(SimpleTestCase):
41+
def test_strips_path_and_extension(self):
42+
self.assertEqual(
43+
get_filename_no_ext("publications/Doe_Paper_CHI2022.pdf"),
44+
"Doe_Paper_CHI2022",
45+
)
46+
47+
48+
class GetFilenameForArtifactTests(SimpleTestCase):
49+
"""The canonical Lastname_Title_Forum+Year naming used for archived files."""
50+
51+
def test_basic_filename(self):
52+
self.assertEqual(
53+
get_filename_for_artifact(
54+
"Froehlich", "This Is A Test", "CHI", date(2022, 1, 1), "pdf"
55+
),
56+
"Froehlich_ThisIsATest_CHI2022.pdf",
57+
)
58+
59+
def test_extension_normalized_with_or_without_dot(self):
60+
with_dot = get_filename_for_artifact(
61+
"Doe", "Paper", "UIST", date(2021, 1, 1), ".pdf"
62+
)
63+
without_dot = get_filename_for_artifact(
64+
"Doe", "Paper", "UIST", date(2021, 1, 1), "pdf"
65+
)
66+
self.assertEqual(with_dot, without_dot)
67+
self.assertTrue(with_dot.endswith(".pdf"))
68+
69+
def test_missing_last_name_uses_none_placeholder(self):
70+
self.assertTrue(
71+
get_filename_without_ext_for_artifact(
72+
"", "Paper", "CHI", date(2022, 1, 1)
73+
).startswith("None_")
74+
)
75+
76+
def test_suffix_is_inserted_before_forum(self):
77+
self.assertEqual(
78+
get_filename_without_ext_for_artifact(
79+
"Doe", "Paper", "CHI", date(2022, 1, 1), suffix="poster"
80+
),
81+
"Doe_Paper_poster_CHI2022",
82+
)
83+
84+
def test_title_truncation(self):
85+
result = get_filename_without_ext_for_artifact(
86+
"Doe", "A Very Long Title Here", "CHI", date(2022, 1, 1),
87+
max_pub_title_length=5,
88+
)
89+
# Title portion (between the underscores) is capped at 5 chars.
90+
title_part = result.split("_")[1]
91+
self.assertEqual(len(title_part), 5)
92+
93+
94+
class EnsureFilenameIsUniqueTests(SimpleTestCase):
95+
def test_nonexistent_path_returned_unchanged(self):
96+
path = os.path.join("/tmp", "definitely-not-a-real-file-1278.pdf")
97+
self.assertEqual(ensure_filename_is_unique(path), path)

0 commit comments

Comments
 (0)