|
| 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 |
0 commit comments