open-notebook/tests/test_domain.py
MisonL 67dd85c928
Feat/localization tests docker (#371)
* feat(i18n): complete 100% internationalization and fix Next.js 15 compatibility

* feat(i18n): complete 100% internationalization coverage

* chore(test): finalize component tests and project cleanup

* test(logic): add unit tests for useModalManager hook

* fix(test): resolve timeout in AppSidebar tests by mocking TooltipProvider

* feat(i18n): comprehensive i18n audit, fixes for hardcoded strings, and complete zh-TW support

* fix(i18n): resolve TypeScript warnings and improve translation hook stability

- Remove unused useTranslation import from ConnectionGuard
- Add ref-based checking state to prevent dependency cycles
- Fix useTranslation hook to return empty string for undefined translations
- Add comment for backward compatibility on ExtractedReference interface
- Ensure .replace() string methods work safely with nested translation keys

* feat(i18n): complete internationalization implementation with Docker deployment

- Add LanguageLoadingOverlay component for smooth language transitions
- Update all translation files (en-US, zh-CN, zh-TW) with improved terminology
- Optimize Docker configuration for better performance
- Update version check and config handling for i18n support
- Fix route handling for language-specific content
- Add comprehensive task documentation

* fix(i18n): resolve localization errors, duplicates, and type issues

* chore(i18n): finalize 100% internationalization coverage

* chore(test): supplement i18n test cases and cleanup redundant files

* fix(test): resolve lint type errors and finalize delivery documents

* feat(i18n): finalize full internationalization and zh-TW localization

* fix(frontend): add missing devDependency and fix build tsconfig

* feat(ui): enhance sidebar hover effects with better visual feedback

* fix(frontend): resolve accessibility, i18n, and lint issues

- fix: add missing id, name, autocomplete attributes to dialog inputs
- fix: add aria labels and DialogDescription for accessibility
- fix: resolve uncontrolled component warning in SettingsForm
- fix: correct duplicate 'Traditional Chinese' label in zh-TW locale
- feat: add i18n support for podcast template names
- chore: fix lint errors in Dialogs

* fix: address all 21 PR feedback items from cubic-dev-ai bot

Configuration:
- Remove ignoreDuringBuilds flags from next.config.ts

Testing:
- Fix AppSidebar.test.tsx regex pattern and add missing assertion

Logic:
- Fix ConnectionGuard.tsx re-entry prevention logic

Internationalization (I18n) - Translations:
- Add missing keys: notebooks.archived, common.note/insight, accessibility keys
- Add specific keys: sources.allSourcesDescShort, transformations.selectModel
- Add singular/plural keys: podcasts.usedByCount_one/other, common.note/notes
- Add common.created/updated with {time} placeholder

Internationalization (I18n) - Usage:
- SourcesPage: use allSourcesDescShort instead of string splitting
- TransformationPlayground: use navigation.transformation and selectModel
- CommandPalette: use dedicated keys instead of string concatenation
- GeneratePodcastDialog: fix zh-TW date locale handling
- NotebookHeader: correctly interpolate {time} placeholder
- TransformationCard: use common.description instead of undefined key
- ChatPanel/SpeakerProfilesPanel: implement proper pluralization
- SystemInfo: correctly interpolate {version} placeholder
- LanguageLoadingOverlay: use t.common.loading instead of hardcoded string
- MessageActions: use specific error key cannotSaveNoteNoNotebook

Other:
- Fix SessionManager.tsx exhaustive-deps warning

* fix: remove duplicate locale keys and add missing zh-CN translations

- en-US: remove duplicate loading key (line 59) and addNew key (sources)
- zh-CN: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-CN: remove duplicate accessibility.searchNotebooks key
- zh-CN: remove duplicate sources.addNew key
- zh-CN: remove duplicate navigation.transformation key
- zh-CN: add missing usedByCount_one and usedByCount_other keys in podcasts
- zh-TW: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-TW: remove duplicate accessibility.searchNotebooks key
- zh-TW: remove duplicate sources.addNew key

* docs: remove info.md

* fix: remove duplicate notebook keys and unused ts-expect-error

- zh-CN: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- zh-TW: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- GeneratePodcastDialog: remove unused @ts-expect-error directive

* fix(a11y): fix unassociated labels in search page

- Replace <Label> with role='group' + aria-labelledby for search type section
- Replace <Label> with role='group' + aria-labelledby for search in section
- Follows WAI-ARIA best practices for labeling form field groups

* fix(a11y): fix unassociated labels across multiple components

- search/page.tsx: use role='group' + aria-labelledby for search type and search in sections
- RebuildEmbeddings.tsx: use role='group' + aria-labelledby for include checkboxes
- TransformationPlayground.tsx: replace Label with span for non-form output label

* chore: revert to npm stack and ensure i18n compatibility

* chore: polish zh-TW translations for better idiomatic usage

* fix: resolve linter errors (ruff import sort, mypy config duplicate)

* style: apply ruff formatting

* fix: finalize upstream compliance (Dockerfile.single, i18n hooks, docker-compose)

* style: polish strings, fix timeout cleanup, and improve test mocks

* fix: use relative imports in test setup to resolve IDE path errors

* perf(docker): optimize build speed by removing apt-get upgrade and build tools

- Remove apt-get upgrade from both builder and runtime stages (saves 10-15 min each)
- Remove gcc/g++/make/git from builder (uv downloads pre-built wheels)
- Add --no-install-recommends to minimize package footprint
- Keep npm mirror (npmmirror.com) for faster frontend deps
- Add npm registry config for reliable China network access

Also includes:
- fix(a11y): add missing labels and aria attributes to form fields
- fix(i18n): add 2s safety timeout to LanguageLoadingOverlay
- fix(i18n): add robustness checks to use-translation proxy

Build time reduced from 2+ hours to ~34 minutes (~70% improvement)

* fix(a11y): resolve 16 form field accessibility warnings in notebook and podcast pages

* fix(a11y): resolve 4 button and 1 select field accessibility warnings in models page

* fix(a11y): resolve redundant attributes and residual warnings in transformations and podcast forms

* fix(i18n): deep fix for language switch hang using proxy protection and safer access

* fix(a11y): add name attributes to ModelSelector, TransformationPlayground, and SourceDetailContent

* fix: add missing Label import to SourceDetailContent

* fix(i18n): use native react-i18next in LanguageLoadingOverlay to prevent hang during language switch

* fix(i18n): rewrite use-translation Proxy with strict depth limit and expanded blocked props to prevent language switch hang

* fix: add type assertion to fix TypeScript comparison error

* fix(i18n): disable useSuspense to prevent thread hang during language resource loading

* fix(i18n): add infinite loop detection circuit breaker to useTranslation hook

* fix(i18n): update traditional chinese label to native script in en-US

* feat: add new localization strings for notebook and note management.

* fix: resolve config priority, docker build deps, and ui glitches

* refactor: improve ui details and test coverage based on feedback

* refactor: improve ui details (version check/lang toggle) and test coverage

* fix: polish language matching and test cleanup

* fix(test): update mocks to resolve timeouts and proxy errors

* fix(frontend): restore tsconfig.json structure and enable IDE support for tests

* fix: address PR review findings and resolve CI OIDC failure

* fix: merge exception headers in custom handler

* fix: comprehensive PR review remediations and async performance fixes

* refactor: address all PR #371 review feedback

- Docker: consolidate SURREAL_URL to docker.env, add single-container override
- Security: restore apt-get upgrade in Dockerfile and Dockerfile.single
- Create centralized getDateLocale helper (lib/utils/date-locale.ts)
- Refactor 7 files to use getDateLocale helper
- Revert config/route.ts to origin/main version
- Move test files to co-located pattern (3 files)
- Remove local useTranslation mock from ConfirmDialog.test.tsx
- Simplify use-version-check to single useEffect pattern
- Fix test import paths after moving to co-located pattern

* fix: add jest-dom types for test files

* fix: address remaining review issues

- Add apt-get upgrade -y to Dockerfile.single backend-builder stage
- Refactor ChatColumn.test.tsx: use 'as unknown as ReturnType<typeof hook>' instead of 'as any'
- Use toBeInTheDocument() assertions instead of toBeDefined()
2026-01-15 13:51:05 -03:00

396 lines
14 KiB
Python

"""
Unit tests for the open_notebook.domain module.
This test suite focuses on validation logic, business rules, and data structures
that can be tested without database mocking.
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import ValidationError
from open_notebook.ai.models import ModelManager
from open_notebook.domain.base import RecordModel
from open_notebook.domain.content_settings import ContentSettings
from open_notebook.domain.notebook import Asset, Note, Notebook, Source
from open_notebook.domain.transformation import Transformation
from open_notebook.exceptions import InvalidInputError
from open_notebook.podcasts.models import EpisodeProfile, SpeakerProfile
# ============================================================================
# TEST SUITE 1: RecordModel Singleton Pattern
# ============================================================================
class TestRecordModelSingleton:
"""Test suite for RecordModel singleton behavior."""
def test_recordmodel_singleton_behavior(self):
"""Test that same instance is returned for same record_id."""
class TestRecord(RecordModel):
record_id = "test:singleton"
value: int = 0
# Clear any existing instance
TestRecord.clear_instance()
# Create first instance
instance1 = TestRecord(value=42)
assert instance1.value == 42
# Create second instance - should return same object
instance2 = TestRecord(value=99)
assert instance1 is instance2
assert instance2.value == 99 # Value was updated
# Cleanup
TestRecord.clear_instance()
# ============================================================================
# TEST SUITE 2: ModelManager Instance Isolation
# ============================================================================
class TestModelManager:
"""Test suite for ModelManager instance behavior."""
def test_model_manager_instance_isolation(self):
"""Test that each ModelManager instance is independent (not a singleton)."""
manager1 = ModelManager()
manager2 = ModelManager()
# Each instance should be independent (not a singleton)
assert manager1 is not manager2
assert id(manager1) != id(manager2)
# ============================================================================
# TEST SUITE 3: Notebook Domain Logic
# ============================================================================
class TestNotebookDomain:
"""Test suite for Notebook validation and business rules."""
def test_notebook_name_validation(self):
"""Test empty/whitespace names are rejected."""
# Empty name should raise error
with pytest.raises(InvalidInputError, match="Notebook name cannot be empty"):
Notebook(name="", description="Test")
# Whitespace-only name should raise error
with pytest.raises(InvalidInputError, match="Notebook name cannot be empty"):
Notebook(name=" ", description="Test")
# Valid name should work
notebook = Notebook(name="Valid Name", description="Test")
assert notebook.name == "Valid Name"
def test_notebook_archived_flag(self):
"""Test archived flag defaults to False."""
notebook = Notebook(name="Test", description="Test")
assert notebook.archived is False
notebook_archived = Notebook(name="Test", description="Test", archived=True)
assert notebook_archived.archived is True
# ============================================================================
# TEST SUITE 4: Source Domain
# ============================================================================
class TestSourceDomain:
"""Test suite for Source domain model."""
def test_source_command_field_parsing(self):
"""Test RecordID parsing for command field."""
# Test with string command
source = Source(title="Test", command="command:123")
assert source.command is not None
# Test with None command
source2 = Source(title="Test", command=None)
assert source2.command is None
# Test command is included in save data prep
source3 = Source(id="source:123", title="Test", command="command:456")
save_data = source3._prepare_save_data()
assert "command" in save_data
@pytest.mark.asyncio
async def test_source_delete_cleans_up_file(self):
"""Test that deleting a source removes the associated file."""
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp_file:
tmp_file.write(b"Test content")
tmp_path = Path(tmp_file.name)
try:
# Create source with file asset
source = Source(
id="source:test_delete",
title="Test Source",
asset=Asset(file_path=str(tmp_path)),
)
# Verify file exists
assert tmp_path.exists()
# Mock the parent delete method to avoid database operations
with patch.object(
Source.__bases__[0], "delete", new_callable=AsyncMock
) as mock_delete:
mock_delete.return_value = True
# Delete the source
result = await source.delete()
# Verify parent delete was called
mock_delete.assert_called_once()
assert result is True
# Verify file was deleted
assert not tmp_path.exists()
finally:
# Cleanup in case test fails
if tmp_path.exists():
tmp_path.unlink()
@pytest.mark.asyncio
async def test_source_delete_without_file(self):
"""Test that deleting a source without a file doesn't fail."""
# Create source without file asset
source = Source(id="source:test_no_file", title="Test Source", asset=None)
# Mock the parent delete method
with patch.object(
Source.__bases__[0], "delete", new_callable=AsyncMock
) as mock_delete:
mock_delete.return_value = True
# Delete should complete without error
result = await source.delete()
assert result is True
mock_delete.assert_called_once()
@pytest.mark.asyncio
async def test_source_delete_continues_on_file_error(self):
"""Test that source deletion continues even if file deletion fails."""
# Create source with non-existent file
source = Source(
id="source:test_missing_file",
title="Test Source",
asset=Asset(file_path="/nonexistent/path/file.txt"),
)
# Mock the parent delete method
with patch.object(
Source.__bases__[0], "delete", new_callable=AsyncMock
) as mock_delete:
mock_delete.return_value = True
# Delete should complete even though file doesn't exist
result = await source.delete()
assert result is True
mock_delete.assert_called_once()
# ============================================================================
# TEST SUITE 5: Note Domain
# ============================================================================
class TestNoteDomain:
"""Test suite for Note validation."""
def test_note_content_validation(self):
"""Test empty content is rejected."""
# None content is allowed
note = Note(title="Test", content=None)
assert note.content is None
# Non-empty content is valid
note2 = Note(title="Test", content="Valid content")
assert note2.content == "Valid content"
# Empty string should raise error
with pytest.raises(InvalidInputError, match="Note content cannot be empty"):
Note(title="Test", content="")
# Whitespace-only should raise error
with pytest.raises(InvalidInputError, match="Note content cannot be empty"):
Note(title="Test", content=" ")
def test_note_embedding_enabled(self):
"""Test notes have embedding enabled by default."""
note = Note(title="Test", content="Test content")
assert note.needs_embedding() is True
assert note.get_embedding_content() == "Test content"
# Test with None content
note2 = Note(title="Test", content=None)
assert note2.get_embedding_content() is None
# ============================================================================
# TEST SUITE 6: Podcast Domain Validation
# ============================================================================
class TestPodcastDomain:
"""Test suite for Podcast domain validation."""
def test_speaker_profile_validation(self):
"""Test speaker profile validates count and required fields."""
# Test invalid - no speakers
with pytest.raises(ValidationError):
SpeakerProfile(
name="Test",
tts_provider="openai",
tts_model="tts-1",
speakers=[],
)
# Test invalid - too many speakers (> 4)
with pytest.raises(ValidationError):
SpeakerProfile(
name="Test",
tts_provider="openai",
tts_model="tts-1",
speakers=[{"name": f"Speaker{i}"} for i in range(5)],
)
# Test invalid - missing required fields
with pytest.raises(ValidationError):
SpeakerProfile(
name="Test",
tts_provider="openai",
tts_model="tts-1",
speakers=[
{"name": "Speaker 1"}
], # Missing voice_id, backstory, personality
)
# Test valid - single speaker with all fields
profile = SpeakerProfile(
name="Test",
tts_provider="openai",
tts_model="tts-1",
speakers=[
{
"name": "Host",
"voice_id": "voice123",
"backstory": "A friendly host",
"personality": "Enthusiastic and welcoming",
}
],
)
assert len(profile.speakers) == 1
assert profile.speakers[0]["name"] == "Host"
# ============================================================================
# TEST SUITE 7: Transformation Domain
# ============================================================================
class TestTransformationDomain:
"""Test suite for Transformation domain model."""
def test_transformation_creation(self):
"""Test transformation model creation."""
transform = Transformation(
name="summarize",
title="Summarize Content",
description="Creates a summary",
prompt="Summarize the following text: {content}",
apply_default=True,
)
assert transform.name == "summarize"
assert transform.apply_default is True
# ============================================================================
# TEST SUITE 8: Content Settings
# ============================================================================
class TestContentSettings:
"""Test suite for ContentSettings defaults."""
def test_content_settings_defaults(self):
"""Test ContentSettings has proper defaults."""
settings = ContentSettings()
assert settings.record_id == "open_notebook:content_settings"
assert settings.default_content_processing_engine_doc == "auto"
assert settings.default_embedding_option == "ask"
assert settings.auto_delete_files == "yes"
assert len(settings.youtube_preferred_languages) > 0
# ============================================================================
# TEST SUITE 9: Episode Profile Validation
# ============================================================================
class TestEpisodeProfile:
"""Test suite for EpisodeProfile validation."""
def test_episode_profile_segment_validation(self):
"""Test segment count validation (3-20)."""
# Test invalid - too few segments
with pytest.raises(
ValidationError, match="Number of segments must be between 3 and 20"
):
EpisodeProfile(
name="Test",
speaker_config="default",
outline_provider="openai",
outline_model="gpt-4",
transcript_provider="openai",
transcript_model="gpt-4",
default_briefing="Test briefing",
num_segments=2,
)
# Test invalid - too many segments
with pytest.raises(
ValidationError, match="Number of segments must be between 3 and 20"
):
EpisodeProfile(
name="Test",
speaker_config="default",
outline_provider="openai",
outline_model="gpt-4",
transcript_provider="openai",
transcript_model="gpt-4",
default_briefing="Test briefing",
num_segments=21,
)
# Test valid segment count
profile = EpisodeProfile(
name="Test",
speaker_config="default",
outline_provider="openai",
outline_model="gpt-4",
transcript_provider="openai",
transcript_model="gpt-4",
default_briefing="Test briefing",
num_segments=5,
)
assert profile.num_segments == 5
if __name__ == "__main__":
pytest.main([__file__, "-v"])