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