Fix issues #002-#006 found by the LLM test harness

- #006 (high, data loss): make_room_for() no longer deletes unkept
  artifacts on a doomed request - checks whether eviction can possibly
  succeed before deleting anything. Also fixed a related latent bug found
  while testing: the function returned evicted Artifact objects after
  calling .delete() on them, so their .id was always None (Django clears
  pk on delete); now returns ids captured beforehand.
- #003: added server-side field-length validation (source/title/
  source_name/source_id/theme) on POST and PATCH, matching the model's
  own max_length limits, which were previously unenforced on the ingest
  path entirely.
- #004: ported daybook_post.py's content-shape validation
  (html_fragment/html_full/deck/markdown) to the server, closing the gap
  for any client that doesn't go through the helper script.
- #005: PATCH now rejects wrong-typed `tags` (previously silently wiped
  existing tags) and rejects immutable fields (content/source/kind/day)
  with 400 instead of silently no-op'ing them.
- #002: deleted the misleading skills/daybook-posting/.env.example (a
  zero-dependency script that never reads .env files) and clarified
  SKILL.md/docs/llm_posting_helper.md that these must be real
  process/shell env vars.

Adds tests/unit/ for storage-layer tests. 46 tests passing (was 25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 13:38:59 +01:00
parent c1ea5c7990
commit 07328a3412
13 changed files with 635 additions and 35 deletions

0
tests/unit/__init__.py Normal file
View file

View file

@ -0,0 +1,86 @@
import shutil
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from artifacts.models import Artifact, UserProfile
from artifacts.storage import QuotaExceeded, make_room_for
User = get_user_model()
@override_settings()
class MakeRoomForTestCase(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-storage-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@classmethod
def tearDownClass(cls):
cls.settings_override.disable()
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(username="quota-test", password="pw")
def make_artifact(self, artifact_id, size_bytes, is_kept=False):
return Artifact.objects.create(
id=artifact_id,
owner=self.user,
source="test",
title=f"Artifact {artifact_id}",
kind=Artifact.MARKDOWN,
day="2026-01-01",
path=f"test/2026-01-01/{artifact_id}.html",
original_kind=Artifact.MARKDOWN,
size_bytes=size_bytes,
is_kept=is_kept,
)
def set_quota(self, quota_bytes):
UserProfile.objects.update_or_create(user=self.user, defaults={"quota_bytes": quota_bytes})
def test_unwinnable_eviction_deletes_nothing(self):
"""Regression test for issue #006: a doomed post must not destroy
unkept artifacts on its way to still failing."""
self.set_quota(1000)
self.make_artifact("kept0001", size_bytes=900, is_kept=True)
self.make_artifact("unkept01", size_bytes=50)
self.make_artifact("unkept02", size_bytes=40)
with self.assertRaises(QuotaExceeded):
make_room_for(self.user, incoming_bytes=200)
self.assertTrue(Artifact.objects.filter(id="unkept01").exists())
self.assertTrue(Artifact.objects.filter(id="unkept02").exists())
self.assertTrue(Artifact.objects.filter(id="kept0001").exists())
def test_successful_eviction_frees_only_what_is_needed(self):
self.set_quota(1000)
self.make_artifact("kept0001", size_bytes=500, is_kept=True)
self.make_artifact("oldest01", size_bytes=200)
self.make_artifact("newest01", size_bytes=200)
Artifact.objects.filter(id="oldest01").update(created_at="2026-01-01T00:00:00Z")
Artifact.objects.filter(id="newest01").update(created_at="2026-06-01T00:00:00Z")
evicted = make_room_for(self.user, incoming_bytes=300)
self.assertEqual(set(evicted), {"oldest01"})
self.assertFalse(Artifact.objects.filter(id="oldest01").exists())
self.assertTrue(Artifact.objects.filter(id="newest01").exists())
self.assertTrue(Artifact.objects.filter(id="kept0001").exists())
def test_incoming_larger_than_quota_deletes_nothing(self):
self.set_quota(1000)
self.make_artifact("unkept01", size_bytes=50)
with self.assertRaises(QuotaExceeded):
make_room_for(self.user, incoming_bytes=2000)
self.assertTrue(Artifact.objects.filter(id="unkept01").exists())