860 lines
29 KiB
Python
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
|