Implements tag/category system backend following microformats2 p-category specification. Database changes: - Migration 008: Add tags and note_tags tables - Normalized tag storage (case-insensitive lookup, display name preserved) - Indexes for performance New module: - starpunk/tags.py: Tag management functions - normalize_tag: Normalize tag strings - get_or_create_tag: Get or create tag records - add_tags_to_note: Associate tags with notes (replaces existing) - get_note_tags: Retrieve note tags (alphabetically ordered) - get_tag_by_name: Lookup tag by normalized name - get_notes_by_tag: Get all notes with specific tag - parse_tag_input: Parse comma-separated tag input Model updates: - Note.tags property (lazy-loaded, prefer pre-loading in routes) - Note.to_dict() add include_tags parameter CRUD updates: - create_note() accepts tags parameter - update_note() accepts tags parameter (None = no change, [] = remove all) Micropub integration: - Pass tags to create_note() (tags already extracted by extract_tags()) - Return tags in q=source response Per design doc: docs/design/v1.3.0/microformats-tags-design.md Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
7.7 KiB
Phase 4: Error Handling Fix - Implementation Guide
Created: 2025-11-18
Status: Ready for Implementation
Related ADR: ADR-012 HTTP Error Handling Policy
Related Review: /home/phil/Projects/starpunk/docs/reviews/error-handling-rest-vs-web-patterns.md
Test Failure: test_update_nonexistent_note_404
Problem Summary
The POST route for updating notes (/admin/edit/<id>) returns HTTP 302 (redirect) when the note doesn't exist, but the test expects HTTP 404. The GET route for the edit form already returns 404 correctly, so this is an inconsistency in the implementation.
Solution
Add an existence check at the start of update_note_submit() in /home/phil/Projects/starpunk/starpunk/routes/admin.py, matching the pattern used in edit_note_form().
Implementation Steps
Step 1: Modify update_note_submit() Function
File: /home/phil/Projects/starpunk/starpunk/routes/admin.py
Lines: 127-164
Function: update_note_submit(note_id: int)
Add the following code after the function definition and decorator, before processing form data:
@bp.route("/edit/<int:note_id>", methods=["POST"])
@require_auth
def update_note_submit(note_id: int):
"""
Handle note update submission
Updates existing note with submitted form data.
Requires authentication.
Args:
note_id: Database ID of note to update
Form data:
content: Updated markdown content (required)
published: Checkbox for published status (optional)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
# CHECK IF NOTE EXISTS FIRST (ADDED)
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Rest of the function remains the same
content = request.form.get("content", "").strip()
published = "published" in request.form
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
try:
note = update_note(id=note_id, content=content, published=published)
flash(f"Note updated: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
flash(f"Error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
except Exception as e:
flash(f"Unexpected error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
Step 2: Verify Fix with Tests
Run the failing test to verify it now passes:
uv run pytest tests/test_routes_admin.py::TestEditNote::test_update_nonexistent_note_404 -v
Expected output:
tests/test_routes_admin.py::TestEditNote::test_update_nonexistent_note_404 PASSED
Step 3: Run Full Admin Route Test Suite
Verify no regressions:
uv run pytest tests/test_routes_admin.py -v
All tests should pass.
Step 4: Verify Existing GET Route Still Works
The GET route should still return 404:
uv run pytest tests/test_routes_admin.py::TestEditNote::test_edit_nonexistent_note_404 -v
Should still pass (no changes to this route).
Code Changes Summary
File: /home/phil/Projects/starpunk/starpunk/routes/admin.py
Location: After line 129 (after function docstring, before form processing)
Add:
# Check if note exists first
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
No other changes needed - the import for get_note already exists (line 15).
Why This Fix Works
Pattern Consistency
This matches the existing pattern in edit_note_form() (lines 118-122):
note = get_note(id=note_id)
if not note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
Prevents Exception Handling
Without this check, the code would:
- Try to call
update_note(id=note_id, ...) update_note()callsget_note()internally (line 603)get_note()returnsNonefor missing notes (line 368)update_note()raisesNoteNotFoundError(line 607)- Exception caught by
except Exception(line 162) - Returns redirect with 302 status
With this check, the code:
- Calls
get_note(id=note_id)first - Returns 404 immediately if not found
- Never calls
update_note()for nonexistent notes
HTTP Semantic Correctness
- 404 Not Found: The correct HTTP status for "resource does not exist"
- 302 Found (Redirect): Used for successful operations that redirect elsewhere
- The test expects 404, which is semantically correct
User Experience
While returning 404, we still:
- Flash an error message ("Note not found")
- Redirect to the dashboard (safe location)
- User sees the error in context
Flask allows returning both: return redirect(...), 404
Testing Strategy
Unit Test Coverage
This test should now pass:
def test_update_nonexistent_note_404(self, authenticated_client):
"""Test that updating a nonexistent note returns 404"""
response = authenticated_client.post(
"/admin/edit/99999",
data={"content": "Updated content", "published": "on"},
follow_redirects=False,
)
assert response.status_code == 404 # ✓ Should pass now
Manual Testing (Optional)
- Start the development server
- Log in as admin
- Try to access
/admin/edit/99999(GET)- Should redirect to dashboard with "Note not found" message
- Network tab shows 404 status
- Try to POST to
/admin/edit/99999with form data- Should redirect to dashboard with "Note not found" message
- Network tab shows 404 status
Additional Considerations
Performance Impact
Minimal: The existence check adds one database query:
- Query:
SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL - With
load_content=False: No file I/O - SQLite with index: ~0.1ms
- Acceptable for single-user system
Alternative Approaches Rejected
- Catch
NoteNotFoundErrorspecifically: Possible, but less explicit than checking first - Let error handler deal with it: Less flexible for per-route flash messages
- Change test to expect 302: Wrong - test is correct, implementation is buggy
Future Improvements
Consider adding a similar check to delete_note_submit() for consistency:
@bp.route("/delete/<int:note_id>", methods=["POST"])
@require_auth
def delete_note_submit(note_id: int):
if request.form.get("confirm") != "yes":
flash("Deletion cancelled", "info")
return redirect(url_for("admin.dashboard"))
# ADD EXISTENCE CHECK
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Rest of delete logic...
However, this requires updating the test test_delete_nonexistent_note_shows_error to expect 404 instead of 200.
Expected Outcome
After implementing this fix:
- ✓
test_update_nonexistent_note_404passes - ✓
test_edit_nonexistent_note_404still passes - ✓ All other admin route tests pass
- ✓ GET and POST routes have consistent behavior
- ✓ HTTP semantics are correct (404 for missing resources)
References
- Architectural review:
/home/phil/Projects/starpunk/docs/reviews/error-handling-rest-vs-web-patterns.md - ADR:
/home/phil/Projects/starpunk/docs/decisions/ADR-012-http-error-handling-policy.md - Current implementation:
/home/phil/Projects/starpunk/starpunk/routes/admin.py - Test file:
/home/phil/Projects/starpunk/tests/test_routes_admin.py