diff --git a/.github/workflows/pr-testing.yml b/.github/workflows/pr-testing.yml index cfbad0dc..2308cbce 100644 --- a/.github/workflows/pr-testing.yml +++ b/.github/workflows/pr-testing.yml @@ -32,11 +32,6 @@ jobs: cd echo/server ruff check . - - name: Run Ruff Format Check - run: | - cd echo/server - ruff format --check . - - name: Run MyPy Type Check run: | cd echo/server @@ -45,6 +40,48 @@ jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest + env: + # Required environment variables for config.py + DIRECTUS_SECRET: test-secret + DIRECTUS_TOKEN: test-token + DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test + REDIS_URL: redis://localhost:6379 + OPENAI_API_KEY: sk-test-key + ANTHROPIC_API_KEY: sk-ant-test-key + STORAGE_S3_BUCKET: test-bucket + STORAGE_S3_ENDPOINT: https://test.endpoint.com + STORAGE_S3_KEY: test-key + STORAGE_S3_SECRET: test-secret + SMALL_LITELLM_MODEL: gpt-4o-mini + SMALL_LITELLM_API_KEY: test-key + SMALL_LITELLM_API_VERSION: "2024-06-01" + SMALL_LITELLM_API_BASE: https://api.openai.com/v1 + MEDIUM_LITELLM_MODEL: gpt-4o + MEDIUM_LITELLM_API_KEY: test-key + MEDIUM_LITELLM_API_VERSION: "2024-06-01" + MEDIUM_LITELLM_API_BASE: https://api.openai.com/v1 + LARGE_LITELLM_MODEL: gpt-4o + LARGE_LITELLM_API_KEY: test-key + LARGE_LITELLM_API_VERSION: "2024-06-01" + LARGE_LITELLM_API_BASE: https://api.openai.com/v1 + LIGHTRAG_LITELLM_MODEL: gpt-4o-mini + LIGHTRAG_LITELLM_API_KEY: test-key + LIGHTRAG_LITELLM_API_VERSION: "2024-06-01" + LIGHTRAG_LITELLM_API_BASE: https://api.openai.com/v1 + LIGHTRAG_LITELLM_AUDIOMODEL_MODEL: gpt-4o-audio + LIGHTRAG_LITELLM_AUDIOMODEL_API_BASE: https://api.openai.com/v1 + LIGHTRAG_LITELLM_AUDIOMODEL_API_KEY: test-key + LIGHTRAG_LITELLM_AUDIOMODEL_API_VERSION: "2024-06-01" + LIGHTRAG_LITELLM_TEXTSTRUCTUREMODEL_MODEL: gpt-4o-mini + LIGHTRAG_LITELLM_TEXTSTRUCTUREMODEL_API_BASE: https://api.openai.com/v1 + LIGHTRAG_LITELLM_TEXTSTRUCTUREMODEL_API_KEY: test-key + LIGHTRAG_LITELLM_TEXTSTRUCTUREMODEL_API_VERSION: "2024-06-01" + LIGHTRAG_LITELLM_EMBEDDING_MODEL: text-embedding-ada-002 + LIGHTRAG_LITELLM_EMBEDDING_API_BASE: https://api.openai.com/v1 + LIGHTRAG_LITELLM_EMBEDDING_API_KEY: test-key + LIGHTRAG_LITELLM_EMBEDDING_API_VERSION: "2024-06-01" + LIGHTRAG_LITELLM_INFERENCE_MODEL: claude-3-5-sonnet + LIGHTRAG_LITELLM_INFERENCE_API_KEY: test-key steps: - uses: actions/checkout@v4 @@ -61,7 +98,7 @@ jobs: - name: Run Unit Tests run: | cd echo/server - pytest tests/ -v -m "not integration and not slow" --maxfail=3 + pytest tests/ -v -m "not integration and not slow and not smoke" --maxfail=3 build-images: name: Build Docker Images (validation only) diff --git a/README.md b/README.md index dc2728f1..52d62768 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,4 @@ Thanks to everyone in the community that has contributed to this project! ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/Dembrane/echo?utm_source=oss&utm_medium=github&utm_campaign=Dembrane%2Fecho&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) + \ No newline at end of file diff --git a/echo/server/dembrane/config.py b/echo/server/dembrane/config.py index edaf642c..1bfae9ec 100644 --- a/echo/server/dembrane/config.py +++ b/echo/server/dembrane/config.py @@ -278,7 +278,7 @@ logger.debug(f"RUNPOD_WHISPER_PRIORITY_BASE_URL: {RUNPOD_WHISPER_PRIORITY_BASE_URL}") RUNPOD_WHISPER_MAX_REQUEST_THRESHOLD = int( - str(os.environ.get("RUNPOD_WHISPER_MAX_REQUEST_THRESHOLD")) + os.environ.get("RUNPOD_WHISPER_MAX_REQUEST_THRESHOLD") or 100 ) ENABLE_LITELLM_WHISPER_TRANSCRIPTION = os.environ.get( @@ -539,7 +539,7 @@ ) logger.debug(f"RUNPOD_DIARIZATION_BASE_URL: {RUNPOD_DIARIZATION_BASE_URL}") -RUNPOD_DIARIZATION_TIMEOUT = int(os.environ.get("RUNPOD_DIARIZATION_TIMEOUT", 30)) +RUNPOD_DIARIZATION_TIMEOUT = int(os.environ.get("RUNPOD_DIARIZATION_TIMEOUT") or 30) if ENABLE_RUNPOD_DIARIZATION: logger.debug(f"RUNPOD_DIARIZATION_TIMEOUT: {RUNPOD_DIARIZATION_TIMEOUT}") # ---------------/Secrets--------------- @@ -547,7 +547,7 @@ # ---------------Configurations--------------- AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM = int( - os.environ.get("AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM", 10) + os.environ.get("AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM") or 10 ) assert AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM, ( "AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM environment variable is not set" @@ -555,7 +555,7 @@ logger.debug(f"AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM: {AUDIO_LIGHTRAG_CONVERSATION_HISTORY_NUM}") AUDIO_LIGHTRAG_COOL_OFF_TIME_SECONDS = int( - os.environ.get("AUDIO_LIGHTRAG_COOL_OFF_TIME_SECONDS", 60) + os.environ.get("AUDIO_LIGHTRAG_COOL_OFF_TIME_SECONDS") or 60 ) assert AUDIO_LIGHTRAG_COOL_OFF_TIME_SECONDS, ( "AUDIO_LIGHTRAG_COOL_OFF_TIME_SECONDS environment variable is not set" @@ -572,14 +572,14 @@ logger.debug(f"ENABLE_AUDIO_LIGHTRAG_INPUT: {ENABLE_AUDIO_LIGHTRAG_INPUT}") AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB = int( - os.environ.get("AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB", 15) + os.environ.get("AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB") or 15 ) assert AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB, ( "AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB environment variable is not set" ) logger.debug(f"AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB: {AUDIO_LIGHTRAG_MAX_AUDIO_FILE_SIZE_MB}") -AUDIO_LIGHTRAG_TOP_K_PROMPT = int(os.environ.get("AUDIO_LIGHTRAG_TOP_K_PROMPT", 100)) +AUDIO_LIGHTRAG_TOP_K_PROMPT = int(os.environ.get("AUDIO_LIGHTRAG_TOP_K_PROMPT") or 100) assert AUDIO_LIGHTRAG_TOP_K_PROMPT, "AUDIO_LIGHTRAG_TOP_K_PROMPT environment variable is not set" logger.debug(f"AUDIO_LIGHTRAG_TOP_K_PROMPT: {AUDIO_LIGHTRAG_TOP_K_PROMPT}") @@ -601,7 +601,7 @@ ) logger.debug(f"AUDIO_LIGHTRAG_REDIS_LOCK_PREFIX: {AUDIO_LIGHTRAG_REDIS_LOCK_PREFIX}") -AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY = int(os.environ.get("AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY", 3600)) +AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY = int(os.environ.get("AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY") or 3600) assert AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY, ( "AUDIO_LIGHTRAG_REDIS_LOCK_EXPIRY environment variable is not set" ) diff --git a/echo/server/pyproject.toml b/echo/server/pyproject.toml index 2475a49d..368c14ef 100644 --- a/echo/server/pyproject.toml +++ b/echo/server/pyproject.toml @@ -142,6 +142,7 @@ markers = [ "integration: integration tests requiring external services", ] addopts = "--strict-markers --log-cli-level=INFO --log-cli-format='%(asctime)s [%(levelname)8s] %(name)s: %(message)s' --log-cli-date-format='%Y-%m-%d %H:%M:%S'" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.run] source = ["dembrane"] diff --git a/echo/server/tests/api/test_conversation.py b/echo/server/tests/api/test_conversation.py index a997da0b..cc7febf3 100644 --- a/echo/server/tests/api/test_conversation.py +++ b/echo/server/tests/api/test_conversation.py @@ -16,6 +16,8 @@ logger = logging.getLogger("dembrane.tests.api.test_conversation") +pytestmark = pytest.mark.integration + @pytest.mark.asyncio async def test_get_conversation_transcript(): diff --git a/echo/server/tests/service/test_conversation_service.py b/echo/server/tests/service/test_conversation_service.py index 6a5584fb..db6106e4 100644 --- a/echo/server/tests/service/test_conversation_service.py +++ b/echo/server/tests/service/test_conversation_service.py @@ -30,6 +30,7 @@ def project(): project_service.delete(project["id"]) +@pytest.mark.integration def test_create_conversation(project): conversation = conversation_service.create( project_id=project["id"], @@ -49,6 +50,7 @@ def test_create_conversation(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_create_conversation_with_tags(project): tags = project_service.create_tags_and_link(project.get("id"), ["tag1", "tag2"]) @@ -85,6 +87,7 @@ def test_create_conversation_with_tags(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_create_conversation_not_allowed(): project = project_service.create( name="Test Project No Conversations", @@ -101,6 +104,7 @@ def test_create_conversation_not_allowed(): project_service.delete(project["id"]) +@pytest.mark.integration def test_get_by_id_or_raise(project): conversation = conversation_service.create( project_id=project["id"], @@ -115,6 +119,7 @@ def test_get_by_id_or_raise(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_get_by_id_or_raise_not_found(): try: conversation_service.get_by_id_or_raise("non-existent-id") @@ -122,6 +127,7 @@ def test_get_by_id_or_raise_not_found(): assert isinstance(e, ConversationNotFoundException) +@pytest.mark.integration def test_update_conversation(project): conversation = conversation_service.create( project_id=project["id"], @@ -149,6 +155,7 @@ def test_update_conversation(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_update_conversation_partial(project): conversation = conversation_service.create( project_id=project["id"], @@ -174,6 +181,7 @@ def test_update_conversation_partial(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_update_conversation_not_found(): with pytest.raises(ConversationNotFoundException): conversation_service.update( @@ -182,6 +190,7 @@ def test_update_conversation_not_found(): ) +@pytest.mark.integration def test_delete_conversation(project): conversation = conversation_service.create( project_id=project["id"], @@ -194,32 +203,30 @@ def test_delete_conversation(project): conversation_service.get_by_id_or_raise(conversation["id"]) +# Unit test - tests service initialization with dependencies def test_conversation_service_property_getters(): - """Test lazy initialization of service dependencies.""" - # Create a fresh service instance - service = ConversationService() - - # Initially, all services should be None - assert service._file_service is None - assert service._event_service is None - assert service._project_service is None - - # Access properties to trigger lazy initialization - file_service = service.file_service - event_service = service.event_service - project_service = service.project_service - - # Verify services are initialized - assert file_service is not None - assert event_service is not None - assert project_service is not None - - # Verify subsequent access returns same instances + """Test that service dependencies are properly set on initialization.""" + from dembrane.service.file import get_file_service + from dembrane.service.project import ProjectService + + # Create service instances + file_service = get_file_service() + project_svc = ProjectService() + + # Create conversation service with dependencies + service = ConversationService( + file_service=file_service, + project_service=project_svc, + ) + + # Verify services are set correctly + assert service.file_service is not None + assert service.project_service is not None assert service.file_service is file_service - assert service.event_service is event_service - assert service.project_service is project_service + assert service.project_service is project_svc +# Unit test - uses mocks, no external dependencies def test_get_by_id_directus_bad_request(): """Test exception handling when Directus returns bad request.""" with patch("dembrane.service.conversation.directus_client_context") as mock_context: @@ -231,6 +238,7 @@ def test_get_by_id_directus_bad_request(): conversation_service.get_by_id_or_raise("test-id") +# Unit test - uses mocks, no external dependencies def test_get_by_id_empty_result(): """Test exception handling when no conversation found.""" with patch("dembrane.service.conversation.directus_client_context") as mock_context: @@ -242,6 +250,7 @@ def test_get_by_id_empty_result(): conversation_service.get_by_id_or_raise("test-id") +# Unit test - uses mocks, no external dependencies def test_update_conversation_directus_bad_request(): """Test exception handling when updating non-existent conversation.""" with patch("dembrane.service.conversation.directus_client_context") as mock_context: @@ -253,6 +262,8 @@ def test_update_conversation_directus_bad_request(): conversation_service.update(conversation_id="non-existent-id", participant_name="Test") +# Unit test - uses mocks for S3 and events, but still needs DB for conversation +@pytest.mark.integration def test_create_chunk_from_file(project): """Test creating conversation chunk from file upload.""" conversation = conversation_service.create( @@ -295,6 +306,7 @@ def test_create_chunk_from_file(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_create_chunk_from_file_finished_conversation(project): """Test that chunks cannot be added to finished conversations.""" conversation = conversation_service.create( @@ -318,6 +330,8 @@ def test_create_chunk_from_file_finished_conversation(project): conversation_service.delete(conversation["id"]) +# Uses mock for events, but still needs DB for conversation +@pytest.mark.integration def test_create_chunk_from_text(project): """Test creating conversation chunk from text.""" conversation = conversation_service.create( @@ -355,6 +369,7 @@ def test_create_chunk_from_text(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_create_chunk_from_text_finished_conversation(project): """Test that text chunks cannot be added to finished conversations.""" conversation = conversation_service.create( @@ -376,6 +391,7 @@ def test_create_chunk_from_text_finished_conversation(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_get_by_id_with_chunks(project): """Test retrieving conversation with chunks sorted by timestamp.""" conversation = conversation_service.create( @@ -405,6 +421,7 @@ def test_get_by_id_with_chunks(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_delete_chunk(project): conversation = conversation_service.create( project_id=project["id"], @@ -446,6 +463,7 @@ def test_delete_chunk(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_chunk_timestamp_functionality(project): """Test comprehensive timestamp functionality for conversation chunks.""" conversation = conversation_service.create( @@ -536,6 +554,7 @@ def test_chunk_timestamp_functionality(project): conversation_service.delete(conversation["id"]) +@pytest.mark.integration def test_chunk_timestamp_edge_cases(project): """Test edge cases for timestamp handling.""" conversation = conversation_service.create( diff --git a/echo/server/tests/service/test_file_service.py b/echo/server/tests/service/test_file_service.py index f47e3c7f..cc104f7f 100644 --- a/echo/server/tests/service/test_file_service.py +++ b/echo/server/tests/service/test_file_service.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + class TestS3FileService: """Test S3FileService implementation.""" diff --git a/echo/server/tests/service/test_project_service.py b/echo/server/tests/service/test_project_service.py index bcf0b2bc..ff9a75c2 100644 --- a/echo/server/tests/service/test_project_service.py +++ b/echo/server/tests/service/test_project_service.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) +@pytest.mark.integration def test_create_project(): project = project_service.create( name="Test Project", @@ -24,6 +25,7 @@ def test_create_project(): project_service.delete(project["id"]) +@pytest.mark.integration def test_create_and_link_tags(): project = project_service.create( name="Test Project", @@ -48,6 +50,7 @@ def test_create_and_link_tags(): project_service.delete(project["id"]) +@pytest.mark.integration def test_get_by_id_or_raise(): project = project_service.create( name="Test Project", @@ -60,11 +63,13 @@ def test_get_by_id_or_raise(): project_service.delete(project["id"]) +@pytest.mark.integration def test_get_by_id_not_found(): with pytest.raises(ProjectNotFoundException): project_service.get_by_id_or_raise("not-found") +# Unit test - uses mocks, no external dependencies def test_get_by_id_empty_result(): """Test exception handling when no project found.""" with patch("dembrane.service.project.directus_client_context") as mock_context: @@ -76,6 +81,7 @@ def test_get_by_id_empty_result(): project_service.get_by_id_or_raise("test-id") +@pytest.mark.integration def test_delete_project(): project = project_service.create( name="Test Project", @@ -89,6 +95,7 @@ def test_delete_project(): project_service.get_by_id_or_raise(project["id"]) +@pytest.mark.integration def test_create_shallow_clone(): project = project_service.create( name="Test Project", diff --git a/echo/server/tests/smoke/conftest.py b/echo/server/tests/smoke/conftest.py index c5564691..15d97353 100644 --- a/echo/server/tests/smoke/conftest.py +++ b/echo/server/tests/smoke/conftest.py @@ -1,4 +1,5 @@ import os + import pytest @@ -12,4 +13,3 @@ def api_url(): def directus_url(): """Directus URL for smoke tests""" return os.getenv("TEST_DIRECTUS_URL", "https://directus.echo-testing.dembrane.com") - diff --git a/echo/server/tests/test_audio_utils.py b/echo/server/tests/test_audio_utils.py index 360d9c86..09c37403 100644 --- a/echo/server/tests/test_audio_utils.py +++ b/echo/server/tests/test_audio_utils.py @@ -20,6 +20,8 @@ logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + AUDIO_FILES = [ "wav.wav", diff --git a/echo/server/tests/test_chat_utils.py b/echo/server/tests/test_chat_utils.py index 863fb1f6..d76b41ad 100644 --- a/echo/server/tests/test_chat_utils.py +++ b/echo/server/tests/test_chat_utils.py @@ -1,7 +1,11 @@ import asyncio +import pytest + from dembrane.chat_utils import generate_title +pytestmark = pytest.mark.integration + def test_generate_title(): with asyncio.Runner() as runner: diff --git a/echo/server/tests/test_conversation_utils.py b/echo/server/tests/test_conversation_utils.py index b8061993..5812dc74 100644 --- a/echo/server/tests/test_conversation_utils.py +++ b/echo/server/tests/test_conversation_utils.py @@ -1,6 +1,8 @@ import logging from datetime import timedelta +import pytest + from dembrane.utils import get_utc_timestamp from dembrane.directus import directus from dembrane.conversation_utils import ( @@ -20,6 +22,8 @@ logger = logging.getLogger("test_conversation_utils") +pytestmark = pytest.mark.integration + def test_create_conversation_chunk(): # Create test project diff --git a/echo/server/tests/test_embedding.py b/echo/server/tests/test_embedding.py index 3e9ff134..91f90d05 100644 --- a/echo/server/tests/test_embedding.py +++ b/echo/server/tests/test_embedding.py @@ -1,7 +1,11 @@ import math +import pytest + from dembrane.embedding import EMBEDDING_DIM, embed_text +pytestmark = pytest.mark.integration + def test_embed_text_returns_list_of_floats(): """Ensure `embed_text` returns a list of floats of the expected length.""" diff --git a/echo/server/tests/test_quote_utils.py b/echo/server/tests/test_quote_utils.py index 09d13fa4..eb362588 100644 --- a/echo/server/tests/test_quote_utils.py +++ b/echo/server/tests/test_quote_utils.py @@ -5,6 +5,7 @@ from typing import List import numpy as np +import pytest from sqlalchemy.orm import Session from dembrane.utils import generate_uuid @@ -26,6 +27,8 @@ logger = logging.getLogger("test_quote_utils") +pytestmark = pytest.mark.integration + def test_create_test_quotes( db: Session, project_analysis_run_id: str, conversation_id: str, count: int = 10 diff --git a/echo/server/tests/test_transcribe_assembly.py b/echo/server/tests/test_transcribe_assembly.py index 9eab1bec..6d0dc578 100644 --- a/echo/server/tests/test_transcribe_assembly.py +++ b/echo/server/tests/test_transcribe_assembly.py @@ -10,6 +10,8 @@ logger = logging.getLogger("test_transcribe_assembly") +pytestmark = pytest.mark.integration + def _require_assemblyai(): """Ensure AssemblyAI is enabled and credentials are present or skip.""" diff --git a/echo/server/tests/test_transcribe_runpod.py b/echo/server/tests/test_transcribe_runpod.py index 5fe5ba7d..604f207b 100644 --- a/echo/server/tests/test_transcribe_runpod.py +++ b/echo/server/tests/test_transcribe_runpod.py @@ -14,6 +14,9 @@ logger = logging.getLogger("test_transcribe") +# Runpod is no longer used in the platform; keep tests for reference but skip them. +pytestmark = pytest.mark.skip(reason="Runpod transcription is deprecated and no longer used") + @pytest.fixture def fixture_english_chunk(): logger.info("setup") diff --git a/echo/server/tests/test_utils.py b/echo/server/tests/test_utils.py new file mode 100644 index 00000000..2f9cd004 --- /dev/null +++ b/echo/server/tests/test_utils.py @@ -0,0 +1,106 @@ +"""Unit tests for utility functions that don't require external services""" +import re +from datetime import datetime, timezone + +from dembrane.utils import ( + generate_uuid, + get_safe_filename, + get_utc_timestamp, + generate_4_digit_pin, + generate_6_digit_pin, +) + + +def test_generate_uuid(): + """Test that generate_uuid returns a valid UUID string""" + uuid_str = generate_uuid() + + # Check it's a string + assert isinstance(uuid_str, str) + + # Check it matches UUID format (8-4-4-4-12 hexadecimal digits) + uuid_pattern = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + assert uuid_pattern.match(uuid_str), f"Invalid UUID format: {uuid_str}" + + # Check that calling it twice returns different UUIDs + uuid_str2 = generate_uuid() + assert uuid_str != uuid_str2, "UUIDs should be unique" + + +def test_generate_4_digit_pin(): + """Test that generate_4_digit_pin returns a 4-digit string""" + pin = generate_4_digit_pin() + + # Check it's a string + assert isinstance(pin, str) + + # Check it's exactly 4 characters + assert len(pin) == 4, f"PIN should be 4 digits, got {len(pin)}" + + # Check it's all digits + assert pin.isdigit(), f"PIN should only contain digits, got {pin}" + + # Check it's in valid range (1000-9999) + pin_int = int(pin) + assert 1000 <= pin_int <= 9999, f"PIN should be between 1000 and 9999, got {pin_int}" + + +def test_generate_6_digit_pin(): + """Test that generate_6_digit_pin returns a 6-digit string""" + pin = generate_6_digit_pin() + + # Check it's a string + assert isinstance(pin, str) + + # Check it's exactly 6 characters + assert len(pin) == 6, f"PIN should be 6 digits, got {len(pin)}" + + # Check it's all digits + assert pin.isdigit(), f"PIN should only contain digits, got {pin}" + + # Check it's in valid range (100000-999999) + pin_int = int(pin) + assert 100000 <= pin_int <= 999999, f"PIN should be between 100000 and 999999, got {pin_int}" + + +def test_get_utc_timestamp(): + """Test that get_utc_timestamp returns a UTC datetime""" + timestamp = get_utc_timestamp() + + # Check it's a datetime + assert isinstance(timestamp, datetime) + + # Check it has UTC timezone + assert timestamp.tzinfo == timezone.utc, "Timestamp should have UTC timezone" + + # Check it's close to current time (within 1 second) + now = datetime.now(tz=timezone.utc) + time_diff = abs((now - timestamp).total_seconds()) + assert time_diff < 1, f"Timestamp should be current time, got difference of {time_diff}s" + + +def test_get_safe_filename(): + """Test that get_safe_filename sanitizes filenames correctly""" + # Test replacing forward slashes + assert get_safe_filename("path/to/file.txt") == "path_to_file.txt" + + # Test replacing backslashes + assert get_safe_filename("path\\to\\file.txt") == "path_to_file.txt" + + # Test replacing spaces + assert get_safe_filename("my file name.txt") == "my_file_name.txt" + + # Test combining multiple replacements + assert get_safe_filename("path/to\\my file.txt") == "path_to_my_file.txt" + + # Test already safe filename + assert get_safe_filename("safe_filename.txt") == "safe_filename.txt" + + # Test empty string + assert get_safe_filename("") == "" + + # Test filename with only special characters + assert get_safe_filename("/ \\ ") == "____" +