Story 2.4 html formatting simple solution

main
PeninsulaInd 2025-10-20 16:43:31 -05:00
parent 19e1c93358
commit 7a9346635b
17 changed files with 1732 additions and 98 deletions

View File

@ -54,3 +54,19 @@ Implement the core workflow for ingesting CORA data and using AI to generate and
- The function correctly selects and applies the appropriate template based on the configuration mapping.
- The content is structured into a valid HTML document with the selected CSS.
- The final HTML content is stored and associated with the project in the database.
**Dependencies**
- Story 2.5 (optional): If no site_deployment_id is assigned, template selection defaults to random.
### Story 2.5: Deployment Target Assignment
**As a developer**, I want to assign deployment targets to generated content during the content generation process, so that each article knows which site/bucket it will be deployed to and can use the appropriate template.
**Acceptance Criteria**
- The job configuration file supports an optional `deployment_targets` array containing site custom_hostnames or site_deployment_ids.
- The job configuration file supports an optional `deployment_overflow` strategy ("round_robin", "random_available", or "none").
- During content generation, each article is assigned a `site_deployment_id` based on its index in the batch:
- If `deployment_targets` is specified, cycle through the list (round-robin by default).
- If the batch size exceeds the target list, apply the overflow strategy.
- If no `deployment_targets` specified, `site_deployment_id` remains null (random template in Story 2.4).
- The `site_deployment_id` is stored in the `GeneratedContent` record at creation time.
- Invalid site references in `deployment_targets` cause graceful errors with clear messages.

View File

@ -0,0 +1,141 @@
# Story 2.4: HTML Formatting with Multiple Templates
## Status
Completed
## Story
**As a developer**, I want a module that takes the generated text content and formats it into a standard HTML file using one of a few predefined CSS templates, assigning one template per bucket/subdomain, so that all deployed content has a consistent look and feel per site.
## Acceptance Criteria
- A directory of multiple, predefined HTML/CSS templates exists.
- The master JSON configuration file maps a specific template to each deployment target (e.g., S3 bucket, subdomain).
- A function accepts the generated content and a target identifier (e.g., bucket name).
- The function correctly selects and applies the appropriate template based on the configuration mapping.
- The content is structured into a valid HTML document with the selected CSS.
- The final HTML content is stored and associated with the project in the database.
## Dependencies
- **Story 2.5**: Deployment Target Assignment must run before this story to set `site_deployment_id` on GeneratedContent
- If `site_deployment_id` is null, a random template will be selected
## Tasks / Subtasks
### 1. Create Template Infrastructure
**Effort:** 3 story points
- [x] Create template file structure under `src/templating/templates/`
- Basic template (default)
- Modern template
- Classic template
- Minimal template
- [x] Each template should include:
- HTML structure with placeholders for title, meta, content
- Embedded or inline CSS for styling
- Responsive design (mobile-friendly)
- SEO-friendly structure (proper heading hierarchy, meta tags)
### 2. Implement Template Loading Service
**Effort:** 3 story points
- [x] Implement `TemplateService` class in `src/templating/service.py`
- [x] Add `load_template(template_name: str)` method that reads template file
- [x] Add `get_available_templates()` method that lists all templates
- [x] Handle template file not found errors gracefully with fallback to default
- [x] Cache loaded templates in memory for performance
### 3. Implement Template Selection Logic
**Effort:** 2 story points
- [x] Add `select_template_for_content(site_deployment_id: Optional[int])` method
- [x] If `site_deployment_id` exists:
- Query SiteDeployment table for custom_hostname
- Check `master.config.json` templates.mappings for hostname
- If mapping exists, use it
- If no mapping, randomly select template and save to config
- [x] If `site_deployment_id` is null: randomly select template
- [x] Return template name
### 4. Implement Content Formatting
**Effort:** 5 story points
- [x] Create `format_content(content: str, title: str, meta_description: str, template_name: str)` method
- [x] Parse HTML content and extract components
- [x] Replace template placeholders with actual content
- [x] Ensure proper escaping of HTML entities where needed
- [x] Validate output is well-formed HTML
- [x] Return formatted HTML string
### 5. Database Integration
**Effort:** 2 story points
- [x] Add `formatted_html` field to `GeneratedContent` model (Text type, nullable)
- [x] Add `template_used` field to `GeneratedContent` model (String(50), nullable)
- [x] Add `site_deployment_id` field to `GeneratedContent` model (FK to site_deployments, nullable, indexed)
- [x] Create database migration script
- [x] Update repository to save formatted HTML and template_used alongside raw content
### 6. Integration with Content Generation Flow
**Effort:** 2 story points
- [x] Update `src/generation/service.py` to call template service after content generation
- [x] Template service reads `site_deployment_id` from GeneratedContent
- [x] Store formatted HTML and template_used in database
- [x] Handle template formatting errors without breaking content generation
### 7. Unit Tests
**Effort:** 3 story points
- [x] Test template loading with valid and invalid names
- [x] Test template selection with site_deployment_id present
- [x] Test template selection with site_deployment_id null (random)
- [x] Test content formatting with different templates
- [x] Test fallback behavior when template not found
- [x] Test error handling for malformed templates
- [x] Achieve >80% code coverage for templating module
### 8. Integration Tests
**Effort:** 2 story points
- [x] Test end-to-end flow: content generation → template application → database storage
- [x] Test with site_deployment_id assigned (consistent template per site)
- [x] Test with site_deployment_id null (random template)
- [x] Verify formatted HTML is valid and renders correctly
- [x] Test new site gets random template assigned and persisted to config
## Dev Notes
### Current State
- `master.config.json` already has templates section with mappings (lines 52-59)
- `src/templating/service.py` exists but is empty (only 2 lines)
- `src/templating/templates/` directory exists but only contains `__init__.py`
- `GeneratedContent` model stores raw content in Text field but no formatted HTML field yet
### Dependencies
- Story 2.2/2.3: Content must be generated before it can be formatted
- Story 2.5: Deployment target assignment (optional - defaults to random if not assigned)
- Configuration system: Uses existing master.config.json structure
### Technical Decisions
1. **Template format:** Jinja2 or simple string replacement (to be decided during implementation)
2. **CSS approach:** Embedded `<style>` tags in HTML template
3. **Storage:** Store both raw content AND formatted HTML
4. **Template selection:**
- Has site_deployment_id + config mapping → use mapped template
- Has site_deployment_id + no mapping → pick random, save to config
- No site_deployment_id → pick random, don't persist
5. Story 2.4 owns ALL template selection logic
### Suggested Template Structure
```
src/templating/templates/
├── basic.html
├── modern.html
├── classic.html
└── minimal.html
```
### Testing Approach
- Unit tests: Test each service method in isolation
- Integration tests: Test full content → template → storage flow
- Manual QA: Visual inspection of rendered HTML in browser

View File

@ -0,0 +1,157 @@
# Story 2.5: Deployment Target Assignment
## Status
Draft
## Story
**As a developer**, I want to assign deployment targets (site_deployment_id) to generated content during the content generation process based on job configuration, so that each article knows which site/bucket it will be deployed to.
**Note:** This story ONLY assigns site_deployment_id. Template selection logic is handled entirely by Story 2.4.
## Acceptance Criteria
- The job configuration file supports an optional `deployment_targets` array containing site custom_hostnames or site_deployment_ids.
- The job configuration file supports an optional `deployment_overflow` strategy ("round_robin", "random_available", or "none").
- During content generation, each article is assigned a `site_deployment_id` based on its index in the batch:
- If `deployment_targets` is specified, cycle through the list (round-robin by default).
- If the batch size exceeds the target list, apply the overflow strategy.
- If no `deployment_targets` specified, `site_deployment_id` remains null (random template in Story 2.4).
- The `site_deployment_id` is stored in the `GeneratedContent` record at creation time.
- Invalid site references in `deployment_targets` cause graceful errors with clear messages.
## Tasks / Subtasks
### 1. Update Job Configuration Schema
**Effort:** 2 story points
- [ ] Add `deployment_targets` field (optional array of strings) to job config schema
- [ ] Add `deployment_overflow` field (optional string: "round_robin", "random_available", "none")
- [ ] Default `deployment_overflow` to "round_robin" if not specified
- [ ] Update job config validation to check deployment_targets format
- [ ] Update example job files in `jobs/` directory with new fields
### 2. Implement Target Resolution Service
**Effort:** 3 story points
- [ ] Create `DeploymentTargetResolver` class in `src/deployment/` or appropriate module
- [ ] Implement `resolve_target(identifier: str) -> Optional[int]` method
- Accept custom_hostname or site_deployment_id (as string)
- Query SiteDeployment table to get site_deployment_id
- Return None if not found
- [ ] Implement `validate_targets(targets: List[str])` method
- Pre-validate all targets in deployment_targets array
- Return list of invalid targets if any
- Fail fast with clear error message
### 3. Implement Assignment Strategy Logic
**Effort:** 4 story points
- [ ] Implement `assign_site_for_article(article_index: int, job_config: dict, total_articles: int) -> Optional[int]`
- [ ] **Round-robin strategy:**
- Cycle through deployment_targets using modulo operation
- Example: 10 articles, 5 targets → article_index % len(targets)
- [ ] **Random available strategy:**
- When article_index exceeds len(targets), query for SiteDeployments not in targets list
- Randomly select from available sites
- Handle case where no other sites exist (error)
- [ ] **None strategy:**
- Raise error if article_index exceeds len(targets)
- Strict mode: only deploy exact number of articles as targets
- [ ] Handle case where deployment_targets is None/empty (return None for all)
### 4. Database Integration
**Effort:** 2 story points
- [ ] Verify `site_deployment_id` field exists in `GeneratedContent` model (added in Story 2.4)
- [ ] Update `GeneratedContentRepository.create()` to accept `site_deployment_id` parameter
- [ ] Ensure proper foreign key relationship to SiteDeployment table
- [ ] Add database index on `site_deployment_id` for query performance
### 5. Integration with Content Generation Service
**Effort:** 3 story points
- [ ] Update `src/generation/service.py` to parse deployment config from job
- [ ] Call target resolver to validate deployment_targets at job start
- [ ] For each article in batch:
- Call assignment strategy to get site_deployment_id
- Pass site_deployment_id to repository when creating GeneratedContent
- [ ] Log assignment decisions (INFO level: "Article X assigned to site Y")
- [ ] Handle assignment errors gracefully without breaking batch
### 6. Unit Tests
**Effort:** 3 story points
- [ ] Test target resolution with valid hostnames
- [ ] Test target resolution with valid site_deployment_ids
- [ ] Test target resolution with invalid identifiers
- [ ] Test round-robin strategy with various batch sizes
- [ ] Test random_available strategy
- [ ] Test none strategy with overflow scenarios
- [ ] Test validation of deployment_targets array
- [ ] Achieve >80% code coverage
### 7. Integration Tests
**Effort:** 2 story points
- [ ] Test full generation flow with deployment_targets specified
- [ ] Test round-robin assignment across 10 articles with 5 targets
- [ ] Test with deployment_targets = null (all articles get null site_deployment_id)
- [ ] Test error handling for invalid deployment targets
- [ ] Verify site_deployment_id persisted correctly in database
## Dev Notes
### Example Job Config
```json
{
"job_name": "Multi-Site T1 Launch",
"project_id": 2,
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": [
{
"tier": 1,
"article_count": 10
}
]
}
```
### Assignment Example (Round-Robin)
10 articles, 3 targets:
- Article 0 → domain1.com
- Article 1 → domain2.com
- Article 2 → domain3.com
- Article 3 → domain1.com
- Article 4 → domain2.com
- ... and so on
### Assignment Example (Random Available)
10 articles, 3 targets, 5 total sites in database:
- Article 0-2 → Round-robin through specified targets
- Article 3+ → Random selection from domain4.com, domain5.com
### Technical Decisions
1. **Target identifier:** Support both hostname and numeric ID for flexibility
2. **Validation timing:** Validate all targets at job start (fail fast)
3. **Overflow default:** Round-robin is the safest default
4. **Null handling:** No deployment_targets = all articles get null site_deployment_id
### Dependencies
- **Story 1.6:** SiteDeployment table must exist
- **Story 2.3:** Content generation service must be functional
### Related Stories
- **Story 2.4:** Consumes site_deployment_id for template selection (but that's 2.4's concern, not this story's)
### Database Changes Required
None - `site_deployment_id` field added in Story 2.4 task #5
### Testing Strategy
- Unit tests: Test assignment algorithms in isolation
- Integration tests: Test full job execution with various configs
- Edge cases: Empty targets, oversized batches, invalid hostnames

View File

@ -30,6 +30,8 @@ Job files define batch content generation parameters using JSON format.
### Job Level
- `project_id` (required): The project ID to generate content for
- `tiers` (required): Dictionary of tier configurations
- `deployment_targets` (optional): Array of site custom_hostnames or site_deployment_ids to cycle through
- `deployment_overflow` (optional): Strategy when batch size exceeds deployment_targets ("round_robin", "random_available", or "none"). Default: "round_robin"
### Tier Level
- `count` (required): Number of articles to generate for this tier
@ -169,6 +171,43 @@ python main.py generate-batch --job-file jobs/example_tier1_batch.json --usernam
- `--continue-on-error`: Continue processing if article generation fails
- `--model, -m`: AI model to use (default: gpt-4o-mini)
### Deployment Target Assignment (Story 2.5)
Optionally specify which sites/buckets to deploy articles to:
```json
{
"jobs": [
{
"project_id": 1,
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": {
"tier1": {
"count": 10
}
}
}
]
}
```
This generates 10 articles distributed across 3 sites:
- Articles 0, 3, 6, 9 → domain1.com
- Articles 1, 4, 7 → domain2.com
- Articles 2, 5, 8 → domain3.com
**Overflow Strategies:**
- `round_robin` (default): Cycle back through specified targets
- `random_available`: Use random sites not in the targets list
- `none`: Error if batch exceeds target count (strict mode)
If `deployment_targets` is omitted, articles receive random templates (no site assignment).
### Debug Mode
When using `--debug`, AI responses are saved to `debug_output/`:

View File

@ -0,0 +1,38 @@
{
"job_name": "Multi-Site T1 Launch - 10 Articles Across 3 Sites",
"project_id": 2,
"description": "Generate 10 Tier 1 articles distributed across 3 deployment sites",
"deployment_targets": [
"www.domain1.com",
"www.domain2.com",
"www.domain3.com"
],
"deployment_overflow": "round_robin",
"tiers": [
{
"tier": 1,
"article_count": 10,
"models": {
"title": "openai/gpt-4o-mini",
"outline": "anthropic/claude-3.5-sonnet",
"content": "anthropic/claude-3.5-sonnet"
},
"anchor_text_config": {
"mode": "default",
"custom_text": null,
"additional_text": null
},
"validation_attempts": 3
}
],
"failure_config": {
"max_consecutive_failures": 5,
"skip_on_failure": true
},
"interlinking": {
"links_per_article_min": 2,
"links_per_article_max": 4,
"include_home_link": true
}
}

View File

@ -54,7 +54,8 @@
"mappings": {
"aws-s3-bucket-1": "modern",
"bunny-bucket-1": "classic",
"azure-bucket-1": "minimal"
"azure-bucket-1": "minimal",
"test.example.com": "minimal"
}
},
"deployment": {
@ -105,4 +106,4 @@
"reload": true,
"workers": 1
}
}
}

View File

@ -0,0 +1,95 @@
"""
Database migration script to add template-related fields to generated_content table
Adds:
- formatted_html (Text, nullable)
- template_used (String(50), nullable)
- site_deployment_id (Integer, FK to site_deployments, nullable, indexed)
Usage:
python scripts/migrate_add_template_fields.py
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from src.database.session import db_manager
from src.core.config import get_config
def migrate():
"""Add template-related fields to generated_content table"""
print("Starting migration: add template fields to generated_content...")
try:
config = get_config()
print(f"Database URL: {config.database.url}")
except Exception as e:
print(f"Error loading configuration: {e}")
sys.exit(1)
try:
db_manager.initialize()
engine = db_manager.get_engine()
with engine.connect() as conn:
print("Checking for existing columns...")
result = conn.execute(text("PRAGMA table_info(generated_content)"))
existing_columns = [row[1] for row in result]
print(f"Existing columns: {', '.join(existing_columns)}")
migrations_applied = []
if "formatted_html" not in existing_columns:
print("Adding formatted_html column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN formatted_html TEXT"))
migrations_applied.append("formatted_html")
conn.commit()
else:
print("formatted_html column already exists, skipping")
if "template_used" not in existing_columns:
print("Adding template_used column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN template_used VARCHAR(50)"))
migrations_applied.append("template_used")
conn.commit()
else:
print("template_used column already exists, skipping")
if "site_deployment_id" not in existing_columns:
print("Adding site_deployment_id column...")
conn.execute(text("ALTER TABLE generated_content ADD COLUMN site_deployment_id INTEGER"))
migrations_applied.append("site_deployment_id")
conn.commit()
print("Creating index on site_deployment_id...")
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_generated_content_site_deployment_id "
"ON generated_content(site_deployment_id)"
))
conn.commit()
else:
print("site_deployment_id column already exists, skipping")
if migrations_applied:
print(f"\nMigration complete! Added columns: {', '.join(migrations_applied)}")
else:
print("\nNo migrations needed - all columns already exist")
except Exception as e:
print(f"Error during migration: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db_manager.close()
if __name__ == "__main__":
migrate()

View File

@ -132,6 +132,9 @@ class GeneratedContent(Base):
content: Mapped[str] = mapped_column(Text, nullable=False)
word_count: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
formatted_html: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
template_used: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
site_deployment_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('site_deployments.id'), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,

View File

@ -5,6 +5,7 @@ Concrete repository implementations
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from src.core.config import get_config
from src.database.interfaces import IUserRepository, ISiteDeploymentRepository, IProjectRepository
from src.database.models import User, SiteDeployment, Project, GeneratedContent
@ -376,7 +377,6 @@ class ProjectRepository(IProjectRepository):
return False
<<<<<<< HEAD
class GeneratedContentRepository:
"""Repository for GeneratedContent data access"""
@ -426,98 +426,6 @@ class GeneratedContentRepository:
self.session.refresh(content_record)
return content_record
def get_by_id(self, content_id: int) -> Optional[GeneratedContent]:
"""
Get generated content by ID
Args:
content_id: The content ID to search for
Returns:
GeneratedContent object if found, None otherwise
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.id == content_id).first()
def get_by_project_id(self, project_id: int) -> List[GeneratedContent]:
"""
Get all generated content for a project
Args:
project_id: The project ID to search for
Returns:
List of GeneratedContent objects for the project
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.project_id == project_id).all()
def get_active_by_project(self, project_id: int, tier: int) -> Optional[GeneratedContent]:
"""
Get the active generated content for a project/tier
Args:
project_id: The project ID
tier: The tier level
Returns:
Active GeneratedContent object if found, None otherwise
"""
return self.session.query(GeneratedContent).filter(
GeneratedContent.project_id == project_id,
GeneratedContent.tier == tier,
GeneratedContent.is_active == True
).first()
def get_by_tier(self, tier: int) -> List[GeneratedContent]:
"""
Get all generated content for a specific tier
Args:
tier: The tier level
Returns:
List of GeneratedContent objects for the tier
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.tier == tier).all()
def get_by_status(self, status: str) -> List[GeneratedContent]:
"""
Get all generated content with a specific status
Args:
status: The status to filter by
Returns:
List of GeneratedContent objects with the status
"""
return self.session.query(GeneratedContent).filter(GeneratedContent.status == status).all()
def update(self, content: GeneratedContent) -> GeneratedContent:
"""
Update an existing generated content record
Args:
content: The GeneratedContent object with updated data
Returns:
The updated GeneratedContent object
"""
=======
content_record = GeneratedContent(
project_id=project_id,
tier=tier,
keyword=keyword,
title=title,
outline=outline,
content=content,
word_count=word_count,
status=status
)
self.session.add(content_record)
self.session.commit()
self.session.refresh(content_record)
return content_record
def get_by_id(self, content_id: int) -> Optional[GeneratedContent]:
"""Get content by ID"""
return self.session.query(GeneratedContent).filter(GeneratedContent.id == content_id).first()
@ -537,6 +445,10 @@ class GeneratedContentRepository:
"""Get content by keyword"""
return self.session.query(GeneratedContent).filter(GeneratedContent.keyword == keyword).all()
def get_by_status(self, status: str) -> List[GeneratedContent]:
"""Get content by status"""
return self.session.query(GeneratedContent).filter(GeneratedContent.status == status).all()
def update(self, content: GeneratedContent) -> GeneratedContent:
"""Update existing content"""
self.session.add(content)

View File

@ -9,7 +9,8 @@ from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple
from src.generation.ai_client import AIClient, PromptManager
from src.database.repositories import ProjectRepository, GeneratedContentRepository
from src.database.repositories import ProjectRepository, GeneratedContentRepository, SiteDeploymentRepository
from src.templating.service import TemplateService
class ContentGenerator:
@ -20,12 +21,16 @@ class ContentGenerator:
ai_client: AIClient,
prompt_manager: PromptManager,
project_repo: ProjectRepository,
content_repo: GeneratedContentRepository
content_repo: GeneratedContentRepository,
template_service: Optional[TemplateService] = None,
site_deployment_repo: Optional[SiteDeploymentRepository] = None
):
self.ai_client = ai_client
self.prompt_manager = prompt_manager
self.project_repo = project_repo
self.content_repo = content_repo
self.template_service = template_service or TemplateService(content_repo)
self.site_deployment_repo = site_deployment_repo
def generate_title(self, project_id: int, debug: bool = False) -> str:
"""
@ -286,6 +291,56 @@ class ContentGenerator:
return augmented
def apply_template(
self,
content_id: int,
meta_description: Optional[str] = None
) -> bool:
"""
Apply HTML template to generated content and save to database
Args:
content_id: GeneratedContent ID to format
meta_description: Optional meta description (defaults to truncated content)
Returns:
True if successful, False otherwise
"""
try:
content_record = self.content_repo.get_by_id(content_id)
if not content_record:
print(f"Warning: Content {content_id} not found")
return False
if not meta_description:
text = re.sub(r'<[^>]+>', '', content_record.content)
text = unescape(text)
words = text.split()[:25]
meta_description = ' '.join(words) + '...'
template_name = self.template_service.select_template_for_content(
site_deployment_id=content_record.site_deployment_id,
site_deployment_repo=self.site_deployment_repo
)
formatted_html = self.template_service.format_content(
content=content_record.content,
title=content_record.title,
meta_description=meta_description,
template_name=template_name
)
content_record.formatted_html = formatted_html
content_record.template_used = template_name
self.content_repo.update(content_record)
print(f"Applied template '{template_name}' to content {content_id}")
return True
except Exception as e:
print(f"Error applying template to content {content_id}: {e}")
return False
def _save_debug_output(
self,
project_id: int,

View File

@ -1 +1,202 @@
# Applies templates to content
"""
Template service for applying HTML/CSS templates to generated content
"""
import json
import random
from pathlib import Path
from typing import Optional, Dict
from src.core.config import get_config
from src.database.repositories import GeneratedContentRepository
class TemplateService:
"""Service for loading, selecting, and applying HTML templates"""
def __init__(self, content_repo: Optional[GeneratedContentRepository] = None):
self.templates_dir = Path(__file__).parent / "templates"
self._template_cache: Dict[str, str] = {}
self.content_repo = content_repo
def get_available_templates(self) -> list[str]:
"""
Get list of available template names
Returns:
List of template names without .html extension
"""
templates = []
for template_file in self.templates_dir.glob("*.html"):
templates.append(template_file.stem)
return sorted(templates)
def load_template(self, template_name: str) -> str:
"""
Load a template file by name
Args:
template_name: Name of template (without .html extension)
Returns:
Template content as string
Raises:
FileNotFoundError: If template doesn't exist
"""
if template_name in self._template_cache:
return self._template_cache[template_name]
template_path = self.templates_dir / f"{template_name}.html"
if not template_path.exists():
available = self.get_available_templates()
raise FileNotFoundError(
f"Template '{template_name}' not found. Available templates: {', '.join(available)}"
)
with open(template_path, 'r', encoding='utf-8') as f:
template_content = f.read()
self._template_cache[template_name] = template_content
return template_content
def select_template_for_content(
self,
site_deployment_id: Optional[int] = None,
site_deployment_repo=None
) -> str:
"""
Select appropriate template based on site deployment
Args:
site_deployment_id: Optional site deployment ID
site_deployment_repo: Optional repository for querying site deployments
Returns:
Template name to use
Logic:
1. If site_deployment_id exists:
- Query custom_hostname from SiteDeployment
- Check config mappings for hostname
- If mapping exists, use it
- If no mapping, randomly select and persist to config
2. If site_deployment_id is null: randomly select (don't persist)
"""
config = get_config()
if site_deployment_id and site_deployment_repo:
site_deployment = site_deployment_repo.get_by_id(site_deployment_id)
if site_deployment:
hostname = site_deployment.custom_hostname
if hostname in config.templates.mappings:
return config.templates.mappings[hostname]
template_name = self._select_random_template()
self._persist_template_mapping(hostname, template_name)
return template_name
return self._select_random_template()
def _select_random_template(self) -> str:
"""
Randomly select a template from available templates
Returns:
Template name
"""
available = self.get_available_templates()
if not available:
return "basic"
return random.choice(available)
def _persist_template_mapping(self, hostname: str, template_name: str) -> None:
"""
Save template mapping to master.config.json
Args:
hostname: Custom hostname to map
template_name: Template to assign
"""
config_path = Path("master.config.json")
try:
with open(config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
if "templates" not in config_data:
config_data["templates"] = {"default": "basic", "mappings": {}}
if "mappings" not in config_data["templates"]:
config_data["templates"]["mappings"] = {}
config_data["templates"]["mappings"][hostname] = template_name
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=2)
except Exception as e:
print(f"Warning: Failed to persist template mapping: {e}")
def format_content(
self,
content: str,
title: str,
meta_description: str,
template_name: str
) -> str:
"""
Format content using specified template
Args:
content: Raw HTML content (h2, h3, p tags)
title: Article title
meta_description: Meta description for SEO
template_name: Name of template to use
Returns:
Complete formatted HTML document
Raises:
FileNotFoundError: If template doesn't exist
"""
config = get_config()
try:
template = self.load_template(template_name)
except FileNotFoundError:
fallback_template = config.templates.default
print(f"Warning: Template '{template_name}' not found, using '{fallback_template}'")
template = self.load_template(fallback_template)
formatted_html = template.replace("{{ title }}", self._escape_html(title))
formatted_html = formatted_html.replace("{{ meta_description }}", self._escape_html(meta_description))
formatted_html = formatted_html.replace("{{ content }}", content)
return formatted_html
def _escape_html(self, text: str) -> str:
"""
Escape HTML special characters in text
Args:
text: Text to escape
Returns:
Escaped text
"""
replacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
for char, escaped in replacements.items():
text = text.replace(char, escaped)
return text

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background-color: #fff;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #1a1a1a;
border-bottom: 3px solid #007bff;
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.8rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
}
h3 {
font-size: 1.4rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #34495e;
}
p {
margin-bottom: 1rem;
text-align: justify;
}
ul, ol {
margin-bottom: 1rem;
margin-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 15px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.8;
color: #2c2c2c;
background-color: #f9f6f2;
padding: 20px;
}
article {
max-width: 750px;
margin: 0 auto;
background: #fff;
padding: 50px 60px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 1px solid #e0d7c9;
}
h1 {
font-size: 2.8rem;
margin-bottom: 1rem;
color: #1a1a1a;
font-weight: 600;
text-align: center;
letter-spacing: -0.01em;
padding-bottom: 1rem;
border-bottom: 2px solid #8b7355;
}
h2 {
font-size: 2rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #3a3a3a;
font-weight: 600;
}
h3 {
font-size: 1.5rem;
margin-top: 1.75rem;
margin-bottom: 0.75rem;
color: #4a4a4a;
font-weight: 600;
font-style: italic;
}
p {
margin-bottom: 1.25rem;
text-indent: 1.5em;
text-align: justify;
}
p:first-of-type {
font-size: 1.1rem;
}
ul, ol {
margin-bottom: 1.25rem;
margin-left: 3rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #8b7355;
text-decoration: underline;
}
a:hover {
color: #5d4a37;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
article {
padding: 30px 25px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
}
h3 {
font-size: 1.3rem;
}
p {
text-indent: 0;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.75;
color: #000;
background-color: #fff;
padding: 40px 20px;
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2.2rem;
margin-bottom: 2rem;
color: #000;
font-weight: 700;
letter-spacing: -0.03em;
}
h2 {
font-size: 1.6rem;
margin-top: 3rem;
margin-bottom: 1rem;
color: #000;
font-weight: 600;
}
h3 {
font-size: 1.3rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #000;
font-weight: 600;
}
p {
margin-bottom: 1.5rem;
font-size: 1rem;
}
ul, ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #000;
text-decoration: none;
border-bottom: 1px solid #000;
}
a:hover {
border-bottom: 2px solid #000;
}
@media (max-width: 768px) {
body {
padding: 20px 15px;
}
h1 {
font-size: 1.8rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ meta_description }}">
<title>{{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.7;
color: #1e293b;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
min-height: 100vh;
}
article {
background: white;
max-width: 850px;
margin: 0 auto;
padding: 60px;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
font-size: 3rem;
margin-bottom: 1.5rem;
color: #0f172a;
font-weight: 800;
letter-spacing: -0.02em;
}
h2 {
font-size: 2rem;
margin-top: 3rem;
margin-bottom: 1rem;
color: #334155;
font-weight: 700;
position: relative;
padding-left: 20px;
}
h2::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
h3 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #475569;
font-weight: 600;
}
p {
margin-bottom: 1.25rem;
font-size: 1.05rem;
}
ul, ol {
margin-bottom: 1.25rem;
margin-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
a:hover {
color: #764ba2;
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 20px 10px;
}
article {
padding: 30px 20px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
}
h3 {
font-size: 1.3rem;
}
}
</style>
</head>
<body>
<article>
<h1>{{ title }}</h1>
{{ content }}
</article>
</body>
</html>

View File

@ -0,0 +1,278 @@
"""
Integration tests for template service with content generation
"""
import pytest
from unittest.mock import patch
from src.database.models import Project, User, GeneratedContent, SiteDeployment
from src.database.repositories import (
ProjectRepository,
GeneratedContentRepository,
SiteDeploymentRepository
)
from src.templating.service import TemplateService
from src.generation.service import ContentGenerator
from src.generation.ai_client import AIClient, PromptManager
@pytest.fixture
def test_user(db_session):
"""Create a test user"""
user = User(
username="testuser_template",
hashed_password="hashed",
role="User"
)
db_session.add(user)
db_session.commit()
return user
@pytest.fixture
def test_project(db_session, test_user):
"""Create a test project"""
project_data = {
"main_keyword": "template testing",
"word_count": 1000,
"term_frequency": 2,
"h2_total": 5,
"h2_exact": 1,
"h3_total": 8,
"h3_exact": 1,
"entities": ["entity1", "entity2"],
"related_searches": ["search1", "search2"]
}
project_repo = ProjectRepository(db_session)
project = project_repo.create(test_user.id, "Template Test Project", project_data)
return project
@pytest.fixture
def test_site_deployment(db_session):
"""Create a test site deployment"""
site_repo = SiteDeploymentRepository(db_session)
site = site_repo.create(
site_name="Test Site",
custom_hostname="test.example.com",
storage_zone_id=12345,
storage_zone_name="test-storage",
storage_zone_password="test-password",
storage_zone_region="DE",
pull_zone_id=67890,
pull_zone_bcdn_hostname="test.b-cdn.net"
)
return site
@pytest.fixture
def test_generated_content(db_session, test_project):
"""Create test generated content"""
content_repo = GeneratedContentRepository(db_session)
content = content_repo.create(
project_id=test_project.id,
tier="tier1",
keyword="template testing",
title="Test Article About Template Testing",
outline={"outline": [{"h2": "Introduction", "h3": ["Overview", "Benefits"]}]},
content="<h2>Introduction</h2><p>This is test content.</p><h3>Overview</h3><p>More content here.</p>",
word_count=500,
status="generated"
)
return content
@pytest.mark.integration
def test_template_service_with_database(db_session):
"""Test TemplateService instantiation with database session"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
assert template_service is not None
assert template_service.content_repo == content_repo
assert len(template_service.get_available_templates()) >= 4
@pytest.mark.integration
def test_format_content_end_to_end(db_session, test_generated_content):
"""Test formatting content and storing in database"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
formatted = template_service.format_content(
content=test_generated_content.content,
title=test_generated_content.title,
meta_description="Test meta description",
template_name="basic"
)
assert "<!DOCTYPE html>" in formatted
assert test_generated_content.title in formatted
assert "Test meta description" in formatted
assert test_generated_content.content in formatted
test_generated_content.formatted_html = formatted
test_generated_content.template_used = "basic"
content_repo.update(test_generated_content)
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html is not None
assert retrieved.template_used == "basic"
assert "<!DOCTYPE html>" in retrieved.formatted_html
@pytest.mark.integration
def test_template_selection_with_site_deployment(
db_session,
test_generated_content,
test_site_deployment
):
"""Test template selection based on site deployment"""
content_repo = GeneratedContentRepository(db_session)
site_repo = SiteDeploymentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
test_generated_content.site_deployment_id = test_site_deployment.id
content_repo.update(test_generated_content)
template_name = template_service.select_template_for_content(
site_deployment_id=test_site_deployment.id,
site_deployment_repo=site_repo
)
available = template_service.get_available_templates()
assert template_name in available
@pytest.mark.integration
def test_template_selection_without_site_deployment(db_session, test_generated_content):
"""Test random template selection when no site deployment"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
template_name = template_service.select_template_for_content(
site_deployment_id=None,
site_deployment_repo=None
)
available = template_service.get_available_templates()
assert template_name in available
@pytest.mark.integration
def test_content_generator_apply_template(
db_session,
test_project,
test_generated_content
):
"""Test ContentGenerator.apply_template method"""
content_repo = GeneratedContentRepository(db_session)
project_repo = ProjectRepository(db_session)
mock_ai_client = None
mock_prompt_manager = None
generator = ContentGenerator(
ai_client=mock_ai_client,
prompt_manager=mock_prompt_manager,
project_repo=project_repo,
content_repo=content_repo
)
success = generator.apply_template(test_generated_content.id)
assert success is True
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html is not None
assert retrieved.template_used is not None
assert "<!DOCTYPE html>" in retrieved.formatted_html
assert retrieved.title in retrieved.formatted_html
@pytest.mark.integration
def test_multiple_content_different_templates(db_session, test_project):
"""Test that multiple content items can use different templates"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
content_items = []
for i in range(4):
content = content_repo.create(
project_id=test_project.id,
tier="tier1",
keyword=f"keyword {i}",
title=f"Test Article {i}",
outline={"outline": [{"h2": "Section", "h3": ["Sub"]}]},
content=f"<h2>Section {i}</h2><p>Content {i}</p>",
word_count=300,
status="generated"
)
content_items.append(content)
templates = ["basic", "modern", "classic", "minimal"]
for content, template_name in zip(content_items, templates):
formatted = template_service.format_content(
content=content.content,
title=content.title,
meta_description=f"Description {content.id}",
template_name=template_name
)
content.formatted_html = formatted
content.template_used = template_name
content_repo.update(content)
for content, expected_template in zip(content_items, templates):
retrieved = content_repo.get_by_id(content.id)
assert retrieved.template_used == expected_template
assert retrieved.formatted_html is not None
@pytest.mark.integration
def test_apply_template_with_missing_content(db_session, test_project):
"""Test apply_template with non-existent content ID"""
content_repo = GeneratedContentRepository(db_session)
project_repo = ProjectRepository(db_session)
generator = ContentGenerator(
ai_client=None,
prompt_manager=None,
project_repo=project_repo,
content_repo=content_repo
)
success = generator.apply_template(content_id=99999)
assert success is False
@pytest.mark.integration
def test_formatted_html_storage(
db_session,
test_generated_content
):
"""Test that formatted HTML is correctly stored in database"""
content_repo = GeneratedContentRepository(db_session)
template_service = TemplateService(content_repo=content_repo)
formatted = template_service.format_content(
content=test_generated_content.content,
title=test_generated_content.title,
meta_description="Meta description",
template_name="modern"
)
test_generated_content.formatted_html = formatted
test_generated_content.template_used = "modern"
test_generated_content.site_deployment_id = None
updated = content_repo.update(test_generated_content)
retrieved = content_repo.get_by_id(test_generated_content.id)
assert retrieved.formatted_html == formatted
assert retrieved.template_used == "modern"
assert retrieved.site_deployment_id is None

View File

@ -0,0 +1,315 @@
"""
Unit tests for template service
"""
import pytest
import json
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, mock_open
from src.templating.service import TemplateService
from src.database.models import GeneratedContent, SiteDeployment
@pytest.fixture
def mock_content_repo():
return Mock()
@pytest.fixture
def mock_site_deployment_repo():
return Mock()
@pytest.fixture
def template_service(mock_content_repo):
return TemplateService(content_repo=mock_content_repo)
@pytest.fixture
def mock_site_deployment():
deployment = Mock(spec=SiteDeployment)
deployment.id = 1
deployment.custom_hostname = "test.example.com"
deployment.site_name = "Test Site"
return deployment
class TestGetAvailableTemplates:
def test_returns_list_of_template_names(self, template_service):
"""Test that available templates are returned"""
templates = template_service.get_available_templates()
assert isinstance(templates, list)
assert len(templates) >= 4
assert "basic" in templates
assert "modern" in templates
assert "classic" in templates
assert "minimal" in templates
def test_templates_are_sorted(self, template_service):
"""Test that templates are returned in sorted order"""
templates = template_service.get_available_templates()
assert templates == sorted(templates)
class TestLoadTemplate:
def test_load_valid_template(self, template_service):
"""Test loading a valid template"""
template_content = template_service.load_template("basic")
assert isinstance(template_content, str)
assert len(template_content) > 0
assert "<!DOCTYPE html>" in template_content
assert "{{ title }}" in template_content
assert "{{ content }}" in template_content
def test_load_all_templates(self, template_service):
"""Test that all templates can be loaded"""
for template_name in ["basic", "modern", "classic", "minimal"]:
template_content = template_service.load_template(template_name)
assert len(template_content) > 0
def test_template_caching(self, template_service):
"""Test that templates are cached after first load"""
template_service.load_template("basic")
assert "basic" in template_service._template_cache
cached_content = template_service._template_cache["basic"]
loaded_content = template_service.load_template("basic")
assert cached_content == loaded_content
def test_load_nonexistent_template(self, template_service):
"""Test that loading nonexistent template raises error"""
with pytest.raises(FileNotFoundError) as exc_info:
template_service.load_template("nonexistent")
assert "nonexistent" in str(exc_info.value)
assert "not found" in str(exc_info.value).lower()
class TestSelectTemplateForContent:
def test_select_random_when_no_site_deployment(self, template_service):
"""Test random selection when site_deployment_id is None"""
template_name = template_service.select_template_for_content(
site_deployment_id=None,
site_deployment_repo=None
)
available_templates = template_service.get_available_templates()
assert template_name in available_templates
@patch('src.templating.service.get_config')
def test_use_existing_mapping(
self,
mock_get_config,
template_service,
mock_site_deployment,
mock_site_deployment_repo
):
"""Test using existing template mapping from config"""
mock_config = Mock()
mock_config.templates.mappings = {
"test.example.com": "modern"
}
mock_get_config.return_value = mock_config
mock_site_deployment_repo.get_by_id.return_value = mock_site_deployment
template_name = template_service.select_template_for_content(
site_deployment_id=1,
site_deployment_repo=mock_site_deployment_repo
)
assert template_name == "modern"
mock_site_deployment_repo.get_by_id.assert_called_once_with(1)
@patch('src.templating.service.get_config')
@patch('builtins.open', new_callable=mock_open)
@patch('pathlib.Path.exists', return_value=True)
def test_create_new_mapping(
self,
mock_exists,
mock_file,
mock_get_config,
template_service,
mock_site_deployment,
mock_site_deployment_repo
):
"""Test creating new mapping when hostname not in config"""
mock_config = Mock()
mock_config.templates.mappings = {}
mock_get_config.return_value = mock_config
mock_file.return_value.read.return_value = json.dumps({
"templates": {"default": "basic", "mappings": {}}
})
mock_site_deployment_repo.get_by_id.return_value = mock_site_deployment
template_name = template_service.select_template_for_content(
site_deployment_id=1,
site_deployment_repo=mock_site_deployment_repo
)
available_templates = template_service.get_available_templates()
assert template_name in available_templates
class TestFormatContent:
def test_format_with_basic_template(self, template_service):
"""Test formatting content with basic template"""
content = "<h2>Test Section</h2><p>Test paragraph.</p>"
title = "Test Article"
meta = "Test meta description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "<!DOCTYPE html>" in formatted
assert "Test Article" in formatted
assert "Test meta description" in formatted
assert "<h2>Test Section</h2>" in formatted
assert "<p>Test paragraph.</p>" in formatted
def test_format_with_all_templates(self, template_service):
"""Test formatting with all available templates"""
content = "<h2>Section</h2><p>Content.</p>"
title = "Title"
meta = "Description"
for template_name in ["basic", "modern", "classic", "minimal"]:
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name=template_name
)
assert len(formatted) > 0
assert "Title" in formatted
assert "Description" in formatted
assert content in formatted
def test_html_escaping_in_title(self, template_service):
"""Test that HTML is escaped in title"""
content = "<p>Content</p>"
title = "Title with <script>alert('xss')</script>"
meta = "Description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "&lt;script&gt;" in formatted
assert "<script>" not in formatted or "<script>" in content
def test_html_escaping_in_meta(self, template_service):
"""Test that HTML is escaped in meta description"""
content = "<p>Content</p>"
title = "Title"
meta = "Description with <tag>"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="basic"
)
assert "&lt;tag&gt;" in formatted
@patch('src.templating.service.get_config')
def test_fallback_to_default_template(
self,
mock_get_config,
template_service
):
"""Test fallback to default template when template not found"""
mock_config = Mock()
mock_config.templates.default = "basic"
mock_get_config.return_value = mock_config
content = "<p>Content</p>"
title = "Title"
meta = "Description"
formatted = template_service.format_content(
content=content,
title=title,
meta_description=meta,
template_name="nonexistent"
)
assert len(formatted) > 0
assert "Title" in formatted
class TestEscapeHtml:
def test_escape_special_characters(self, template_service):
"""Test HTML special character escaping"""
text = "Test & <script> \"quotes\" 'single' >"
escaped = template_service._escape_html(text)
assert "&amp;" in escaped
assert "&lt;" in escaped
assert "&gt;" in escaped
assert "&quot;" in escaped
assert "&#39;" in escaped
assert "<script>" not in escaped
def test_escape_empty_string(self, template_service):
"""Test escaping empty string"""
escaped = template_service._escape_html("")
assert escaped == ""
def test_escape_normal_text(self, template_service):
"""Test that normal text is unchanged"""
text = "Normal text without special chars"
escaped = template_service._escape_html(text)
assert escaped == text
class TestPersistTemplateMapping:
@patch('builtins.open', new_callable=mock_open)
@patch('pathlib.Path.exists', return_value=True)
def test_persist_new_mapping(
self,
mock_exists,
mock_file,
template_service
):
"""Test persisting a new template mapping"""
config_data = {
"templates": {
"default": "basic",
"mappings": {}
}
}
mock_file.return_value.read.return_value = json.dumps(config_data)
template_service._persist_template_mapping("new.example.com", "modern")
assert mock_file.call_count >= 2
@patch('builtins.open', side_effect=Exception("File error"))
@patch('pathlib.Path.exists', return_value=True)
def test_persist_handles_errors_gracefully(
self,
mock_exists,
mock_file,
template_service
):
"""Test that persist handles errors without raising"""
template_service._persist_template_mapping("test.com", "basic")