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

@ -274,3 +274,85 @@ class ArtifactAPITestCase(APITestCase):
detail_response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(detail_response.status_code, 404)
# -- field length limits (issue #003) ----------------------------------
def test_create_rejects_oversized_title(self):
response = self.create_artifact(title="A" * 201)
self.assertEqual(response.status_code, 400)
self.assertIn("title", response.data["detail"])
def test_create_rejects_oversized_source(self):
response = self.create_artifact(source="a" * 81)
self.assertEqual(response.status_code, 400)
self.assertIn("source", response.data["detail"])
def test_create_accepts_title_at_exact_limit(self):
response = self.create_artifact(title="A" * 200)
self.assertEqual(response.status_code, 201)
def test_patch_rejects_oversized_title(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{"title": "A" * 201},
format="json",
**self.auth_headers(self.token),
)
self.assertEqual(response.status_code, 400)
# -- content-shape validation (issue #004) -----------------------------
def test_create_rejects_nested_html_in_html_fragment(self):
response = self.create_artifact(
kind="html_fragment", content="<html><body>nested</body></html>"
)
self.assertEqual(response.status_code, 400)
def test_create_rejects_html_full_without_html_tag(self):
response = self.create_artifact(kind="html_full", content="<p>not a full document</p>")
self.assertEqual(response.status_code, 400)
def test_create_rejects_deck_without_section(self):
response = self.create_artifact(kind="deck", content="<h1>Not a deck slide</h1>")
self.assertEqual(response.status_code, 400)
def test_create_rejects_whitespace_only_markdown(self):
response = self.create_artifact(kind="markdown", content=" \n\t ")
self.assertEqual(response.status_code, 400)
def test_create_accepts_well_formed_html_fragment(self):
response = self.create_artifact(kind="html_fragment", content="<p>fine</p>")
self.assertEqual(response.status_code, 201)
# -- PATCH validation (issue #005) -------------------------------------
def test_patch_rejects_non_string_non_list_tags(self):
artifact_id = self.create_artifact(tags=["important", "keep-me"]).data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{"tags": 12345},
format="json",
**self.auth_headers(self.token),
)
self.assertEqual(response.status_code, 400)
artifact = Artifact.objects.get(pk=artifact_id)
self.assertEqual(artifact.tags, ["important", "keep-me"])
def test_patch_rejects_immutable_fields(self):
artifact_id = self.create_artifact().data["id"]
for field, value in [
("content", "new content"),
("source", "other-source"),
("kind", "deck"),
("day", "2020-01-01"),
]:
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{field: value},
format="json",
**self.auth_headers(self.token),
)
self.assertEqual(response.status_code, 400, field)
self.assertIn(field, response.data["detail"])