""" 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