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

864 lines
33 KiB
Python

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