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