that initial commit

This commit is contained in:
2025-11-18 19:21:31 -07:00
commit a68fd570c7
69 changed files with 31070 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Test package initialization

48
tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
"""
Pytest configuration and fixtures for StarPunk tests
"""
import pytest
import tempfile
from pathlib import Path
from starpunk import create_app
from starpunk.database import init_db
@pytest.fixture
def app():
"""Create test Flask application"""
# Create temporary directory for test data
temp_dir = tempfile.mkdtemp()
temp_path = Path(temp_dir)
# Test configuration
config = {
'TESTING': True,
'DEBUG': False,
'DATA_PATH': temp_path,
'NOTES_PATH': temp_path / 'notes',
'DATABASE_PATH': temp_path / 'test.db',
'SESSION_SECRET': 'test-secret-key',
'ADMIN_ME': 'https://test.example.com',
'SITE_URL': 'http://localhost:5000',
}
# Create app with test config
app = create_app(config)
yield app
# Cleanup (optional - temp dir will be cleaned up by OS)
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def runner(app):
"""Create test CLI runner"""
return app.test_cli_runner()

859
tests/test_models.py Normal file
View File

@@ -0,0 +1,859 @@
"""
Tests for data models
Organized by model:
- Note model tests
- Session model tests
- Token model tests
- AuthState model tests
"""
import pytest
from datetime import datetime, timedelta
from pathlib import Path
from starpunk.models import Note, Session, Token, AuthState
from starpunk.utils import calculate_content_hash
class TestNoteModel:
"""Test Note model"""
def test_from_row_with_dict(self, tmp_path):
"""Test creating Note from dictionary row"""
row = {
"id": 1,
"slug": "test-note",
"file_path": "notes/2024/11/test-note.md",
"published": 1,
"created_at": datetime(2024, 11, 18, 14, 30),
"updated_at": datetime(2024, 11, 18, 14, 30),
"content_hash": "abc123",
}
note = Note.from_row(row, tmp_path)
assert note.id == 1
assert note.slug == "test-note"
assert note.file_path == "notes/2024/11/test-note.md"
assert note.published is True
assert note.content_hash == "abc123"
def test_from_row_with_string_timestamps(self, tmp_path):
"""Test creating Note with ISO timestamp strings"""
row = {
"id": 1,
"slug": "test-note",
"file_path": "notes/2024/11/test-note.md",
"published": True,
"created_at": "2024-11-18T14:30:00Z",
"updated_at": "2024-11-18T14:30:00Z",
"content_hash": None,
}
note = Note.from_row(row, tmp_path)
# Check year, month, day, hour, minute (ignore timezone)
assert note.created_at.year == 2024
assert note.created_at.month == 11
assert note.created_at.day == 18
assert note.created_at.hour == 14
assert note.created_at.minute == 30
def test_content_lazy_loading(self, tmp_path):
"""Test content is lazy-loaded from file"""
# Create test note file
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("# Test Note\n\nContent here.")
# Create note instance
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
# Content should be loaded on first access
content = note.content
assert "# Test Note" in content
assert "Content here." in content
# Second access should return cached value
content2 = note.content
assert content2 == content
def test_content_file_not_found(self, tmp_path):
"""Test content raises FileNotFoundError if file missing"""
note = Note(
id=1,
slug="missing",
file_path="notes/2024/11/missing.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
with pytest.raises(FileNotFoundError):
_ = note.content
def test_html_rendering(self, tmp_path):
"""Test HTML rendering with caching"""
# Create test note
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("# Heading\n\nParagraph here.")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
html = note.html
# Check for common markdown-rendered elements
assert "<h1" in html # Heading
assert "Paragraph here." in html
def test_html_caching(self, tmp_path):
"""Test HTML is cached after first render"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("Content")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
html1 = note.html
html2 = note.html
assert html1 == html2
def test_title_extraction_from_heading(self, tmp_path):
"""Test title extraction from markdown heading"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("# My Note Title\n\nContent.")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
assert note.title == "My Note Title"
def test_title_extraction_from_first_line(self, tmp_path):
"""Test title extraction from first line without heading"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("Just a note without heading\n\nMore content.")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
assert note.title == "Just a note without heading"
def test_title_fallback_to_slug(self):
"""Test title falls back to slug if no content"""
note = Note(
id=1,
slug="my-test-note",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=Path("/nonexistent"),
)
# Should fall back to slug (file doesn't exist)
assert note.title == "my-test-note"
def test_excerpt_generation(self, tmp_path):
"""Test excerpt generation"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
content = "# Title\n\n" + ("This is a test. " * 50) # Long content
note_file.write_text(content)
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
excerpt = note.excerpt
assert len(excerpt) <= 203 # EXCERPT_LENGTH + "..."
assert excerpt.endswith("...")
def test_excerpt_short_content(self, tmp_path):
"""Test excerpt with short content (no ellipsis)"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("# Title\n\nShort content.")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
excerpt = note.excerpt
assert "Short content." in excerpt
assert not excerpt.endswith("...")
def test_excerpt_fallback_to_slug(self):
"""Test excerpt falls back to slug if file missing"""
note = Note(
id=1,
slug="my-note",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=Path("/nonexistent"),
)
assert note.excerpt == "my-note"
def test_permalink(self):
"""Test permalink generation"""
note = Note(
id=1,
slug="my-note",
file_path="notes/2024/11/my-note.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=Path("data"),
)
assert note.permalink == "/note/my-note"
def test_is_published_property(self):
"""Test is_published property"""
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=Path("data"),
)
assert note.is_published is True
assert note.is_published == note.published
def test_to_dict_basic(self):
"""Test serialization to dictionary"""
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime(2024, 11, 18, 14, 30),
updated_at=datetime(2024, 11, 18, 14, 30),
_data_dir=Path("data"),
)
data = note.to_dict()
assert data["id"] == 1
assert data["slug"] == "test"
assert data["published"] is True
assert data["permalink"] == "/note/test"
assert "content" not in data # Not included by default
assert "html" not in data # Not included by default
def test_to_dict_with_content(self, tmp_path):
"""Test serialization includes content when requested"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("Test content")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
data = note.to_dict(include_content=True)
assert "content" in data
assert data["content"] == "Test content"
def test_to_dict_with_html(self, tmp_path):
"""Test serialization includes HTML when requested"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("# Test")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
_data_dir=tmp_path,
)
data = note.to_dict(include_html=True)
assert "html" in data
assert "<h1" in data["html"]
def test_verify_integrity_success(self, tmp_path):
"""Test content integrity verification succeeds"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
content = "Test content"
note_file.write_text(content)
# Calculate correct hash
content_hash = calculate_content_hash(content)
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
content_hash=content_hash,
_data_dir=tmp_path,
)
# Should verify successfully
assert note.verify_integrity() is True
def test_verify_integrity_failure(self, tmp_path):
"""Test content integrity verification fails when modified"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
content = "Test content"
note_file.write_text(content)
# Calculate hash of original content
content_hash = calculate_content_hash(content)
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
content_hash=content_hash,
_data_dir=tmp_path,
)
# Modify file
note_file.write_text("Modified content")
# Should fail verification
assert note.verify_integrity() is False
def test_verify_integrity_no_hash(self, tmp_path):
"""Test integrity verification fails if no hash stored"""
note_file = tmp_path / "notes" / "2024" / "11" / "test.md"
note_file.parent.mkdir(parents=True)
note_file.write_text("Content")
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
content_hash=None,
_data_dir=tmp_path,
)
# Should return False (no hash to verify against)
assert note.verify_integrity() is False
def test_verify_integrity_file_not_found(self):
"""Test integrity verification fails if file missing"""
note = Note(
id=1,
slug="test",
file_path="notes/2024/11/test.md",
published=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
content_hash="abc123",
_data_dir=Path("/nonexistent"),
)
assert note.verify_integrity() is False
class TestSessionModel:
"""Test Session model"""
def test_from_row_with_dict(self):
"""Test creating Session from dictionary row"""
row = {
"id": 1,
"session_token": "abc123",
"me": "https://alice.example.com",
"created_at": datetime(2024, 11, 18, 14, 30),
"expires_at": datetime(2024, 12, 18, 14, 30),
"last_used_at": None,
}
session = Session.from_row(row)
assert session.id == 1
assert session.session_token == "abc123"
assert session.me == "https://alice.example.com"
def test_from_row_with_string_timestamps(self):
"""Test creating Session with ISO timestamp strings"""
row = {
"id": 1,
"session_token": "abc123",
"me": "https://alice.example.com",
"created_at": "2024-11-18T14:30:00Z",
"expires_at": "2024-12-18T14:30:00Z",
"last_used_at": "2024-11-18T15:00:00Z",
}
session = Session.from_row(row)
# Check timestamps were parsed correctly (ignore timezone)
assert session.created_at.year == 2024
assert session.created_at.month == 11
assert session.expires_at.month == 12
assert session.last_used_at.hour == 15
def test_is_expired_false(self):
"""Test is_expired returns False for active session"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
)
assert session.is_expired is False
assert session.is_active is True
def test_is_expired_true(self):
"""Test is_expired returns True for expired session"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow() - timedelta(days=31),
expires_at=datetime.utcnow() - timedelta(days=1),
)
assert session.is_expired is True
assert session.is_active is False
def test_age(self):
"""Test age calculation"""
created = datetime.utcnow() - timedelta(hours=2)
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=created,
expires_at=datetime.utcnow() + timedelta(days=30),
)
age = session.age
# Age should be at least 2 hours (account for test execution time)
assert age.total_seconds() >= 7200
def test_time_until_expiry(self):
"""Test time until expiry calculation"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
)
time_left = session.time_until_expiry
# Should be approximately 30 days
assert time_left.days >= 29
def test_time_until_expiry_negative(self):
"""Test time until expiry is negative for expired session"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow() - timedelta(days=31),
expires_at=datetime.utcnow() - timedelta(days=1),
)
time_left = session.time_until_expiry
assert time_left.total_seconds() < 0
def test_is_valid_active_session(self):
"""Test validation succeeds for active session"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
)
assert session.is_valid() is True
def test_is_valid_expired_session(self):
"""Test validation fails for expired session"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow() - timedelta(days=31),
expires_at=datetime.utcnow() - timedelta(days=1),
)
assert session.is_valid() is False
def test_is_valid_empty_token(self):
"""Test validation fails for empty token"""
session = Session(
id=1,
session_token="",
me="https://alice.example.com",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
)
assert session.is_valid() is False
def test_is_valid_empty_me(self):
"""Test validation fails for empty me URL"""
session = Session(
id=1,
session_token="abc123",
me="",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
)
assert session.is_valid() is False
def test_with_updated_last_used(self):
"""Test creating session with updated timestamp"""
original = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=30),
last_used_at=None,
)
updated = original.with_updated_last_used()
assert updated.last_used_at is not None
assert updated.session_token == original.session_token
assert updated.id == original.id
def test_to_dict(self):
"""Test serialization to dictionary"""
session = Session(
id=1,
session_token="abc123",
me="https://alice.example.com",
created_at=datetime(2024, 11, 18, 14, 30),
expires_at=datetime(2024, 12, 18, 14, 30),
)
data = session.to_dict()
assert data["id"] == 1
assert data["me"] == "https://alice.example.com"
assert "session_token" not in data # Excluded for security
assert "is_active" in data
class TestTokenModel:
"""Test Token model"""
def test_from_row_with_dict(self):
"""Test creating Token from dictionary row"""
row = {
"token": "xyz789",
"me": "https://alice.example.com",
"client_id": "https://quill.p3k.io",
"scope": "create update",
"created_at": datetime(2024, 11, 18, 14, 30),
"expires_at": None,
}
token = Token.from_row(row)
assert token.token == "xyz789"
assert token.me == "https://alice.example.com"
assert token.scope == "create update"
def test_from_row_with_string_timestamps(self):
"""Test creating Token with ISO timestamp strings"""
row = {
"token": "xyz789",
"me": "https://alice.example.com",
"client_id": None,
"scope": "create",
"created_at": "2024-11-18T14:30:00Z",
"expires_at": "2025-02-18T14:30:00Z",
}
token = Token.from_row(row)
# Check timestamps were parsed correctly (ignore timezone)
assert token.created_at.year == 2024
assert token.created_at.month == 11
assert token.expires_at.year == 2025
assert token.expires_at.month == 2
def test_scopes_property(self):
"""Test scope parsing"""
token = Token(
token="xyz789",
me="https://alice.example.com",
scope="create update delete",
)
assert token.scopes == ["create", "update", "delete"]
def test_scopes_empty(self):
"""Test empty scope"""
token = Token(token="xyz789", me="https://alice.example.com", scope=None)
assert token.scopes == []
def test_scopes_whitespace(self):
"""Test scope with multiple spaces"""
token = Token(
token="xyz789", me="https://alice.example.com", scope="create update"
)
scopes = token.scopes
assert "create" in scopes
assert "update" in scopes
def test_has_scope_true(self):
"""Test scope checking returns True"""
token = Token(
token="xyz789", me="https://alice.example.com", scope="create update"
)
assert token.has_scope("create") is True
assert token.has_scope("update") is True
def test_has_scope_false(self):
"""Test scope checking returns False"""
token = Token(
token="xyz789", me="https://alice.example.com", scope="create update"
)
assert token.has_scope("delete") is False
def test_has_scope_empty(self):
"""Test scope checking with no scopes"""
token = Token(token="xyz789", me="https://alice.example.com", scope=None)
assert token.has_scope("create") is False
def test_is_expired_never_expires(self):
"""Test token with no expiry"""
token = Token(token="xyz789", me="https://alice.example.com", expires_at=None)
assert token.is_expired is False
assert token.is_active is True
def test_is_expired_with_future_expiry(self):
"""Test token with future expiry"""
token = Token(
token="xyz789",
me="https://alice.example.com",
expires_at=datetime.utcnow() + timedelta(days=90),
)
assert token.is_expired is False
assert token.is_active is True
def test_is_expired_with_past_expiry(self):
"""Test token with past expiry"""
token = Token(
token="xyz789",
me="https://alice.example.com",
expires_at=datetime.utcnow() - timedelta(days=1),
)
assert token.is_expired is True
assert token.is_active is False
def test_is_valid_active_token(self):
"""Test validation succeeds for active token"""
token = Token(token="xyz789", me="https://alice.example.com", scope="create")
assert token.is_valid() is True
def test_is_valid_expired_token(self):
"""Test validation fails for expired token"""
token = Token(
token="xyz789",
me="https://alice.example.com",
expires_at=datetime.utcnow() - timedelta(days=1),
)
assert token.is_valid() is False
def test_is_valid_empty_token(self):
"""Test validation fails for empty token"""
token = Token(token="", me="https://alice.example.com")
assert token.is_valid() is False
def test_is_valid_with_required_scope_success(self):
"""Test validation with required scope succeeds"""
token = Token(
token="xyz789", me="https://alice.example.com", scope="create update"
)
assert token.is_valid(required_scope="create") is True
def test_is_valid_with_required_scope_failure(self):
"""Test validation with required scope fails"""
token = Token(
token="xyz789", me="https://alice.example.com", scope="create update"
)
assert token.is_valid(required_scope="delete") is False
def test_to_dict(self):
"""Test serialization to dictionary"""
token = Token(
token="xyz789",
me="https://alice.example.com",
client_id="https://quill.p3k.io",
scope="create update",
created_at=datetime(2024, 11, 18, 14, 30),
)
data = token.to_dict()
assert data["me"] == "https://alice.example.com"
assert data["client_id"] == "https://quill.p3k.io"
assert data["scope"] == "create update"
assert "token" not in data # Excluded for security
assert "is_active" in data
def test_to_dict_with_expires_at(self):
"""Test serialization includes expires_at if set"""
token = Token(
token="xyz789",
me="https://alice.example.com",
created_at=datetime(2024, 11, 18, 14, 30),
expires_at=datetime(2025, 2, 18, 14, 30),
)
data = token.to_dict()
assert "expires_at" in data
class TestAuthStateModel:
"""Test AuthState model"""
def test_from_row_with_dict(self):
"""Test creating AuthState from dictionary row"""
row = {
"state": "random123",
"created_at": datetime(2024, 11, 18, 14, 30),
"expires_at": datetime(2024, 11, 18, 14, 35),
}
auth_state = AuthState.from_row(row)
assert auth_state.state == "random123"
def test_from_row_with_string_timestamps(self):
"""Test creating AuthState with ISO timestamp strings"""
row = {
"state": "random123",
"created_at": "2024-11-18T14:30:00Z",
"expires_at": "2024-11-18T14:35:00Z",
}
auth_state = AuthState.from_row(row)
# Check timestamps were parsed correctly (ignore timezone)
assert auth_state.created_at.year == 2024
assert auth_state.created_at.minute == 30
assert auth_state.expires_at.minute == 35
def test_is_expired_false(self):
"""Test is_expired returns False for active state"""
auth_state = AuthState(
state="random123",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
assert auth_state.is_expired is False
assert auth_state.is_active is True
def test_is_expired_true(self):
"""Test is_expired returns True for expired state"""
auth_state = AuthState(
state="random123",
created_at=datetime.utcnow() - timedelta(minutes=10),
expires_at=datetime.utcnow() - timedelta(minutes=5),
)
assert auth_state.is_expired is True
assert auth_state.is_active is False
def test_age(self):
"""Test age calculation"""
created = datetime.utcnow() - timedelta(minutes=2)
auth_state = AuthState(
state="random123",
created_at=created,
expires_at=datetime.utcnow() + timedelta(minutes=3),
)
age = auth_state.age
# Age should be at least 2 minutes
assert age.total_seconds() >= 120
def test_is_valid_active_state(self):
"""Test validation succeeds for active state"""
auth_state = AuthState(
state="random123",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
assert auth_state.is_valid() is True
def test_is_valid_expired_state(self):
"""Test validation fails for expired state"""
auth_state = AuthState(
state="random123",
created_at=datetime.utcnow() - timedelta(minutes=10),
expires_at=datetime.utcnow() - timedelta(minutes=5),
)
assert auth_state.is_valid() is False
def test_is_valid_empty_state(self):
"""Test validation fails for empty state"""
auth_state = AuthState(
state="",
created_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
assert auth_state.is_valid() is False
def test_to_dict(self):
"""Test serialization to dictionary"""
auth_state = AuthState(
state="random123",
created_at=datetime(2024, 11, 18, 14, 30),
expires_at=datetime(2024, 11, 18, 14, 35),
)
data = auth_state.to_dict()
assert "created_at" in data
assert "expires_at" in data
assert "is_active" in data
assert "state" not in data # State value not included in dict

921
tests/test_notes.py Normal file
View File

@@ -0,0 +1,921 @@
"""
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

863
tests/test_utils.py Normal file
View File

@@ -0,0 +1,863 @@
"""
Tests for utility functions
Organized by function category:
- Slug generation tests
- Helper function tests
- Edge cases and security tests
"""
import pytest
from datetime import datetime
from pathlib import Path
from starpunk.utils import (
# Helper functions
extract_first_words,
normalize_slug_text,
generate_random_suffix,
# Slug functions
generate_slug,
make_slug_unique,
validate_slug,
# Content hashing
calculate_content_hash,
# Path operations
generate_note_path,
ensure_note_directory,
validate_note_path,
# File operations
write_note_file,
read_note_file,
delete_note_file,
# Date/time utilities
format_rfc822,
format_iso8601,
parse_iso8601,
# Constants
RANDOM_SUFFIX_LENGTH,
MAX_SLUG_LENGTH,
TRASH_DIR_NAME,
)
class TestHelperFunctions:
"""Test helper functions used by slug generation"""
def test_extract_first_words_default(self):
"""Test extracting first 5 words (default)."""
text = "Hello world this is a test note content"
result = extract_first_words(text)
assert result == "Hello world this is a"
def test_extract_first_words_custom_count(self):
"""Test extracting custom number of words."""
text = "One two three four five six seven"
result = extract_first_words(text, max_words=3)
assert result == "One two three"
def test_extract_first_words_fewer_than_max(self):
"""Test extracting when text has fewer words than max."""
text = "Only three words"
result = extract_first_words(text, max_words=5)
assert result == "Only three words"
def test_extract_first_words_multiple_spaces(self):
"""Test extracting handles multiple spaces correctly."""
text = " Multiple spaces between words "
result = extract_first_words(text, max_words=3)
assert result == "Multiple spaces between"
def test_extract_first_words_empty(self):
"""Test extracting from empty string."""
text = ""
result = extract_first_words(text)
assert result == ""
def test_normalize_slug_text_basic(self):
"""Test basic text normalization."""
assert normalize_slug_text("Hello World") == "hello-world"
def test_normalize_slug_text_special_characters(self):
"""Test normalization removes special characters."""
assert normalize_slug_text("Testing!@#$%^&*()") == "testing"
assert normalize_slug_text("Hello... World!!!") == "hello-world"
def test_normalize_slug_text_multiple_hyphens(self):
"""Test normalization collapses multiple hyphens."""
assert normalize_slug_text("Hello -- World") == "hello-world"
assert normalize_slug_text("Test---note") == "test-note"
def test_normalize_slug_text_leading_trailing_hyphens(self):
"""Test normalization strips leading/trailing hyphens."""
assert normalize_slug_text("-Hello-") == "hello"
assert normalize_slug_text("---Test---") == "test"
def test_normalize_slug_text_numbers(self):
"""Test normalization preserves numbers."""
assert normalize_slug_text("Test 123 Note") == "test-123-note"
assert normalize_slug_text("2024-11-18") == "2024-11-18"
def test_normalize_slug_text_unicode(self):
"""Test normalization handles unicode (strips it)."""
assert normalize_slug_text("Hello 世界") == "hello"
assert normalize_slug_text("Café") == "caf"
def test_generate_random_suffix_length(self):
"""Test random suffix has correct length."""
suffix = generate_random_suffix()
assert len(suffix) == RANDOM_SUFFIX_LENGTH
def test_generate_random_suffix_custom_length(self):
"""Test random suffix with custom length."""
suffix = generate_random_suffix(8)
assert len(suffix) == 8
def test_generate_random_suffix_alphanumeric(self):
"""Test random suffix contains only lowercase alphanumeric."""
suffix = generate_random_suffix()
assert suffix.isalnum()
assert suffix.islower()
def test_generate_random_suffix_uniqueness(self):
"""Test random suffixes are different (high probability)."""
suffixes = [generate_random_suffix() for _ in range(100)]
# Should have high uniqueness (not all the same)
assert len(set(suffixes)) > 90 # At least 90 unique out of 100
class TestSlugGeneration:
"""Test slug generation functions"""
def test_generate_slug_basic(self):
"""Test basic slug generation from content."""
slug = generate_slug("Hello World This Is My Note")
assert slug == "hello-world-this-is-my"
def test_generate_slug_special_characters(self):
"""Test slug generation removes special characters."""
slug = generate_slug("Testing... with!@# special chars")
assert slug == "testing-with-special-chars"
def test_generate_slug_punctuation(self):
"""Test slug generation handles punctuation."""
slug = generate_slug("What's up? How are you doing today?")
assert slug == "whats-up-how-are-you"
def test_generate_slug_numbers(self):
"""Test slug generation preserves numbers."""
slug = generate_slug("The 2024 Guide to Python Programming")
assert slug == "the-2024-guide-to-python"
def test_generate_slug_first_five_words(self):
"""Test slug uses first 5 words (default)."""
slug = generate_slug("One two three four five six seven eight")
assert slug == "one-two-three-four-five"
def test_generate_slug_fewer_than_five_words(self):
"""Test slug with fewer than 5 words."""
slug = generate_slug("Three word note")
assert slug == "three-word-note"
def test_generate_slug_empty_content_raises_error(self):
"""Test slug generation raises error on empty content."""
with pytest.raises(ValueError, match="Content cannot be empty"):
generate_slug("")
def test_generate_slug_whitespace_only_raises_error(self):
"""Test slug generation raises error on whitespace-only content."""
with pytest.raises(ValueError, match="Content cannot be empty"):
generate_slug(" \n\t ")
def test_generate_slug_special_chars_only_uses_timestamp(self):
"""Test slug falls back to timestamp when only special chars."""
dt = datetime(2024, 11, 18, 14, 30, 45)
slug = generate_slug("!@#$%^&*()", created_at=dt)
assert slug == "20241118-143045"
def test_generate_slug_very_short_content_uses_timestamp(self):
"""Test slug falls back to timestamp for very short content."""
# Single character is valid after normalization
slug = generate_slug("A")
assert slug == "a"
# But if normalized result is empty, uses timestamp
dt = datetime(2024, 11, 18, 14, 30, 0)
slug = generate_slug("!", created_at=dt)
assert slug == "20241118-143000"
def test_generate_slug_timestamp_fallback_uses_utcnow(self):
"""Test slug timestamp fallback uses current time if not provided."""
slug = generate_slug("!@#$")
# Should be in timestamp format
assert len(slug) == 15 # YYYYMMDD-HHMMSS
assert slug[8] == "-"
assert slug[:8].isdigit()
assert slug[9:].isdigit()
def test_generate_slug_truncation(self):
"""Test slug is truncated to max length."""
# Create very long content
long_content = " ".join(["verylongword" for _ in range(20)])
slug = generate_slug(long_content)
assert len(slug) <= MAX_SLUG_LENGTH
def test_generate_slug_unicode_content(self):
"""Test slug generation handles unicode gracefully."""
slug = generate_slug("Hello 世界 World Python Programming")
# Unicode is stripped, should keep ASCII words
assert "hello" in slug
assert "world" in slug
def test_generate_slug_multiple_spaces(self):
"""Test slug handles multiple spaces between words."""
slug = generate_slug("Hello World This Is Test")
assert slug == "hello-world-this-is-test"
class TestSlugUniqueness:
"""Test slug uniqueness enforcement"""
def test_make_slug_unique_no_collision(self):
"""Test make_slug_unique returns original if no collision."""
slug = make_slug_unique("test-note", set())
assert slug == "test-note"
def test_make_slug_unique_with_collision(self):
"""Test make_slug_unique adds suffix on collision."""
slug = make_slug_unique("test-note", {"test-note"})
assert slug.startswith("test-note-")
assert len(slug) == len("test-note-") + RANDOM_SUFFIX_LENGTH
def test_make_slug_unique_suffix_is_alphanumeric(self):
"""Test unique slug suffix is alphanumeric."""
slug = make_slug_unique("test-note", {"test-note"})
suffix = slug.split("-")[-1]
assert suffix.isalnum()
assert suffix.islower()
def test_make_slug_unique_multiple_collisions(self):
"""Test make_slug_unique handles multiple collisions."""
existing = {"test-note", "test-note-abcd", "test-note-xyz1"}
slug = make_slug_unique("test-note", existing)
assert slug.startswith("test-note-")
assert slug not in existing
def test_make_slug_unique_generates_different_suffixes(self):
"""Test make_slug_unique generates different suffixes each time."""
existing = {"test-note"}
slugs = []
for _ in range(10):
slug = make_slug_unique("test-note", existing)
slugs.append(slug)
existing.add(slug)
# All should be unique
assert len(set(slugs)) == 10
def test_make_slug_unique_empty_existing_set(self):
"""Test make_slug_unique with empty set."""
slug = make_slug_unique("hello-world", set())
assert slug == "hello-world"
class TestSlugValidation:
"""Test slug validation"""
def test_validate_slug_valid_basic(self):
"""Test validation accepts valid basic slugs."""
assert validate_slug("hello-world") is True
assert validate_slug("test-note") is True
assert validate_slug("my-first-post") is True
def test_validate_slug_valid_with_numbers(self):
"""Test validation accepts slugs with numbers."""
assert validate_slug("post-123") is True
assert validate_slug("2024-11-18-note") is True
assert validate_slug("test1-note2") is True
def test_validate_slug_valid_single_character(self):
"""Test validation accepts single character slug."""
assert validate_slug("a") is True
assert validate_slug("1") is True
def test_validate_slug_valid_max_length(self):
"""Test validation accepts slug at max length."""
slug = "a" * MAX_SLUG_LENGTH
assert validate_slug(slug) is True
def test_validate_slug_invalid_empty(self):
"""Test validation rejects empty slug."""
assert validate_slug("") is False
def test_validate_slug_invalid_uppercase(self):
"""Test validation rejects uppercase characters."""
assert validate_slug("Hello-World") is False
assert validate_slug("TEST") is False
def test_validate_slug_invalid_leading_hyphen(self):
"""Test validation rejects leading hyphen."""
assert validate_slug("-hello") is False
assert validate_slug("-test-note") is False
def test_validate_slug_invalid_trailing_hyphen(self):
"""Test validation rejects trailing hyphen."""
assert validate_slug("hello-") is False
assert validate_slug("test-note-") is False
def test_validate_slug_invalid_double_hyphen(self):
"""Test validation rejects consecutive hyphens."""
assert validate_slug("hello--world") is False
assert validate_slug("test---note") is False
def test_validate_slug_invalid_underscore(self):
"""Test validation rejects underscores."""
assert validate_slug("hello_world") is False
assert validate_slug("test_note") is False
def test_validate_slug_invalid_special_characters(self):
"""Test validation rejects special characters."""
assert validate_slug("hello@world") is False
assert validate_slug("test!note") is False
assert validate_slug("note#123") is False
assert validate_slug("hello.world") is False
def test_validate_slug_invalid_spaces(self):
"""Test validation rejects spaces."""
assert validate_slug("hello world") is False
assert validate_slug("test note") is False
def test_validate_slug_invalid_too_long(self):
"""Test validation rejects slug exceeding max length."""
slug = "a" * (MAX_SLUG_LENGTH + 1)
assert validate_slug(slug) is False
def test_validate_slug_invalid_reserved_admin(self):
"""Test validation rejects reserved slug 'admin'."""
assert validate_slug("admin") is False
def test_validate_slug_invalid_reserved_api(self):
"""Test validation rejects reserved slug 'api'."""
assert validate_slug("api") is False
def test_validate_slug_invalid_reserved_static(self):
"""Test validation rejects reserved slug 'static'."""
assert validate_slug("static") is False
def test_validate_slug_invalid_reserved_auth(self):
"""Test validation rejects reserved slug 'auth'."""
assert validate_slug("auth") is False
def test_validate_slug_invalid_reserved_feed(self):
"""Test validation rejects reserved slug 'feed'."""
assert validate_slug("feed") is False
def test_validate_slug_invalid_reserved_login(self):
"""Test validation rejects reserved slug 'login'."""
assert validate_slug("login") is False
def test_validate_slug_invalid_reserved_logout(self):
"""Test validation rejects reserved slug 'logout'."""
assert validate_slug("logout") is False
class TestEdgeCases:
"""Test edge cases and boundary conditions"""
def test_slug_with_only_hyphens_after_normalization(self):
"""Test content that becomes only hyphens after normalization."""
dt = datetime(2024, 11, 18, 15, 0, 0)
slug = generate_slug("--- --- ---", created_at=dt)
# Should fall back to timestamp
assert slug == "20241118-150000"
def test_slug_with_mixed_case(self):
"""Test slug generation normalizes mixed case."""
slug = generate_slug("HeLLo WoRLd ThIs Is TeSt")
assert slug == "hello-world-this-is-test"
def test_slug_with_newlines(self):
"""Test slug generation handles newlines in content."""
slug = generate_slug(
"First line\nSecond line\nThird line\nFourth line\nFifth line"
)
assert slug == "first-line-second-line-third"
def test_slug_with_tabs(self):
"""Test slug generation handles tabs."""
slug = generate_slug("First\tSecond\tThird\tFourth\tFifth")
assert slug == "first-second-third-fourth-fifth"
def test_slug_timestamp_format(self):
"""Test timestamp format is correct."""
dt = datetime(2024, 11, 18, 14, 30, 45)
slug = generate_slug("@@@", created_at=dt)
assert slug == "20241118-143045"
# Verify format
assert len(slug) == 15
assert slug[8] == "-"
def test_very_long_single_word(self):
"""Test slug with very long single word gets truncated."""
long_word = "a" * 200
slug = generate_slug(long_word)
assert len(slug) <= MAX_SLUG_LENGTH
assert slug == "a" * MAX_SLUG_LENGTH
def test_slug_with_emoji(self):
"""Test slug generation strips emoji."""
slug = generate_slug("Hello 🌍 World 🎉 This Is Test")
# Emoji should be stripped, leaving only the first 5 words
assert slug == "hello-world-this"
def test_slug_uniqueness_with_reserved_base(self):
"""Test making reserved slug unique (though shouldn't happen)."""
# If somehow a reserved slug needs uniqueness
existing = {"admin"}
slug = make_slug_unique("admin", existing)
assert slug.startswith("admin-")
assert validate_slug(slug) is True # Should be valid after suffix
def test_normalize_all_special_chars(self):
"""Test normalization with all special characters."""
text = "!@#$%^&*()_+={}[]|\\:;\"'<>,.?/~`"
result = normalize_slug_text(text)
assert result == ""
def test_generate_slug_with_markdown_heading(self):
"""Test slug generation from markdown heading."""
slug = generate_slug("# My First Blog Post About Python")
# The # should be treated as special char and removed, first 5 words
assert slug == "my-first-blog-post"
class TestSecurityCases:
"""Test security-related edge cases"""
def test_slug_no_path_traversal_characters(self):
"""Test slug doesn't contain path traversal patterns."""
slug = generate_slug("../../etc/passwd is the test note")
assert ".." not in slug
assert "/" not in slug
# Dots/slashes removed, "../../etc/passwd" becomes "etcpasswd"
# Then "is the test note" = 4 more words, total 5 words
assert slug == "etcpasswd-is-the-test-note"
def test_slug_no_null_bytes(self):
"""Test slug handles null bytes safely."""
# Python strings can contain null bytes
content = "Test\x00note\x00content"
slug = generate_slug(content)
assert "\x00" not in slug
def test_slug_no_sql_injection_patterns(self):
"""Test slug doesn't preserve SQL injection patterns."""
slug = generate_slug("'; DROP TABLE notes; -- is my note")
# Should be normalized, no special SQL chars
assert ";" not in slug
assert "'" not in slug
assert "--" not in slug
def test_slug_no_script_tags(self):
"""Test slug doesn't preserve script tags."""
slug = generate_slug("<script>alert('xss')</script> My Note Title")
assert "<" not in slug
assert ">" not in slug
assert "script" in slug # The word itself is fine
# Special chars removed, becomes one word, then first 5 words total
assert slug == "scriptalertxssscript-my-note-title"
def test_random_suffix_uses_secrets_module(self):
"""Test random suffix is cryptographically secure (not predictable)."""
# Generate many suffixes and ensure high entropy
suffixes = [generate_random_suffix() for _ in range(1000)]
unique_count = len(set(suffixes))
# Should have very high uniqueness (>99%)
assert unique_count > 990
class TestContentHashing:
"""Test content hashing functions"""
def test_calculate_content_hash_consistency(self):
"""Test hash is consistent for same content."""
hash1 = calculate_content_hash("Test content")
hash2 = calculate_content_hash("Test content")
assert hash1 == hash2
def test_calculate_content_hash_different(self):
"""Test different content produces different hash."""
hash1 = calculate_content_hash("Test content 1")
hash2 = calculate_content_hash("Test content 2")
assert hash1 != hash2
def test_calculate_content_hash_empty(self):
"""Test hash of empty string."""
hash_empty = calculate_content_hash("")
assert len(hash_empty) == 64 # SHA-256 produces 64 hex chars
assert hash_empty.isalnum()
def test_calculate_content_hash_unicode(self):
"""Test hash handles unicode correctly."""
hash_val = calculate_content_hash("Hello 世界")
assert len(hash_val) == 64
assert hash_val.isalnum()
def test_calculate_content_hash_known_value(self):
"""Test hash matches known SHA-256 value."""
# Known SHA-256 hash for "Hello World"
expected = "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"
actual = calculate_content_hash("Hello World")
assert actual == expected
def test_calculate_content_hash_multiline(self):
"""Test hash of multiline content."""
content = "Line 1\nLine 2\nLine 3"
hash_val = calculate_content_hash(content)
assert len(hash_val) == 64
def test_calculate_content_hash_special_characters(self):
"""Test hash handles special characters."""
content = "Special chars: !@#$%^&*()_+-=[]{}|;:',.<>?/~`"
hash_val = calculate_content_hash(content)
assert len(hash_val) == 64
class TestFilePathOperations:
"""Test file path generation and validation"""
def test_generate_note_path_basic(self):
"""Test basic note path generation."""
dt = datetime(2024, 11, 18, 14, 30)
path = generate_note_path("test-note", dt, Path("data"))
assert path == Path("data/notes/2024/11/test-note.md")
def test_generate_note_path_different_months(self):
"""Test path generation for different months."""
dt_jan = datetime(2024, 1, 5, 10, 0)
dt_dec = datetime(2024, 12, 25, 15, 30)
path_jan = generate_note_path("jan-note", dt_jan, Path("data"))
path_dec = generate_note_path("dec-note", dt_dec, Path("data"))
assert path_jan == Path("data/notes/2024/01/jan-note.md")
assert path_dec == Path("data/notes/2024/12/dec-note.md")
def test_generate_note_path_different_years(self):
"""Test path generation for different years."""
dt_2024 = datetime(2024, 6, 15)
dt_2025 = datetime(2025, 6, 15)
path_2024 = generate_note_path("note-2024", dt_2024, Path("data"))
path_2025 = generate_note_path("note-2025", dt_2025, Path("data"))
assert path_2024 == Path("data/notes/2024/06/note-2024.md")
assert path_2025 == Path("data/notes/2025/06/note-2025.md")
def test_generate_note_path_invalid_slug(self):
"""Test note path generation rejects invalid slug."""
dt = datetime(2024, 11, 18)
with pytest.raises(ValueError, match="Invalid slug"):
generate_note_path("Invalid Slug!", dt, Path("data"))
def test_generate_note_path_with_numbers(self):
"""Test path generation with slug containing numbers."""
dt = datetime(2024, 11, 18)
path = generate_note_path("note-123-test", dt, Path("data"))
assert path == Path("data/notes/2024/11/note-123-test.md")
def test_ensure_note_directory_creates_dirs(self, tmp_path):
"""Test ensure_note_directory creates directories."""
note_path = tmp_path / "notes" / "2024" / "11" / "test.md"
assert not note_path.parent.exists()
result = ensure_note_directory(note_path)
assert note_path.parent.exists()
assert result == note_path.parent
def test_ensure_note_directory_existing_dirs(self, tmp_path):
"""Test ensure_note_directory with existing directories."""
note_path = tmp_path / "notes" / "2024" / "11" / "test.md"
note_path.parent.mkdir(parents=True)
# Should not raise error
result = ensure_note_directory(note_path)
assert result == note_path.parent
def test_ensure_note_directory_deep_structure(self, tmp_path):
"""Test ensure_note_directory with deep directory structure."""
note_path = tmp_path / "a" / "b" / "c" / "d" / "e" / "test.md"
result = ensure_note_directory(note_path)
assert note_path.parent.exists()
assert result == note_path.parent
def test_validate_note_path_safe(self, tmp_path):
"""Test path validation accepts safe paths."""
note_path = tmp_path / "notes" / "2024" / "11" / "note.md"
assert validate_note_path(note_path, tmp_path) is True
def test_validate_note_path_traversal_dotdot(self, tmp_path):
"""Test path validation rejects .. traversal."""
note_path = tmp_path / "notes" / ".." / ".." / "etc" / "passwd"
assert validate_note_path(note_path, tmp_path) is False
def test_validate_note_path_absolute_outside(self, tmp_path):
"""Test path validation rejects absolute paths outside data dir."""
assert validate_note_path(Path("/etc/passwd"), tmp_path) is False
def test_validate_note_path_within_subdirectory(self, tmp_path):
"""Test path validation accepts paths in subdirectories."""
note_path = tmp_path / "notes" / "2024" / "11" / "subfolder" / "note.md"
assert validate_note_path(note_path, tmp_path) is True
def test_validate_note_path_symlink_outside(self, tmp_path):
"""Test path validation handles symlinks pointing outside."""
# Create a symlink pointing outside data_dir
outside_dir = tmp_path.parent / "outside"
outside_dir.mkdir(exist_ok=True)
link_path = tmp_path / "link"
link_path.symlink_to(outside_dir)
target_path = link_path / "file.md"
assert validate_note_path(target_path, tmp_path) is False
def test_validate_note_path_same_directory(self, tmp_path):
"""Test path validation for file in data_dir root."""
note_path = tmp_path / "note.md"
assert validate_note_path(note_path, tmp_path) is True
class TestAtomicFileOperations:
"""Test atomic file write/read/delete operations"""
def test_write_and_read_note_file(self, tmp_path):
"""Test writing and reading note file."""
file_path = tmp_path / "test.md"
content = "# Test Note\n\nThis is a test."
write_note_file(file_path, content)
assert file_path.exists()
read_content = read_note_file(file_path)
assert read_content == content
def test_write_note_file_atomic(self, tmp_path):
"""Test write is atomic (temp file cleaned up)."""
file_path = tmp_path / "test.md"
temp_path = file_path.with_suffix(".md.tmp")
write_note_file(file_path, "Test")
# Temp file should not exist after write
assert not temp_path.exists()
assert file_path.exists()
def test_write_note_file_overwrites(self, tmp_path):
"""Test writing overwrites existing file."""
file_path = tmp_path / "test.md"
write_note_file(file_path, "Original content")
write_note_file(file_path, "New content")
content = read_note_file(file_path)
assert content == "New content"
def test_write_note_file_unicode(self, tmp_path):
"""Test writing unicode content."""
file_path = tmp_path / "test.md"
content = "Unicode: 你好世界 🌍"
write_note_file(file_path, content)
read_content = read_note_file(file_path)
assert read_content == content
def test_write_note_file_empty(self, tmp_path):
"""Test writing empty file."""
file_path = tmp_path / "test.md"
write_note_file(file_path, "")
content = read_note_file(file_path)
assert content == ""
def test_write_note_file_multiline(self, tmp_path):
"""Test writing multiline content."""
file_path = tmp_path / "test.md"
content = "Line 1\nLine 2\nLine 3\n"
write_note_file(file_path, content)
read_content = read_note_file(file_path)
assert read_content == content
def test_read_note_file_not_found(self, tmp_path):
"""Test reading non-existent file raises error."""
file_path = tmp_path / "nonexistent.md"
with pytest.raises(FileNotFoundError):
read_note_file(file_path)
def test_delete_note_file_hard(self, tmp_path):
"""Test hard delete removes file."""
file_path = tmp_path / "test.md"
file_path.write_text("Test")
delete_note_file(file_path, soft=False)
assert not file_path.exists()
def test_delete_note_file_soft(self, tmp_path):
"""Test soft delete moves file to trash."""
# Create note file
notes_dir = tmp_path / "notes" / "2024" / "11"
notes_dir.mkdir(parents=True)
file_path = notes_dir / "test.md"
file_path.write_text("Test")
# Soft delete
delete_note_file(file_path, soft=True, data_dir=tmp_path)
# Original should be gone
assert not file_path.exists()
# Should be in trash
trash_path = tmp_path / TRASH_DIR_NAME / "2024" / "11" / "test.md"
assert trash_path.exists()
assert trash_path.read_text() == "Test"
def test_delete_note_file_soft_without_data_dir(self, tmp_path):
"""Test soft delete requires data_dir."""
file_path = tmp_path / "test.md"
file_path.write_text("Test")
with pytest.raises(ValueError, match="data_dir is required"):
delete_note_file(file_path, soft=True, data_dir=None)
def test_delete_note_file_soft_different_months(self, tmp_path):
"""Test soft delete preserves year/month structure."""
# Create note in January
jan_dir = tmp_path / "notes" / "2024" / "01"
jan_dir.mkdir(parents=True)
jan_file = jan_dir / "jan-note.md"
jan_file.write_text("January note")
# Create note in December
dec_dir = tmp_path / "notes" / "2024" / "12"
dec_dir.mkdir(parents=True)
dec_file = dec_dir / "dec-note.md"
dec_file.write_text("December note")
# Soft delete both
delete_note_file(jan_file, soft=True, data_dir=tmp_path)
delete_note_file(dec_file, soft=True, data_dir=tmp_path)
# Check trash structure
jan_trash = tmp_path / TRASH_DIR_NAME / "2024" / "01" / "jan-note.md"
dec_trash = tmp_path / TRASH_DIR_NAME / "2024" / "12" / "dec-note.md"
assert jan_trash.exists()
assert dec_trash.exists()
def test_delete_note_file_hard_not_found(self, tmp_path):
"""Test hard delete of non-existent file raises error."""
file_path = tmp_path / "nonexistent.md"
with pytest.raises(FileNotFoundError):
delete_note_file(file_path, soft=False)
class TestDateTimeFormatting:
"""Test date/time formatting functions"""
def test_format_rfc822_basic(self):
"""Test RFC-822 date formatting."""
dt = datetime(2024, 11, 18, 14, 30, 45)
formatted = format_rfc822(dt)
assert formatted == "Mon, 18 Nov 2024 14:30:45 +0000"
def test_format_rfc822_different_dates(self):
"""Test RFC-822 formatting for different dates."""
dt1 = datetime(2024, 1, 1, 0, 0, 0)
dt2 = datetime(2024, 12, 31, 23, 59, 59)
assert format_rfc822(dt1) == "Mon, 01 Jan 2024 00:00:00 +0000"
assert format_rfc822(dt2) == "Tue, 31 Dec 2024 23:59:59 +0000"
def test_format_rfc822_weekdays(self):
"""Test RFC-822 format includes correct weekday."""
# Known dates and weekdays
monday = datetime(2024, 11, 18, 12, 0, 0)
friday = datetime(2024, 11, 22, 12, 0, 0)
sunday = datetime(2024, 11, 24, 12, 0, 0)
assert format_rfc822(monday).startswith("Mon,")
assert format_rfc822(friday).startswith("Fri,")
assert format_rfc822(sunday).startswith("Sun,")
def test_format_iso8601_basic(self):
"""Test ISO 8601 date formatting."""
dt = datetime(2024, 11, 18, 14, 30, 45)
formatted = format_iso8601(dt)
assert formatted == "2024-11-18T14:30:45Z"
def test_format_iso8601_different_dates(self):
"""Test ISO 8601 formatting for different dates."""
dt1 = datetime(2024, 1, 1, 0, 0, 0)
dt2 = datetime(2024, 12, 31, 23, 59, 59)
assert format_iso8601(dt1) == "2024-01-01T00:00:00Z"
assert format_iso8601(dt2) == "2024-12-31T23:59:59Z"
def test_format_iso8601_single_digits(self):
"""Test ISO 8601 format pads single digits."""
dt = datetime(2024, 1, 5, 9, 8, 7)
formatted = format_iso8601(dt)
assert formatted == "2024-01-05T09:08:07Z"
def test_parse_iso8601_basic(self):
"""Test ISO 8601 date parsing."""
dt = parse_iso8601("2024-11-18T14:30:45Z")
assert dt.year == 2024
assert dt.month == 11
assert dt.day == 18
assert dt.hour == 14
assert dt.minute == 30
assert dt.second == 45
def test_parse_iso8601_without_z(self):
"""Test ISO 8601 parsing without Z suffix."""
dt = parse_iso8601("2024-11-18T14:30:45")
assert dt.year == 2024
assert dt.month == 11
assert dt.day == 18
def test_parse_iso8601_roundtrip(self):
"""Test ISO 8601 format and parse roundtrip."""
original = datetime(2024, 11, 18, 14, 30, 45)
formatted = format_iso8601(original)
parsed = parse_iso8601(formatted)
assert parsed == original
def test_parse_iso8601_invalid_format(self):
"""Test ISO 8601 parsing rejects invalid format."""
with pytest.raises(ValueError):
parse_iso8601("not-a-date")
def test_parse_iso8601_invalid_date(self):
"""Test ISO 8601 parsing rejects invalid date values."""
with pytest.raises(ValueError):
parse_iso8601("2024-13-01T00:00:00Z") # Invalid month
def test_format_and_parse_consistency(self):
"""Test RFC-822 and ISO 8601 are both consistent."""
dt = datetime(2024, 11, 18, 14, 30, 45)
# ISO 8601 roundtrip
iso_formatted = format_iso8601(dt)
iso_parsed = parse_iso8601(iso_formatted)
assert iso_parsed == dt
# RFC-822 format is consistent
rfc_formatted = format_rfc822(dt)
assert "2024" in rfc_formatted
assert "14:30:45" in rfc_formatted