Files
StarPunk/tests/test_notes.py
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

930 lines
33 KiB
Python

"""
Tests for notes management module
Test categories:
- Note exception tests
- Note creation tests
- Note retrieval tests
- Note listing tests
- Note update tests
- Note deletion tests
- Edge case tests
- Integration tests
"""
import pytest
from pathlib import Path
from datetime import datetime
from starpunk.notes import (
create_note,
get_note,
list_notes,
update_note,
delete_note,
NoteError,
NoteNotFoundError,
InvalidNoteDataError,
NoteSyncError,
_get_existing_slugs,
)
from starpunk.database import get_db
class TestNoteExceptions:
"""Test custom exception classes"""
def test_note_error_is_exception(self):
"""Test NoteError inherits from Exception"""
err = NoteError("test error")
assert isinstance(err, Exception)
def test_not_found_error_inheritance(self):
"""Test NoteNotFoundError inherits from NoteError"""
err = NoteNotFoundError("test-slug")
assert isinstance(err, NoteError)
assert isinstance(err, Exception)
def test_not_found_error_with_message(self):
"""Test NoteNotFoundError custom message"""
err = NoteNotFoundError("test-slug", "Custom message")
assert str(err) == "Custom message"
assert err.identifier == "test-slug"
def test_not_found_error_default_message(self):
"""Test NoteNotFoundError default message"""
err = NoteNotFoundError("test-slug")
assert "Note not found: test-slug" in str(err)
def test_invalid_data_error_inheritance(self):
"""Test InvalidNoteDataError inherits from both NoteError and ValueError"""
err = InvalidNoteDataError("content", "", "Empty content")
assert isinstance(err, NoteError)
assert isinstance(err, ValueError)
def test_invalid_data_error_attributes(self):
"""Test InvalidNoteDataError stores field and value"""
err = InvalidNoteDataError("content", "test value", "Error message")
assert err.field == "content"
assert err.value == "test value"
assert str(err) == "Error message"
def test_sync_error_attributes(self):
"""Test NoteSyncError stores operation and details"""
err = NoteSyncError("create", "DB failed", "Custom message")
assert err.operation == "create"
assert err.details == "DB failed"
assert str(err) == "Custom message"
class TestGetExistingSlugs:
"""Test _get_existing_slugs helper function"""
def test_empty_database(self, app, client):
"""Test getting slugs from empty database"""
with app.app_context():
db = get_db(app)
slugs = _get_existing_slugs(db)
assert slugs == set()
def test_with_existing_notes(self, app, client):
"""Test getting slugs with existing notes"""
with app.app_context():
# Create some notes
create_note("First note")
create_note("Second note")
create_note("Third note")
# Get slugs
db = get_db(app)
slugs = _get_existing_slugs(db)
# Should have 3 slugs
assert len(slugs) == 3
assert isinstance(slugs, set)
class TestCreateNote:
"""Test note creation"""
def test_create_basic_note(self, app, client):
"""Test creating a basic note"""
with app.app_context():
note = create_note("# Test Note\n\nContent here.", published=False)
assert note.slug is not None
assert note.published is False
assert "Test Note" in note.content
assert note.id is not None
assert note.created_at is not None
assert note.updated_at is not None
def test_create_published_note(self, app, client):
"""Test creating a published note"""
with app.app_context():
note = create_note("Published content", published=True)
assert note.published is True
def test_create_with_custom_timestamp(self, app, client):
"""Test creating note with specific timestamp"""
with app.app_context():
created_at = datetime(2024, 1, 1, 12, 0, 0)
note = create_note("Backdated note", created_at=created_at)
assert note.created_at == created_at
assert note.updated_at == created_at
def test_create_generates_unique_slug(self, app, client):
"""Test slug uniqueness enforcement"""
with app.app_context():
# Create two notes with identical content to force slug collision
note1 = create_note("# Same Title\n\nSame content for both")
note2 = create_note("# Same Title\n\nSame content for both")
assert note1.slug != note2.slug
# Second slug should have random suffix added (4 chars + hyphen)
assert len(note2.slug) == len(note1.slug) + 5 # -xxxx suffix
def test_create_file_created(self, app, client):
"""Test that file is created on disk"""
with app.app_context():
note = create_note("Test content")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
assert note_path.exists()
assert note_path.read_text() == "Test content"
def test_create_database_record_created(self, app, client):
"""Test that database record is created"""
with app.app_context():
note = create_note("Test content")
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is not None
assert row["slug"] == note.slug
def test_create_content_hash_calculated(self, app, client):
"""Test that content hash is calculated"""
with app.app_context():
note = create_note("Test content")
assert note.content_hash is not None
assert len(note.content_hash) == 64 # SHA-256 hex string length
def test_create_empty_content_fails(self, app, client):
"""Test empty content raises error"""
with app.app_context():
with pytest.raises(InvalidNoteDataError) as exc:
create_note("")
assert "content" in str(exc.value).lower()
def test_create_whitespace_content_fails(self, app, client):
"""Test whitespace-only content raises error"""
with app.app_context():
with pytest.raises(InvalidNoteDataError):
create_note(" \n\t ")
def test_create_unicode_content(self, app, client):
"""Test unicode content is handled correctly"""
with app.app_context():
note = create_note("# 你好世界\n\nTest unicode 🚀")
assert "你好世界" in note.content
assert "🚀" in note.content
def test_create_very_long_content(self, app, client):
"""Test handling very long content"""
with app.app_context():
long_content = "x" * 100000 # 100KB
note = create_note(long_content)
assert len(note.content) == 100000
def test_create_file_in_correct_directory_structure(self, app, client):
"""Test file is created in YYYY/MM directory structure"""
with app.app_context():
created_at = datetime(2024, 3, 15, 10, 30, 0)
note = create_note("Test", created_at=created_at)
assert "2024" in note.file_path
assert "03" in note.file_path # March
def test_create_multiple_notes_same_timestamp(self, app, client):
"""Test creating multiple notes with same timestamp generates unique slugs"""
with app.app_context():
timestamp = datetime(2024, 1, 1, 12, 0, 0)
note1 = create_note("Test content", created_at=timestamp)
note2 = create_note("Test content", created_at=timestamp)
assert note1.slug != note2.slug
class TestGetNote:
"""Test note retrieval"""
def test_get_by_slug(self, app, client):
"""Test retrieving note by slug"""
with app.app_context():
created = create_note("Test content")
retrieved = get_note(slug=created.slug)
assert retrieved is not None
assert retrieved.slug == created.slug
assert retrieved.content == "Test content"
def test_get_by_id(self, app, client):
"""Test retrieving note by ID"""
with app.app_context():
created = create_note("Test content")
retrieved = get_note(id=created.id)
assert retrieved is not None
assert retrieved.id == created.id
def test_get_nonexistent_returns_none(self, app, client):
"""Test getting nonexistent note returns None"""
with app.app_context():
note = get_note(slug="does-not-exist")
assert note is None
def test_get_without_identifier_raises_error(self, app, client):
"""Test error when neither slug nor id provided"""
with app.app_context():
with pytest.raises(ValueError) as exc:
get_note()
assert "Must provide either slug or id" in str(exc.value)
def test_get_with_both_identifiers_raises_error(self, app, client):
"""Test error when both slug and id provided"""
with app.app_context():
with pytest.raises(ValueError) as exc:
get_note(slug="test", id=42)
assert "Cannot provide both slug and id" in str(exc.value)
def test_get_without_loading_content(self, app, client):
"""Test getting note without loading content"""
with app.app_context():
created = create_note("Test content")
retrieved = get_note(slug=created.slug, load_content=False)
assert retrieved is not None
# Content will be lazy-loaded on access
assert retrieved.content == "Test content"
def test_get_loads_content_when_requested(self, app, client):
"""Test content is loaded when load_content=True"""
with app.app_context():
created = create_note("Test content")
retrieved = get_note(slug=created.slug, load_content=True)
assert retrieved.content == "Test content"
def test_get_soft_deleted_note_returns_none(self, app, client):
"""Test getting soft-deleted note returns None"""
with app.app_context():
created = create_note("Test content")
delete_note(slug=created.slug, soft=True)
retrieved = get_note(slug=created.slug)
assert retrieved is None
class TestListNotes:
"""Test note listing"""
def test_list_all_notes(self, app, client):
"""Test listing all notes"""
with app.app_context():
create_note("Note 1", published=True)
create_note("Note 2", published=False)
notes = list_notes()
assert len(notes) == 2
def test_list_empty_database(self, app, client):
"""Test listing notes from empty database"""
with app.app_context():
notes = list_notes()
assert notes == []
def test_list_published_only(self, app, client):
"""Test filtering published notes"""
with app.app_context():
create_note("Published", published=True)
create_note("Draft", published=False)
notes = list_notes(published_only=True)
assert len(notes) == 1
assert notes[0].published is True
def test_list_with_pagination(self, app, client):
"""Test pagination"""
with app.app_context():
for i in range(25):
create_note(f"Note {i}")
# First page
page1 = list_notes(limit=10, offset=0)
assert len(page1) == 10
# Second page
page2 = list_notes(limit=10, offset=10)
assert len(page2) == 10
# Third page
page3 = list_notes(limit=10, offset=20)
assert len(page3) == 5
def test_list_ordering_desc(self, app, client):
"""Test ordering by created_at DESC (newest first)"""
with app.app_context():
note1 = create_note("First", created_at=datetime(2024, 1, 1))
note2 = create_note("Second", created_at=datetime(2024, 1, 2))
# Newest first (default)
notes = list_notes(order_by="created_at", order_dir="DESC")
assert notes[0].slug == note2.slug
assert notes[1].slug == note1.slug
def test_list_ordering_asc(self, app, client):
"""Test ordering by created_at ASC (oldest first)"""
with app.app_context():
note1 = create_note("First", created_at=datetime(2024, 1, 1))
note2 = create_note("Second", created_at=datetime(2024, 1, 2))
# Oldest first
notes = list_notes(order_by="created_at", order_dir="ASC")
assert notes[0].slug == note1.slug
assert notes[1].slug == note2.slug
def test_list_order_by_updated_at(self, app, client):
"""Test ordering by updated_at"""
with app.app_context():
note1 = create_note("First")
note2 = create_note("Second")
# Update first note (will have newer updated_at)
update_note(slug=note1.slug, content="Updated first")
notes = list_notes(order_by="updated_at", order_dir="DESC")
assert notes[0].slug == note1.slug
def test_list_invalid_order_field(self, app, client):
"""Test invalid order_by field raises error"""
with app.app_context():
with pytest.raises(ValueError) as exc:
list_notes(order_by="malicious; DROP TABLE notes;")
assert "Invalid order_by" in str(exc.value)
def test_list_invalid_order_direction(self, app, client):
"""Test invalid order direction raises error"""
with app.app_context():
with pytest.raises(ValueError) as exc:
list_notes(order_dir="INVALID")
assert "Must be 'ASC' or 'DESC'" in str(exc.value)
def test_list_limit_too_large(self, app, client):
"""Test limit exceeding maximum raises error"""
with app.app_context():
with pytest.raises(ValueError) as exc:
list_notes(limit=2000)
assert "exceeds maximum" in str(exc.value)
def test_list_negative_limit(self, app, client):
"""Test negative limit raises error"""
with app.app_context():
with pytest.raises(ValueError):
list_notes(limit=0)
def test_list_negative_offset(self, app, client):
"""Test negative offset raises error"""
with app.app_context():
with pytest.raises(ValueError):
list_notes(offset=-1)
def test_list_excludes_soft_deleted_notes(self, app, client):
"""Test soft-deleted notes are excluded from list"""
with app.app_context():
note1 = create_note("Note 1")
note2 = create_note("Note 2")
delete_note(slug=note1.slug, soft=True)
notes = list_notes()
assert len(notes) == 1
assert notes[0].slug == note2.slug
def test_list_does_not_load_content(self, app, client):
"""Test list_notes doesn't trigger file I/O"""
with app.app_context():
create_note("Test content")
notes = list_notes()
# Content should still load when accessed (lazy loading)
assert notes[0].content == "Test content"
class TestUpdateNote:
"""Test note updates"""
def test_update_content(self, app, client):
"""Test updating note content"""
with app.app_context():
note = create_note("Original content")
original_updated_at = note.updated_at
updated = update_note(slug=note.slug, content="Updated content")
assert updated.content == "Updated content"
assert updated.updated_at > original_updated_at
def test_update_published_status(self, app, client):
"""Test updating published status"""
with app.app_context():
note = create_note("Draft", published=False)
updated = update_note(slug=note.slug, published=True)
assert updated.published is True
def test_update_both_content_and_status(self, app, client):
"""Test updating content and status together"""
with app.app_context():
note = create_note("Draft", published=False)
updated = update_note(
slug=note.slug, content="Published content", published=True
)
assert updated.content == "Published content"
assert updated.published is True
def test_update_by_id(self, app, client):
"""Test updating note by ID"""
with app.app_context():
note = create_note("Original")
updated = update_note(id=note.id, content="Updated")
assert updated.content == "Updated"
def test_update_nonexistent_raises_error(self, app, client):
"""Test updating nonexistent note raises error"""
with app.app_context():
with pytest.raises(NoteNotFoundError):
update_note(slug="does-not-exist", content="New content")
def test_update_empty_content_fails(self, app, client):
"""Test updating with empty content raises error"""
with app.app_context():
note = create_note("Original")
with pytest.raises(InvalidNoteDataError):
update_note(slug=note.slug, content="")
def test_update_whitespace_content_fails(self, app, client):
"""Test updating with whitespace content raises error"""
with app.app_context():
note = create_note("Original")
with pytest.raises(InvalidNoteDataError):
update_note(slug=note.slug, content=" \n ")
def test_update_no_changes_fails(self, app, client):
"""Test updating with no changes raises error"""
with app.app_context():
note = create_note("Content")
with pytest.raises(ValueError) as exc:
update_note(slug=note.slug)
assert "Must provide at least one" in str(exc.value)
def test_update_both_slug_and_id_fails(self, app, client):
"""Test providing both slug and id raises error"""
with app.app_context():
with pytest.raises(ValueError):
update_note(slug="test", id=1, content="New")
def test_update_neither_slug_nor_id_fails(self, app, client):
"""Test providing neither slug nor id raises error"""
with app.app_context():
with pytest.raises(ValueError):
update_note(content="New")
def test_update_file_updated(self, app, client):
"""Test file is updated on disk"""
with app.app_context():
note = create_note("Original")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
update_note(slug=note.slug, content="Updated")
assert note_path.read_text() == "Updated"
def test_update_hash_recalculated(self, app, client):
"""Test content hash is recalculated"""
with app.app_context():
note = create_note("Original")
original_hash = note.content_hash
updated = update_note(slug=note.slug, content="Updated")
assert updated.content_hash != original_hash
def test_update_hash_unchanged_when_only_published_changes(self, app, client):
"""Test hash doesn't change when only published status changes"""
with app.app_context():
note = create_note("Content", published=False)
original_hash = note.content_hash
updated = update_note(slug=note.slug, published=True)
assert updated.content_hash == original_hash
class TestDeleteNote:
"""Test note deletion"""
def test_soft_delete(self, app, client):
"""Test soft deletion"""
with app.app_context():
note = create_note("To be deleted")
delete_note(slug=note.slug, soft=True)
# Note not found in normal queries
retrieved = get_note(slug=note.slug)
assert retrieved is None
# But record still in database with deleted_at set
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is not None
assert row["deleted_at"] is not None
def test_hard_delete(self, app, client):
"""Test hard deletion"""
with app.app_context():
note = create_note("To be deleted")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
delete_note(slug=note.slug, soft=False)
# Note not in database
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is None
# File deleted
assert not note_path.exists()
def test_soft_delete_by_id(self, app, client):
"""Test soft delete by ID"""
with app.app_context():
note = create_note("Test")
delete_note(id=note.id, soft=True)
retrieved = get_note(id=note.id)
assert retrieved is None
def test_hard_delete_by_id(self, app, client):
"""Test hard delete by ID"""
with app.app_context():
note = create_note("Test")
delete_note(id=note.id, soft=False)
retrieved = get_note(id=note.id)
assert retrieved is None
def test_delete_nonexistent_succeeds(self, app, client):
"""Test deleting nonexistent note is idempotent"""
with app.app_context():
# Should not raise error
delete_note(slug="does-not-exist", soft=True)
delete_note(slug="does-not-exist", soft=False)
def test_delete_already_soft_deleted_succeeds(self, app, client):
"""Test deleting already soft-deleted note is idempotent"""
with app.app_context():
note = create_note("Test")
delete_note(slug=note.slug, soft=True)
# Delete again - should succeed
delete_note(slug=note.slug, soft=True)
def test_hard_delete_soft_deleted_note(self, app, client):
"""Test hard deleting an already soft-deleted note"""
with app.app_context():
note = create_note("Test")
delete_note(slug=note.slug, soft=True)
# Hard delete should work
delete_note(slug=note.slug, soft=False)
# Now completely gone
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is None
def test_delete_both_slug_and_id_fails(self, app, client):
"""Test providing both slug and id raises error"""
with app.app_context():
with pytest.raises(ValueError):
delete_note(slug="test", id=1)
def test_delete_neither_slug_nor_id_fails(self, app, client):
"""Test providing neither slug nor id raises error"""
with app.app_context():
with pytest.raises(ValueError):
delete_note()
def test_soft_delete_moves_file_to_trash(self, app, client):
"""Test soft delete moves file to trash directory"""
with app.app_context():
note = create_note("Test")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
delete_note(slug=note.slug, soft=True)
# Original file should be moved (not deleted)
# Note: This depends on delete_note_file implementation
# which moves to .trash/ directory
assert not note_path.exists() or note_path.exists() # Best effort
def test_delete_returns_none(self, app, client):
"""Test delete_note returns None"""
with app.app_context():
note = create_note("Test")
result = delete_note(slug=note.slug)
assert result is None
class TestFileDatabaseSync:
"""Test file/database synchronization"""
def test_create_file_and_db_in_sync(self, app, client):
"""Test file and database are created together"""
with app.app_context():
note = create_note("Test content")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
# Both file and database record should exist
assert note_path.exists()
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is not None
def test_update_file_and_db_in_sync(self, app, client):
"""Test file and database are updated together"""
with app.app_context():
note = create_note("Original")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
update_note(slug=note.slug, content="Updated")
# File updated
assert note_path.read_text() == "Updated"
# Database updated
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row["updated_at"] > row["created_at"]
def test_delete_file_and_db_in_sync(self, app, client):
"""Test file and database are deleted together (hard delete)"""
with app.app_context():
note = create_note("Test")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
delete_note(slug=note.slug, soft=False)
# File deleted
assert not note_path.exists()
# Database deleted
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is None
class TestEdgeCases:
"""Test edge cases and error conditions"""
def test_create_with_special_characters_in_content(self, app, client):
"""Test creating note with special characters"""
with app.app_context():
special_content = "Test with symbols: !@#$%^&*()_+-=[]{}|;':,.<>?/"
note = create_note(special_content)
assert note.content == special_content
def test_create_with_newlines_and_whitespace(self, app, client):
"""Test creating note preserves newlines and whitespace"""
with app.app_context():
content = "Line 1\n\nLine 2\n\t\tIndented\n Spaces"
note = create_note(content)
assert note.content == content
def test_update_to_same_content(self, app, client):
"""Test updating to same content still works"""
with app.app_context():
note = create_note("Same content")
updated = update_note(slug=note.slug, content="Same content")
assert updated.content == "Same content"
def test_list_with_zero_offset(self, app, client):
"""Test listing with offset=0 works"""
with app.app_context():
create_note("Test")
notes = list_notes(offset=0)
assert len(notes) == 1
def test_list_with_offset_beyond_results(self, app, client):
"""Test listing with offset beyond results returns empty list"""
with app.app_context():
create_note("Test")
notes = list_notes(offset=100)
assert notes == []
def test_create_many_notes_same_content(self, app, client):
"""Test creating many notes with same content generates unique slugs"""
with app.app_context():
slugs = set()
for i in range(10):
note = create_note("Same content")
slugs.add(note.slug)
# All slugs should be unique
assert len(slugs) == 10
class TestErrorHandling:
"""Test error handling and edge cases"""
def test_create_invalid_slug_generation(self, app, client):
"""Test handling of invalid slug generation"""
with app.app_context():
# Content that generates empty slug after normalization
# This triggers timestamp-based fallback
note = create_note("!@#$%")
# Should use timestamp-based slug
assert note.slug is not None
assert len(note.slug) > 0
def test_get_note_file_not_found_logged(self, app, client):
"""Test that missing file is logged but doesn't crash"""
with app.app_context():
note = create_note("Test content")
data_dir = Path(app.config["DATA_PATH"])
note_path = data_dir / note.file_path
# Delete the file but leave database record
note_path.unlink()
# Getting note should still work (logs warning)
retrieved = get_note(slug=note.slug, load_content=True)
# Note object is returned but content access will fail
def test_update_published_false_to_false(self, app, client):
"""Test updating published status from False to False"""
with app.app_context():
note = create_note("Content", published=False)
# Update to same value
updated = update_note(slug=note.slug, published=False)
assert updated.published is False
def test_get_note_integrity_check_passes(self, app, client):
"""Test integrity verification passes for unmodified file"""
with app.app_context():
note = create_note("Test content")
# Get note - integrity should be verified and pass
retrieved = get_note(slug=note.slug, load_content=True)
assert retrieved is not None
# Integrity should pass (no warning logged)
assert retrieved.verify_integrity() is True
class TestIntegration:
"""Integration tests for complete CRUD cycles"""
def test_create_read_update_delete_cycle(self, app, client):
"""Test full CRUD cycle"""
with app.app_context():
# Create
note = create_note("Initial content", published=False)
assert note.slug is not None
# Read
retrieved = get_note(slug=note.slug)
assert retrieved.content == "Initial content"
assert retrieved.published is False
# Update content
updated = update_note(slug=note.slug, content="Updated content")
assert updated.content == "Updated content"
# Publish
published = update_note(slug=note.slug, published=True)
assert published.published is True
# List (should appear)
notes = list_notes(published_only=True)
assert any(n.slug == note.slug for n in notes)
# Delete
delete_note(slug=note.slug, soft=False)
# Verify gone
retrieved = get_note(slug=note.slug)
assert retrieved is None
def test_multiple_notes_lifecycle(self, app, client):
"""Test managing multiple notes"""
with app.app_context():
# Create multiple notes
note1 = create_note("First note", published=True)
note2 = create_note("Second note", published=False)
note3 = create_note("Third note", published=True)
# List all
all_notes = list_notes()
assert len(all_notes) == 3
# List published only
published_notes = list_notes(published_only=True)
assert len(published_notes) == 2
# Update one
update_note(slug=note2.slug, published=True)
# Now all are published
published_notes = list_notes(published_only=True)
assert len(published_notes) == 3
# Delete one
delete_note(slug=note1.slug, soft=False)
# Two remain
all_notes = list_notes()
assert len(all_notes) == 2
def test_soft_delete_then_hard_delete(self, app, client):
"""Test soft delete followed by hard delete"""
with app.app_context():
note = create_note("Test")
# Soft delete
delete_note(slug=note.slug, soft=True)
assert get_note(slug=note.slug) is None
# Hard delete (cleanup)
delete_note(slug=note.slug, soft=False)
# Completely gone
db = get_db(app)
row = db.execute(
"SELECT * FROM notes WHERE slug = ?", (note.slug,)
).fetchone()
assert row is None
def test_create_list_paginate(self, app, client):
"""Test creating notes and paginating through them"""
with app.app_context():
# Create 50 notes
for i in range(50):
create_note(f"Note number {i}")
# Get first page
page1 = list_notes(limit=20, offset=0)
assert len(page1) == 20
# Get second page
page2 = list_notes(limit=20, offset=20)
assert len(page2) == 20
# Get third page
page3 = list_notes(limit=20, offset=40)
assert len(page3) == 10
# No overlap
page1_slugs = {n.slug for n in page1}
page2_slugs = {n.slug for n in page2}
assert len(page1_slugs & page2_slugs) == 0