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:
parent
c1ea5c7990
commit
07328a3412
13 changed files with 635 additions and 35 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
86
tests/unit/test_storage.py
Normal file
86
tests/unit/test_storage.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue