that initial commit
This commit is contained in:
863
tests/test_utils.py
Normal file
863
tests/test_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user