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

View file

@ -76,8 +76,10 @@ def user_quota_bytes(user):
def make_room_for(user, incoming_bytes):
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
if even removing every unkept artifact leaves no room.
Returns the ids of evicted artifacts (already deleted - Django clears an
instance's pk on .delete(), so ids are captured beforehand). Raises
QuotaExceeded, without deleting anything, if even evicting every unkept
artifact wouldn't free enough room.
"""
quota = user_quota_bytes(user)
if incoming_bytes > quota:
@ -87,21 +89,27 @@ def make_room_for(user, incoming_bytes):
if used + incoming_bytes <= quota:
return []
evicted = []
unkept = (
unkept = list(
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
)
# Check whether eviction can possibly succeed before deleting anything -
# otherwise a doomed request would still destroy every unkept artifact on
# its way to failing anyway.
evictable_total = sum(artifact_total_bytes(artifact) for artifact in unkept)
if used - evictable_total + incoming_bytes > quota:
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
evicted = []
for artifact in unkept:
if used + incoming_bytes <= quota:
break
# Freeing an artifact also frees the assets embedded in it.
used -= artifact_total_bytes(artifact)
evicted_id = artifact.id
delete_artifact_files(artifact)
artifact.delete()
evicted.append(artifact)
evicted.append(evicted_id)
if used + incoming_bytes > quota:
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
return evicted