Files
StarPunk/tests/test_models.py
2025-11-18 19:21:31 -07:00

860 lines
29 KiB
Python

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