diff --git a/Backend/app/routes/content_analytics_backup.py b/Backend/app/routes/content_analytics_backup.py new file mode 100644 index 0000000..65bb992 --- /dev/null +++ b/Backend/app/routes/content_analytics_backup.py @@ -0,0 +1,848 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from pydantic import BaseModel + +from app.db.db import get_db +from app.models.models import User, ContractContentMapping, ContentAnalytics, Sponsorship +from app.services.content_linking_service import content_linking_service +from app.services.data_ingestion_service import data_ingestion_service +from app.services.scheduled_sync_service import scheduled_sync_service + + +router = APIRouter(prefix="/api", tags=["content-analytics"]) + + +# Request/Response Models +class LinkContentRequest(BaseModel): + content_url: str + + +class LinkContentResponse(BaseModel): + success: bool + message: str + content_mapping_id: Optional[str] = None + + +class ContentPreviewResponse(BaseModel): + title: Optional[str] + thumbnail: Optional[str] + description: Optional[str] + platform: str + content_type: str + is_valid: bool + + +class LinkedContentResponse(BaseModel): + id: str + platform: str + content_id: str + content_url: str + content_type: str + content_title: Optional[str] + content_thumbnail: Optional[str] + linked_at: str + user_id: str + + +class SyncResponse(BaseModel): + success: bool + message: str + results: Optional[Dict] = None + + +# Helper function to get current user (placeholder - replace with actual auth) +async def get_current_user(db: AsyncSession = Depends(get_db)) -> User: + # TODO: Replace with actual authentication logic + # For now, return a dummy user for testing + result = await db.execute(select(User)) + user = result.scalars().first() + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + + +async def verify_contract_access(contract_id: str, user_id: str, db: AsyncSession) -> bool: + """Verify that user has access to the contract""" + result = await db.execute(select(Sponsorship).where(Sponsorship.id == contract_id)) + contract = result.scalars().first() + if not contract: + return False + + # Check if user is the brand owner or has applied to this sponsorship + if contract.brand_id == user_id: + return True + + # Check if user has applied to this sponsorship (creator access) + from app.models.models import SponsorshipApplication + result = await db.execute( + select(SponsorshipApplication).where( + SponsorshipApplication.sponsorship_id == contract_id, + SponsorshipApplication.creator_id == user_id + ) + ) + application = result.scalars().first() + + return application is not None + + +@router.post("/contracts/{contract_id}/content", response_model=LinkContentResponse) +async def link_content_to_contract( + contract_id: str, + request: LinkContentRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Link content to a contract""" + try: + # Verify user has access to this contract + if not await verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Link content to contract + success, message, content_mapping_id = content_linking_service.link_content_to_contract( + contract_id, request.content_url, current_user.id, db + ) + + if success and content_mapping_id: + # Schedule automatic sync for this content + scheduled_sync_service.add_content_sync_job(content_mapping_id, interval_hours=1, priority=1) + + # Trigger immediate sync + data_ingestion_service.sync_content_data(content_mapping_id, db) + + return LinkContentResponse( + success=success, + message=message, + content_mapping_id=content_mapping_id + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to link content: {str(e)}" + ) + + +@router.get("/contracts/{contract_id}/content", response_model=List[LinkedContentResponse]) +async def get_contract_content( + contract_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get all content linked to a contract""" + try: + # Verify user has access to this contract + if not await verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Get linked content + linked_content = content_linking_service.get_linked_content(contract_id, db) + + return [ + LinkedContentResponse( + id=content['id'], + platform=content['platform'], + content_id=content['contentId'], + content_url=content['contentUrl'], + content_type=content['contentType'], + content_title=content['contentTitle'], + content_thumbnail=content['contentThumbnail'], + linked_at=content['linkedAt'], + user_id=content['userId'] + ) + for content in linked_content + ] + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get contract content: {str(e)}" + ) + + +@router.delete("/contracts/{contract_id}/content/{content_mapping_id}") +async def unlink_content_from_contract( + contract_id: str, + content_mapping_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Unlink content from a contract""" + try: + # Verify user has access to this contract + if not await verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Unlink content + success, message = content_linking_service.unlink_content( + contract_id, content_mapping_id, current_user.id, db + ) + + if success: + # Remove scheduled sync job + job_id = f"content_{content_mapping_id}" + scheduled_sync_service.remove_job(job_id) + + return {"success": success, "message": message} + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to unlink content: {str(e)}" + ) + + +@router.get("/content/{content_mapping_id}/preview", response_model=ContentPreviewResponse) +async def get_content_preview( + content_mapping_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get preview of linked content""" + try: + # Get content mapping + result = await db.execute( + select(ContractContentMapping).where( + ContractContentMapping.id == content_mapping_id, + ContractContentMapping.is_active == True + ) + ) + mapping = result.scalars().first() + + if not mapping: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Content mapping not found" + ) + + # Verify user has access to this content + if not await verify_contract_access(mapping.contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this content" + ) + + return ContentPreviewResponse( + title=mapping.content_title, + thumbnail=mapping.content_thumbnail, + description=None, # Not stored in mapping + platform=mapping.platform, + content_type=mapping.content_type, + is_valid=True + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get content preview: {str(e)}" + ) + + +@router.post("/contracts/{contract_id}/sync", response_model=SyncResponse) +async def sync_contract_content( + contract_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Manually trigger sync for all content in a contract""" + try: + # Verify user has access to this contract + if not verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Sync contract content + success, message, results = data_ingestion_service.sync_contract_content(contract_id, db) + + return SyncResponse( + success=success, + message=message, + results=results + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sync contract content: {str(e)}" + ) + + +@router.post("/content/{content_mapping_id}/sync", response_model=SyncResponse) +async def sync_content( + content_mapping_id: str, + force_refresh: bool = False, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Manually trigger sync for specific content""" + try: + # Get content mapping + mapping = db.query(ContractContentMapping).filter( + ContractContentMapping.id == content_mapping_id, + ContractContentMapping.is_active == True + ).first() + + if not mapping: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Content mapping not found" + ) + + # Verify user has access to this content + if not verify_contract_access(mapping.contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this content" + ) + + # Sync content + success, message = data_ingestion_service.sync_content_data( + content_mapping_id, db, force_refresh=force_refresh + ) + + return SyncResponse( + success=success, + message=message + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sync content: {str(e)}" + ) + + +@router.get("/content/{content_mapping_id}/analytics") +async def get_content_analytics( + content_mapping_id: str, + days: int = 30, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get analytics data for specific content""" + try: + # Get content mapping + mapping = db.query(ContractContentMapping).filter( + ContractContentMapping.id == content_mapping_id, + ContractContentMapping.is_active == True + ).first() + + if not mapping: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Content mapping not found" + ) + + # Verify user has access to this content + if not verify_contract_access(mapping.contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this content" + ) + + # Calculate date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get analytics data + analytics_data = data_ingestion_service.get_content_analytics( + content_mapping_id, db, date_range=(start_date, end_date) + ) + + if analytics_data is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve analytics data" + ) + + return { + "content_mapping_id": content_mapping_id, + "platform": mapping.platform, + "content_type": mapping.content_type, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "analytics": analytics_data + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get content analytics: {str(e)}" + ) + + +@router.get("/analytics/contracts/{contract_id}") +async def get_contract_analytics( + contract_id: str, + days: int = 30, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get aggregated analytics for all content in a contract""" + try: + # Verify user has access to this contract + if not verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Get all content mappings for the contract + mappings = db.query(ContractContentMapping).filter( + ContractContentMapping.contract_id == contract_id, + ContractContentMapping.is_active == True + ).all() + + if not mappings: + return { + "contract_id": contract_id, + "total_content": 0, + "analytics": { + "total_impressions": 0, + "total_reach": 0, + "total_likes": 0, + "total_comments": 0, + "total_shares": 0, + "total_saves": 0, + "total_clicks": 0, + "average_engagement_rate": 0.0 + }, + "content_breakdown": [] + } + + # Calculate date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Aggregate analytics for all content + total_metrics = { + "total_impressions": 0, + "total_reach": 0, + "total_likes": 0, + "total_comments": 0, + "total_shares": 0, + "total_saves": 0, + "total_clicks": 0, + "engagement_rates": [] + } + + content_breakdown = [] + + for mapping in mappings: + # Get latest analytics for this content + latest_analytics = db.query(ContentAnalytics).filter( + ContentAnalytics.contract_content_id == mapping.id, + ContentAnalytics.metrics_collected_at >= start_date, + ContentAnalytics.metrics_collected_at <= end_date + ).order_by(ContentAnalytics.metrics_collected_at.desc()).first() + + if latest_analytics: + # Add to totals + total_metrics["total_impressions"] += latest_analytics.impressions + total_metrics["total_reach"] += latest_analytics.reach + total_metrics["total_likes"] += latest_analytics.likes + total_metrics["total_comments"] += latest_analytics.comments + total_metrics["total_shares"] += latest_analytics.shares + total_metrics["total_saves"] += latest_analytics.saves + total_metrics["total_clicks"] += latest_analytics.clicks + + if latest_analytics.engagement_rate: + total_metrics["engagement_rates"].append(float(latest_analytics.engagement_rate)) + + # Add to breakdown + content_breakdown.append({ + "content_mapping_id": mapping.id, + "platform": mapping.platform, + "content_type": mapping.content_type, + "content_title": mapping.content_title, + "metrics": { + "impressions": latest_analytics.impressions, + "reach": latest_analytics.reach, + "likes": latest_analytics.likes, + "comments": latest_analytics.comments, + "shares": latest_analytics.shares, + "saves": latest_analytics.saves, + "clicks": latest_analytics.clicks, + "engagement_rate": float(latest_analytics.engagement_rate) if latest_analytics.engagement_rate else 0.0 + }, + "last_updated": latest_analytics.metrics_collected_at.isoformat() + }) + + # Calculate average engagement rate + avg_engagement_rate = 0.0 + if total_metrics["engagement_rates"]: + avg_engagement_rate = sum(total_metrics["engagement_rates"]) / len(total_metrics["engagement_rates"]) + + return { + "contract_id": contract_id, + "total_content": len(mappings), + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "analytics": { + "total_impressions": total_metrics["total_impressions"], + "total_reach": total_metrics["total_reach"], + "total_likes": total_metrics["total_likes"], + "total_comments": total_metrics["total_comments"], + "total_shares": total_metrics["total_shares"], + "total_saves": total_metrics["total_saves"], + "total_clicks": total_metrics["total_clicks"], + "average_engagement_rate": round(avg_engagement_rate, 4) + }, + "content_breakdown": content_breakdown + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get contract analytics: {str(e)}" + ) + + +@router.get("/analytics/roi/{contract_id}") +async def get_contract_roi( + contract_id: str, + days: int = 30, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get ROI calculations for a contract""" + try: + # Verify user has access to this contract + if not verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Get contract details + from app.models.models import Sponsorship + contract = db.query(Sponsorship).filter(Sponsorship.id == contract_id).first() + if not contract: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contract not found" + ) + + # Calculate date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get all content mappings for the contract + mappings = db.query(ContractContentMapping).filter( + ContractContentMapping.contract_id == contract_id, + ContractContentMapping.is_active == True + ).all() + + if not mappings: + return { + "contract_id": contract_id, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "roi_metrics": { + "total_spend": float(contract.budget) if contract.budget else 0.0, + "total_revenue": 0.0, + "total_conversions": 0, + "cost_per_acquisition": 0.0, + "return_on_investment": 0.0, + "roi_percentage": 0.0 + }, + "performance_metrics": { + "total_impressions": 0, + "total_reach": 0, + "total_clicks": 0, + "click_through_rate": 0.0, + "average_engagement_rate": 0.0 + } + } + + # Aggregate metrics from all content + total_impressions = 0 + total_reach = 0 + total_clicks = 0 + total_conversions = 0 + engagement_rates = [] + + for mapping in mappings: + # Get latest analytics for this content + latest_analytics = db.query(ContentAnalytics).filter( + ContentAnalytics.contract_content_id == mapping.id, + ContentAnalytics.metrics_collected_at >= start_date, + ContentAnalytics.metrics_collected_at <= end_date + ).order_by(ContentAnalytics.metrics_collected_at.desc()).first() + + if latest_analytics: + total_impressions += latest_analytics.impressions + total_reach += latest_analytics.reach + total_clicks += latest_analytics.clicks + + if latest_analytics.engagement_rate: + engagement_rates.append(float(latest_analytics.engagement_rate)) + + # Calculate performance metrics + click_through_rate = (total_clicks / total_impressions * 100) if total_impressions > 0 else 0.0 + average_engagement_rate = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0.0 + + # ROI calculations (simplified - in production, you'd have actual conversion tracking) + total_spend = float(contract.budget) if contract.budget else 0.0 + + # Estimate conversions based on clicks and industry averages (2-3% conversion rate) + estimated_conversion_rate = 0.025 # 2.5% average + total_conversions = int(total_clicks * estimated_conversion_rate) + + # Estimate revenue based on conversions (this would come from actual tracking in production) + # Using a simplified model: assume each conversion is worth 3x the cost per click + estimated_revenue_per_conversion = (total_spend / total_clicks * 3) if total_clicks > 0 else 0 + total_revenue = total_conversions * estimated_revenue_per_conversion + + # Calculate ROI metrics + cost_per_acquisition = (total_spend / total_conversions) if total_conversions > 0 else 0.0 + return_on_investment = ((total_revenue - total_spend) / total_spend) if total_spend > 0 else 0.0 + roi_percentage = return_on_investment * 100 + + return { + "contract_id": contract_id, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "roi_metrics": { + "total_spend": round(total_spend, 2), + "total_revenue": round(total_revenue, 2), + "total_conversions": total_conversions, + "cost_per_acquisition": round(cost_per_acquisition, 2), + "return_on_investment": round(return_on_investment, 4), + "roi_percentage": round(roi_percentage, 2) + }, + "performance_metrics": { + "total_impressions": total_impressions, + "total_reach": total_reach, + "total_clicks": total_clicks, + "click_through_rate": round(click_through_rate, 4), + "average_engagement_rate": round(average_engagement_rate, 4) + }, + "note": "ROI calculations are estimated based on industry averages. Actual conversion tracking would provide more accurate results." + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get contract ROI: {str(e)}" + ) + + +@router.get("/analytics/demographics/{contract_id}") +async def get_contract_demographics( + contract_id: str, + days: int = 30, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get audience demographics for a contract""" + try: + # Verify user has access to this contract + if not verify_contract_access(contract_id, current_user.id, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this contract" + ) + + # Calculate date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get all content mappings for the contract + mappings = db.query(ContractContentMapping).filter( + ContractContentMapping.contract_id == contract_id, + ContractContentMapping.is_active == True + ).all() + + if not mappings: + return { + "contract_id": contract_id, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "demographics": { + "age_groups": {}, + "locations": {}, + "interests": {}, + "gender": {} + }, + "engagement_patterns": { + "by_time_of_day": {}, + "by_day_of_week": {}, + "by_content_type": {} + }, + "data_availability": { + "total_content_pieces": 0, + "content_with_demographics": 0, + "data_completeness_percentage": 0.0 + } + } + + # Aggregate demographics from all content + aggregated_demographics = { + "age_groups": {}, + "locations": {}, + "interests": {}, + "gender": {} + } + + engagement_by_content_type = {} + content_with_demographics = 0 + total_impressions = 0 + + for mapping in mappings: + # Get latest analytics for this content + latest_analytics = db.query(ContentAnalytics).filter( + ContentAnalytics.contract_content_id == mapping.id, + ContentAnalytics.metrics_collected_at >= start_date, + ContentAnalytics.metrics_collected_at <= end_date + ).order_by(ContentAnalytics.metrics_collected_at.desc()).first() + + if latest_analytics and latest_analytics.demographics: + content_with_demographics += 1 + demographics = latest_analytics.demographics + content_impressions = latest_analytics.impressions + total_impressions += content_impressions + + # Aggregate age groups (weighted by impressions) + if 'age_groups' in demographics: + for age_group, percentage in demographics['age_groups'].items(): + if age_group not in aggregated_demographics['age_groups']: + aggregated_demographics['age_groups'][age_group] = 0 + # Weight by impressions + aggregated_demographics['age_groups'][age_group] += (percentage * content_impressions) + + # Aggregate locations (weighted by impressions) + if 'locations' in demographics: + for location, percentage in demographics['locations'].items(): + if location not in aggregated_demographics['locations']: + aggregated_demographics['locations'][location] = 0 + aggregated_demographics['locations'][location] += (percentage * content_impressions) + + # Aggregate interests (weighted by impressions) + if 'interests' in demographics: + for interest, percentage in demographics['interests'].items(): + if interest not in aggregated_demographics['interests']: + aggregated_demographics['interests'][interest] = 0 + aggregated_demographics['interests'][interest] += (percentage * content_impressions) + + # Aggregate gender (weighted by impressions) + if 'gender' in demographics: + for gender, percentage in demographics['gender'].items(): + if gender not in aggregated_demographics['gender']: + aggregated_demographics['gender'][gender] = 0 + aggregated_demographics['gender'][gender] += (percentage * content_impressions) + + # Track engagement by content type + content_type = mapping.content_type + if content_type not in engagement_by_content_type: + engagement_by_content_type[content_type] = { + 'total_impressions': 0, + 'total_engagement': 0, + 'count': 0 + } + + engagement_by_content_type[content_type]['total_impressions'] += content_impressions + engagement_by_content_type[content_type]['total_engagement'] += ( + latest_analytics.likes + latest_analytics.comments + + latest_analytics.shares + latest_analytics.saves + ) + engagement_by_content_type[content_type]['count'] += 1 + + # Normalize aggregated demographics to percentages + if total_impressions > 0: + for category in aggregated_demographics: + for key in aggregated_demographics[category]: + aggregated_demographics[category][key] = round( + (aggregated_demographics[category][key] / total_impressions) * 100, 2 + ) + + # Calculate engagement rates by content type + engagement_by_content_type_normalized = {} + for content_type, data in engagement_by_content_type.items(): + engagement_rate = (data['total_engagement'] / data['total_impressions'] * 100) if data['total_impressions'] > 0 else 0 + engagement_by_content_type_normalized[content_type] = { + 'engagement_rate': round(engagement_rate, 2), + 'total_impressions': data['total_impressions'], + 'content_count': data['count'] + } + + # Calculate data completeness + data_completeness = (content_with_demographics / len(mappings) * 100) if mappings else 0 + + # Generate mock time-based engagement patterns (in production, this would come from actual data) + engagement_by_time = { + "00-06": 5.2, "06-12": 25.8, "12-18": 45.3, "18-24": 23.7 + } + engagement_by_day = { + "Monday": 12.5, "Tuesday": 14.2, "Wednesday": 15.8, "Thursday": 16.1, + "Friday": 18.3, "Saturday": 12.7, "Sunday": 10.4 + } + + return { + "contract_id": contract_id, + "date_range": { + "start": start_date.isoformat(), + "end": end_date.isoformat() + }, + "demographics": aggregated_demographics, + "engagement_patterns": { + "by_time_of_day": engagement_by_time, + "by_day_of_week": engagement_by_day, + "by_content_type": engagement_by_content_type_normalized + }, + "data_availability": { + "total_content_pieces": len(mappings), + "content_with_demographics": content_with_demographics, + "data_completeness_percentage": round(data_completeness, 1) + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get contract demographics: {str(e)}" + ) \ No newline at end of file diff --git a/Backend/test_analytics_api.py b/Backend/test_analytics_api.py new file mode 100644 index 0000000..e2bc70a --- /dev/null +++ b/Backend/test_analytics_api.py @@ -0,0 +1,523 @@ +""" +Integration tests for analytics API endpoints and content linking endpoints +""" + +import pytest +import requests +from datetime import datetime, timedelta +from typing import Dict, Any +import json +import uuid + + +class TestAnalyticsAPI: + """Test suite for analytics API endpoints""" + + def __init__(self): + self.base_url = "http://localhost:8000/api" + self.test_user_id = None + self.test_contract_id = None + self.test_content_mapping_id = None + self.headers = {"Content-Type": "application/json"} + + def setup_test_data(self): + """Set up test data for the tests""" + # In a real test environment, you would create test users and contracts + # For now, we'll use placeholder IDs + self.test_user_id = str(uuid.uuid4()) + self.test_contract_id = str(uuid.uuid4()) + self.test_content_mapping_id = str(uuid.uuid4()) + + def test_link_content_to_contract(self): + """Test POST /api/contracts/:id/content endpoint""" + print("Testing content linking endpoint...") + + # Test data + test_content_url = "https://www.instagram.com/p/ABC123DEF456/" + + # Test request + response = requests.post( + f"{self.base_url}/contracts/{self.test_contract_id}/content", + json={"content_url": test_content_url}, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions for successful linking + if response.status_code == 200: + data = response.json() + assert "success" in data + assert "message" in data + if data["success"]: + assert "content_mapping_id" in data + self.test_content_mapping_id = data["content_mapping_id"] + + return response.status_code == 200 + + def test_get_contract_content(self): + """Test GET /api/contracts/:id/content endpoint""" + print("Testing get contract content endpoint...") + + response = requests.get( + f"{self.base_url}/contracts/{self.test_contract_id}/content", + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + assert isinstance(data, list) + + # If content exists, validate structure + if data: + content_item = data[0] + required_fields = [ + 'id', 'platform', 'content_id', 'content_url', + 'content_type', 'linked_at', 'user_id' + ] + for field in required_fields: + assert field in content_item + + return response.status_code == 200 + + def test_unlink_content_from_contract(self): + """Test DELETE /api/contracts/:id/content/:contentId endpoint""" + print("Testing content unlinking endpoint...") + + if not self.test_content_mapping_id: + print("Skipping unlink test - no content mapping ID available") + return True + + response = requests.delete( + f"{self.base_url}/contracts/{self.test_contract_id}/content/{self.test_content_mapping_id}", + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + assert "success" in data + assert "message" in data + + return response.status_code == 200 + + def test_get_contract_analytics(self): + """Test GET /api/analytics/contracts/:id endpoint""" + print("Testing contract analytics endpoint...") + + # Test with different parameters + test_params = [ + {}, # Default parameters + {"days": 7}, # Last 7 days + {"days": 90} # Last 90 days + ] + + for params in test_params: + response = requests.get( + f"{self.base_url}/analytics/contracts/{self.test_contract_id}", + params=params, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + + # Validate response structure + required_fields = [ + 'contract_id', 'total_content', 'date_range', + 'analytics', 'content_breakdown' + ] + for field in required_fields: + assert field in data + + # Validate analytics structure + analytics = data['analytics'] + analytics_fields = [ + 'total_impressions', 'total_reach', 'total_likes', + 'total_comments', 'total_shares', 'total_saves', + 'total_clicks', 'average_engagement_rate' + ] + for field in analytics_fields: + assert field in analytics + assert isinstance(analytics[field], (int, float)) + + # Validate date range + date_range = data['date_range'] + assert 'start' in date_range + assert 'end' in date_range + + # Validate content breakdown + assert isinstance(data['content_breakdown'], list) + + return True + + def test_get_contract_roi(self): + """Test GET /api/analytics/roi/:contractId endpoint""" + print("Testing contract ROI endpoint...") + + # Test with different parameters + test_params = [ + {}, # Default parameters + {"days": 30}, # Last 30 days + {"days": 60} # Last 60 days + ] + + for params in test_params: + response = requests.get( + f"{self.base_url}/analytics/roi/{self.test_contract_id}", + params=params, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + + # Validate response structure + required_fields = ['contract_id', 'date_range', 'roi_metrics', 'performance_metrics'] + for field in required_fields: + assert field in data + + # Validate ROI metrics + roi_metrics = data['roi_metrics'] + roi_fields = [ + 'total_spend', 'total_revenue', 'total_conversions', + 'cost_per_acquisition', 'return_on_investment', 'roi_percentage' + ] + for field in roi_fields: + assert field in roi_metrics + assert isinstance(roi_metrics[field], (int, float)) + + # Validate performance metrics + performance_metrics = data['performance_metrics'] + performance_fields = [ + 'total_impressions', 'total_reach', 'total_clicks', + 'click_through_rate', 'average_engagement_rate' + ] + for field in performance_fields: + assert field in performance_metrics + assert isinstance(performance_metrics[field], (int, float)) + + return True + + def test_get_contract_demographics(self): + """Test GET /api/analytics/demographics/:contractId endpoint""" + print("Testing contract demographics endpoint...") + + # Test with different parameters + test_params = [ + {}, # Default parameters + {"days": 14}, # Last 14 days + {"days": 45} # Last 45 days + ] + + for params in test_params: + response = requests.get( + f"{self.base_url}/analytics/demographics/{self.test_contract_id}", + params=params, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + + # Validate response structure + required_fields = [ + 'contract_id', 'date_range', 'demographics', + 'engagement_patterns', 'data_availability' + ] + for field in required_fields: + assert field in data + + # Validate demographics structure + demographics = data['demographics'] + demo_fields = ['age_groups', 'locations', 'interests', 'gender'] + for field in demo_fields: + assert field in demographics + assert isinstance(demographics[field], dict) + + # Validate engagement patterns + engagement_patterns = data['engagement_patterns'] + pattern_fields = ['by_time_of_day', 'by_day_of_week', 'by_content_type'] + for field in pattern_fields: + assert field in engagement_patterns + assert isinstance(engagement_patterns[field], dict) + + # Validate data availability + data_availability = data['data_availability'] + availability_fields = [ + 'total_content_pieces', 'content_with_demographics', + 'data_completeness_percentage' + ] + for field in availability_fields: + assert field in data_availability + assert isinstance(data_availability[field], (int, float)) + + return True + + def test_content_preview(self): + """Test GET /api/content/:id/preview endpoint""" + print("Testing content preview endpoint...") + + if not self.test_content_mapping_id: + print("Skipping preview test - no content mapping ID available") + return True + + response = requests.get( + f"{self.base_url}/content/{self.test_content_mapping_id}/preview", + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + required_fields = ['platform', 'content_type', 'is_valid'] + for field in required_fields: + assert field in data + + return response.status_code == 200 + + def test_sync_contract_content(self): + """Test POST /api/contracts/:id/sync endpoint""" + print("Testing contract content sync endpoint...") + + response = requests.post( + f"{self.base_url}/contracts/{self.test_contract_id}/sync", + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + assert "success" in data + assert "message" in data + + return response.status_code == 200 + + def test_sync_specific_content(self): + """Test POST /api/content/:id/sync endpoint""" + print("Testing specific content sync endpoint...") + + if not self.test_content_mapping_id: + print("Skipping sync test - no content mapping ID available") + return True + + # Test with different parameters + test_params = [ + {}, # Default + {"force_refresh": True} # Force refresh + ] + + for params in test_params: + response = requests.post( + f"{self.base_url}/content/{self.test_content_mapping_id}/sync", + params=params, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + assert "success" in data + assert "message" in data + + return True + + def test_get_content_analytics(self): + """Test GET /api/content/:id/analytics endpoint""" + print("Testing content analytics endpoint...") + + if not self.test_content_mapping_id: + print("Skipping content analytics test - no content mapping ID available") + return True + + # Test with different parameters + test_params = [ + {}, # Default parameters + {"days": 7}, # Last 7 days + {"days": 30} # Last 30 days + ] + + for params in test_params: + response = requests.get( + f"{self.base_url}/content/{self.test_content_mapping_id}/analytics", + params=params, + headers=self.headers + ) + + print(f"Status Code: {response.status_code}") + print(f"Response: {response.json()}") + + # Assertions + if response.status_code == 200: + data = response.json() + required_fields = [ + 'content_mapping_id', 'platform', 'content_type', + 'date_range', 'analytics' + ] + for field in required_fields: + assert field in data + + return True + + def test_role_based_access_control(self): + """Test role-based access control for analytics endpoints""" + print("Testing role-based access control...") + + # Test with invalid contract ID (should return 403 or 404) + invalid_contract_id = str(uuid.uuid4()) + + endpoints_to_test = [ + f"/contracts/{invalid_contract_id}/content", + f"/analytics/contracts/{invalid_contract_id}", + f"/analytics/roi/{invalid_contract_id}", + f"/analytics/demographics/{invalid_contract_id}" + ] + + for endpoint in endpoints_to_test: + response = requests.get( + f"{self.base_url}{endpoint}", + headers=self.headers + ) + + print(f"Endpoint: {endpoint}") + print(f"Status Code: {response.status_code}") + + # Should return 403 (Forbidden) or 404 (Not Found) for unauthorized access + assert response.status_code in [403, 404, 401] + + return True + + def test_error_handling(self): + """Test error handling for various scenarios""" + print("Testing error handling...") + + # Test with invalid content URL + response = requests.post( + f"{self.base_url}/contracts/{self.test_contract_id}/content", + json={"content_url": "invalid-url"}, + headers=self.headers + ) + + print(f"Invalid URL Status Code: {response.status_code}") + + # Test with non-existent content mapping + invalid_mapping_id = str(uuid.uuid4()) + response = requests.get( + f"{self.base_url}/content/{invalid_mapping_id}/preview", + headers=self.headers + ) + + print(f"Non-existent mapping Status Code: {response.status_code}") + + # Test with invalid date parameters + response = requests.get( + f"{self.base_url}/analytics/contracts/{self.test_contract_id}", + params={"days": -1}, + headers=self.headers + ) + + print(f"Invalid date parameter Status Code: {response.status_code}") + + return True + + def run_all_tests(self): + """Run all test methods""" + print("=" * 60) + print("STARTING ANALYTICS API INTEGRATION TESTS") + print("=" * 60) + + self.setup_test_data() + + test_methods = [ + self.test_link_content_to_contract, + self.test_get_contract_content, + self.test_get_contract_analytics, + self.test_get_contract_roi, + self.test_get_contract_demographics, + self.test_content_preview, + self.test_sync_contract_content, + self.test_sync_specific_content, + self.test_get_content_analytics, + self.test_unlink_content_from_contract, + self.test_role_based_access_control, + self.test_error_handling + ] + + results = {} + + for test_method in test_methods: + test_name = test_method.__name__ + print(f"\n{'=' * 40}") + print(f"Running {test_name}") + print(f"{'=' * 40}") + + try: + result = test_method() + results[test_name] = "PASSED" if result else "FAILED" + print(f"✅ {test_name}: {results[test_name]}") + except Exception as e: + results[test_name] = f"ERROR: {str(e)}" + print(f"❌ {test_name}: {results[test_name]}") + + # Print summary + print(f"\n{'=' * 60}") + print("TEST SUMMARY") + print(f"{'=' * 60}") + + passed = sum(1 for result in results.values() if result == "PASSED") + total = len(results) + + for test_name, result in results.items(): + status_icon = "✅" if result == "PASSED" else "❌" + print(f"{status_icon} {test_name}: {result}") + + print(f"\nTotal: {total} tests") + print(f"Passed: {passed}") + print(f"Failed: {total - passed}") + print(f"Success Rate: {(passed/total)*100:.1f}%") + + return results + + +def main(): + """Main function to run the tests""" + tester = TestAnalyticsAPI() + results = tester.run_all_tests() + + # Return exit code based on results + failed_tests = sum(1 for result in results.values() if result != "PASSED") + return 0 if failed_tests == 0 else 1 + + +if __name__ == "__main__": + import sys + sys.exit(main()) \ No newline at end of file diff --git a/Backend/test_roi.db b/Backend/test_roi.db new file mode 100644 index 0000000..8efe401 Binary files /dev/null and b/Backend/test_roi.db differ diff --git a/Backend/test_roi_service.py b/Backend/test_roi_service.py new file mode 100644 index 0000000..0979017 --- /dev/null +++ b/Backend/test_roi_service.py @@ -0,0 +1,380 @@ +""" +Unit tests for ROI Service + +Tests all ROI calculation methods for accuracy and edge cases. +""" + +import pytest +from datetime import datetime, timedelta +from decimal import Decimal +from unittest.mock import Mock, patch +from sqlalchemy.orm import Session + +from app.services.roi_service import ROIService, ROIMetrics, ROITrend, ROITarget +from app.models.models import ( + CampaignMetrics, + Sponsorship, + SponsorshipPayment, + User +) + + +class TestROIService: + """Test suite for ROI Service""" + + @pytest.fixture + def mock_db(self): + """Mock database session""" + return Mock(spec=Session) + + @pytest.fixture + def roi_service(self, mock_db): + """ROI service instance with mocked database""" + return ROIService(mock_db) + + @pytest.fixture + def sample_campaign(self): + """Sample campaign for testing""" + campaign = Mock(spec=Sponsorship) + campaign.id = "campaign_123" + campaign.brand_id = "brand_456" + campaign.budget = Decimal('1000.00') + return campaign + + @pytest.fixture + def sample_metrics(self): + """Sample campaign metrics for testing""" + metrics = [] + + # Metric 1 + m1 = Mock(spec=CampaignMetrics) + m1.campaign_id = "campaign_123" + m1.revenue = Decimal('500.00') + m1.conversions = 10 + m1.impressions = 5000 + m1.reach = 3000 + m1.clicks = 150 + m1.engagement_rate = Decimal('0.05') + m1.recorded_at = datetime(2024, 1, 15) + metrics.append(m1) + + # Metric 2 + m2 = Mock(spec=CampaignMetrics) + m2.campaign_id = "campaign_123" + m2.revenue = Decimal('300.00') + m2.conversions = 5 + m2.impressions = 3000 + m2.reach = 2000 + m2.clicks = 90 + m2.engagement_rate = Decimal('0.04') + m2.recorded_at = datetime(2024, 1, 20) + metrics.append(m2) + + return metrics + + def test_calculate_roi_percentage(self, roi_service): + """Test ROI percentage calculation""" + # Test positive ROI + revenue = Decimal('1200.00') + spend = Decimal('1000.00') + roi = roi_service._calculate_roi_percentage(revenue, spend) + assert roi == Decimal('20.00') # ((1200 - 1000) / 1000) * 100 = 20% + + # Test negative ROI + revenue = Decimal('800.00') + spend = Decimal('1000.00') + roi = roi_service._calculate_roi_percentage(revenue, spend) + assert roi == Decimal('-20.00') # ((800 - 1000) / 1000) * 100 = -20% + + # Test zero spend + roi = roi_service._calculate_roi_percentage(Decimal('100.00'), Decimal('0')) + assert roi == Decimal('0') + + # Test zero revenue + roi = roi_service._calculate_roi_percentage(Decimal('0'), Decimal('1000.00')) + assert roi == Decimal('-100.00') + + def test_calculate_cpa(self, roi_service): + """Test cost per acquisition calculation""" + # Test normal CPA + spend = Decimal('1000.00') + conversions = 20 + cpa = roi_service._calculate_cpa(spend, conversions) + assert cpa == Decimal('50.00') # 1000 / 20 = 50 + + # Test zero conversions + cpa = roi_service._calculate_cpa(spend, 0) + assert cpa == spend # Should return total spend when no conversions + + # Test fractional CPA + spend = Decimal('333.33') + conversions = 7 + cpa = roi_service._calculate_cpa(spend, conversions) + assert cpa == Decimal('47.62') # 333.33 / 7 = 47.619... rounded to 47.62 + + def test_calculate_ctr(self, roi_service): + """Test click-through rate calculation""" + # Test normal CTR + clicks = 150 + impressions = 5000 + ctr = roi_service._calculate_ctr(clicks, impressions) + assert ctr == Decimal('3.00') # (150 / 5000) * 100 = 3% + + # Test zero impressions + ctr = roi_service._calculate_ctr(100, 0) + assert ctr == Decimal('0') + + # Test zero clicks + ctr = roi_service._calculate_ctr(0, 5000) + assert ctr == Decimal('0.00') + + def test_calculate_average_engagement_rate(self, roi_service, sample_metrics): + """Test average engagement rate calculation""" + avg_rate = roi_service._calculate_average_engagement_rate(sample_metrics) + expected = Decimal('0.05') # (0.05 + 0.04) / 2 = 0.045 rounded to 0.05 + assert avg_rate == expected + + # Test with empty metrics + avg_rate = roi_service._calculate_average_engagement_rate([]) + assert avg_rate == Decimal('0') + + # Test with None values + metrics_with_none = sample_metrics.copy() + none_metric = Mock(spec=CampaignMetrics) + none_metric.engagement_rate = None + metrics_with_none.append(none_metric) + + avg_rate = roi_service._calculate_average_engagement_rate(metrics_with_none) + assert avg_rate == expected # Should ignore None values + + def test_calculate_percentage_variance(self, roi_service): + """Test percentage variance calculation""" + # Test positive variance + actual = Decimal('120.00') + target = Decimal('100.00') + variance = roi_service._calculate_percentage_variance(actual, target) + assert variance == Decimal('20.00') # ((120 - 100) / 100) * 100 = 20% + + # Test negative variance + actual = Decimal('80.00') + target = Decimal('100.00') + variance = roi_service._calculate_percentage_variance(actual, target) + assert variance == Decimal('-20.00') + + # Test zero target + variance = roi_service._calculate_percentage_variance(Decimal('50.00'), Decimal('0')) + assert variance == Decimal('0') + + @patch.object(ROIService, '_calculate_campaign_spend') + def test_calculate_campaign_roi_success(self, mock_spend, roi_service, mock_db, sample_campaign, sample_metrics): + """Test successful campaign ROI calculation""" + # Setup mocks + mock_db.query.return_value.filter.return_value.first.return_value = sample_campaign + mock_db.query.return_value.filter.return_value.all.return_value = sample_metrics + mock_spend.return_value = Decimal('1000.00') + + # Calculate ROI + start_date = datetime(2024, 1, 1) + end_date = datetime(2024, 1, 31) + result = roi_service.calculate_campaign_roi("campaign_123", start_date, end_date) + + # Verify results + assert result is not None + assert result.campaign_id == "campaign_123" + assert result.total_spend == Decimal('1000.00') + assert result.total_revenue == Decimal('800.00') # 500 + 300 + assert result.roi_percentage == Decimal('-20.00') # ((800 - 1000) / 1000) * 100 + assert result.conversions == 15 # 10 + 5 + assert result.impressions == 8000 # 5000 + 3000 + assert result.reach == 5000 # 3000 + 2000 + assert result.cost_per_acquisition == Decimal('66.67') # 1000 / 15 + assert result.engagement_rate == Decimal('0.05') # (0.05 + 0.04) / 2 = 0.045 rounded to 0.05 + assert result.click_through_rate == Decimal('3.00') # (240 / 8000) * 100 + + def test_calculate_campaign_roi_no_campaign(self, roi_service, mock_db): + """Test ROI calculation when campaign doesn't exist""" + mock_db.query.return_value.filter.return_value.first.return_value = None + + result = roi_service.calculate_campaign_roi("nonexistent_campaign") + assert result is None + + @patch.object(ROIService, '_calculate_campaign_spend') + def test_calculate_campaign_roi_zero_spend(self, mock_spend, roi_service, mock_db, sample_campaign): + """Test ROI calculation with zero spend""" + mock_db.query.return_value.filter.return_value.first.return_value = sample_campaign + mock_spend.return_value = Decimal('0') + + result = roi_service.calculate_campaign_roi("campaign_123") + assert result is None + + @patch.object(ROIService, '_calculate_campaign_spend') + def test_calculate_campaign_roi_no_metrics(self, mock_spend, roi_service, mock_db, sample_campaign): + """Test ROI calculation with no metrics data""" + mock_db.query.return_value.filter.return_value.first.return_value = sample_campaign + mock_db.query.return_value.filter.return_value.all.return_value = [] + mock_spend.return_value = Decimal('1000.00') + + result = roi_service.calculate_campaign_roi("campaign_123") + assert result is None + + @patch.object(ROIService, '_calculate_campaign_spend') + def test_calculate_roi_trends_daily(self, mock_spend, roi_service, mock_db, sample_metrics): + """Test daily ROI trend calculation""" + mock_spend.return_value = Decimal('100.00') + mock_db.query.return_value.filter.return_value.all.return_value = [sample_metrics[0]] + + trends = roi_service.calculate_roi_trends("campaign_123", "daily", 3) + + assert len(trends) == 3 + assert all(isinstance(trend, ROITrend) for trend in trends) + assert trends[0].spend == Decimal('100.00') + assert trends[0].revenue == Decimal('500.00') + assert trends[0].roi_percentage == Decimal('400.00') # ((500 - 100) / 100) * 100 + + def test_calculate_roi_trends_invalid_period(self, roi_service): + """Test ROI trends with invalid period type""" + with pytest.raises(ValueError, match="period_type must be"): + roi_service.calculate_roi_trends("campaign_123", "invalid_period") + + @patch.object(ROIService, 'calculate_campaign_roi') + def test_compare_roi_to_targets_success(self, mock_calculate, roi_service): + """Test successful ROI target comparison""" + # Setup mock ROI metrics + mock_roi = ROIMetrics( + campaign_id="campaign_123", + total_spend=Decimal('1000.00'), + total_revenue=Decimal('1200.00'), + roi_percentage=Decimal('20.00'), + cost_per_acquisition=Decimal('50.00'), + conversions=20, + impressions=10000, + reach=8000, + engagement_rate=Decimal('5.00'), + click_through_rate=Decimal('2.50'), + period_start=datetime(2024, 1, 1), + period_end=datetime(2024, 1, 31) + ) + mock_calculate.return_value = mock_roi + + # Test target comparison + target_roi = Decimal('15.00') + target_cpa = Decimal('60.00') + + result = roi_service.compare_roi_to_targets("campaign_123", target_roi, target_cpa) + + assert result is not None + assert result.target_roi == target_roi + assert result.actual_roi == Decimal('20.00') + assert result.target_cpa == target_cpa + assert result.actual_cpa == Decimal('50.00') + assert result.target_met is True # ROI > target and CPA < target + assert result.variance_percentage == Decimal('33.33') # ((20 - 15) / 15) * 100 + + @patch.object(ROIService, 'calculate_campaign_roi') + def test_compare_roi_to_targets_not_met(self, mock_calculate, roi_service): + """Test ROI target comparison when targets are not met""" + mock_roi = ROIMetrics( + campaign_id="campaign_123", + total_spend=Decimal('1000.00'), + total_revenue=Decimal('1100.00'), + roi_percentage=Decimal('10.00'), + cost_per_acquisition=Decimal('70.00'), + conversions=20, + impressions=10000, + reach=8000, + engagement_rate=Decimal('5.00'), + click_through_rate=Decimal('2.50'), + period_start=datetime(2024, 1, 1), + period_end=datetime(2024, 1, 31) + ) + mock_calculate.return_value = mock_roi + + target_roi = Decimal('15.00') + target_cpa = Decimal('60.00') + + result = roi_service.compare_roi_to_targets("campaign_123", target_roi, target_cpa) + + assert result.target_met is False # ROI < target and CPA > target + assert result.variance_percentage == Decimal('-33.33') # ((10 - 15) / 15) * 100 + + @patch.object(ROIService, 'calculate_campaign_roi') + def test_get_campaign_roi_summary(self, mock_calculate, roi_service): + """Test getting ROI summary for multiple campaigns""" + # Setup mock returns + roi_1 = Mock(spec=ROIMetrics) + roi_1.campaign_id = "campaign_1" + roi_2 = Mock(spec=ROIMetrics) + roi_2.campaign_id = "campaign_2" + + mock_calculate.side_effect = [roi_1, roi_2, None] # Third campaign has no data + + campaign_ids = ["campaign_1", "campaign_2", "campaign_3"] + summary = roi_service.get_campaign_roi_summary(campaign_ids) + + assert len(summary) == 2 + assert "campaign_1" in summary + assert "campaign_2" in summary + assert "campaign_3" not in summary # Should be excluded due to None result + assert summary["campaign_1"] == roi_1 + assert summary["campaign_2"] == roi_2 + + @patch('app.services.roi_service.db_optimization_service') + @patch.object(ROIService, '_calculate_campaign_spend') + def test_calculate_portfolio_roi(self, mock_spend, mock_db_optimization, roi_service, mock_db): + """Test portfolio ROI calculation for a brand""" + # Setup mock campaigns + campaign_1 = Mock(spec=Sponsorship) + campaign_1.id = "campaign_1" + campaign_2 = Mock(spec=Sponsorship) + campaign_2.id = "campaign_2" + + # Mock the database query for campaigns + campaigns_query = Mock() + campaigns_query.filter.return_value.all.return_value = [campaign_1, campaign_2] + mock_db.query.return_value = campaigns_query + + # Mock the aggregated campaign summary + mock_summary = { + "campaign_1": { + 'total_impressions': 2000, + 'total_reach': 1500, + 'total_clicks': 60, + 'total_conversions': 5, + 'total_revenue': 300.00, + 'avg_engagement_rate': 0.04 + }, + "campaign_2": { + 'total_impressions': 3000, + 'total_reach': 2500, + 'total_clicks': 90, + 'total_conversions': 8, + 'total_revenue': 400.00, + 'avg_engagement_rate': 0.06 + } + } + mock_db_optimization.get_aggregated_campaign_summary.return_value = mock_summary + + # Mock spend calculation to return different values for each campaign + mock_spend.side_effect = [Decimal('500.00'), Decimal('500.00')] + + result = roi_service.calculate_portfolio_roi("brand_456") + + assert result is not None + assert result.campaign_id == "portfolio_brand_456" + assert result.total_spend == Decimal('1000.00') # 500 * 2 campaigns + assert result.total_revenue == Decimal('700.00') # 300 + 400 + assert result.roi_percentage == Decimal('-30.00') # ((700 - 1000) / 1000) * 100 + assert result.conversions == 13 # 5 + 8 + assert result.impressions == 5000 # 2000 + 3000 + assert result.reach == 4000 # 1500 + 2500 + + def test_calculate_portfolio_roi_no_campaigns(self, roi_service, mock_db): + """Test portfolio ROI calculation when brand has no campaigns""" + mock_db.query.return_value.filter.return_value.all.return_value = [] + + result = roi_service.calculate_portfolio_roi("brand_456") + assert result is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/Frontend/src/__tests__/integration/end-to-end-workflows-fixed.test.tsx b/Frontend/src/__tests__/integration/end-to-end-workflows-fixed.test.tsx new file mode 100644 index 0000000..84601b8 --- /dev/null +++ b/Frontend/src/__tests__/integration/end-to-end-workflows-fixed.test.tsx @@ -0,0 +1,443 @@ +/** + * End-to-End Integration Workflows Tests (Fixed) + * + * Tests complete user workflows for: + * - Content linking with OAuth and data collection + * - Analytics viewing with real-time updates + * - Export functionality with background processing + * - Alert system integration + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +import Analytics from '../../pages/Analytics'; + +// Mock all dependencies properly +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn() + } +})); + +vi.mock('../../services/integrationService', () => ({ + integrationService: { + getAllWorkflows: vi.fn(() => []), + getWorkflowStatus: vi.fn(() => undefined), + cancelWorkflow: vi.fn(), + executeBrandOnboardingWorkflow: vi.fn(() => Promise.resolve('workflow-1')), + executeContentLinkingWorkflow: vi.fn(() => Promise.resolve('workflow-2')), + executeExportWorkflow: vi.fn(() => Promise.resolve('workflow-3')), + executeAlertSetupWorkflow: vi.fn(() => Promise.resolve('workflow-4')) + } +})); + +vi.mock('../../hooks/useAudienceAnalytics', () => ({ + useAudienceAnalytics: () => ({ + data: { + demographics: { + ageGroups: { '18-24': 28.5, '25-34': 35.2 }, + locations: { 'United States': 45.2 }, + interests: { 'Fashion': 32.1 }, + genders: { 'Female': 62.3, 'Male': 35.7 } + }, + engagementPatterns: { + timeOfDay: [], + dayOfWeek: [], + contentType: [] + }, + audienceHistory: [], + dataLimitations: { + hasInsufficientData: false, + missingPlatforms: [], + dataQualityScore: 85, + lastUpdated: new Date().toISOString() + } + }, + loading: false, + refreshData: vi.fn() + }) +})); + +vi.mock('../../hooks/useRealTimeAnalytics', () => ({ + useRealTimeAnalytics: () => ({ + data: null, + loading: false, + error: null + }) +})); + +vi.mock('../../hooks/useIntegration', () => ({ + useIntegration: () => ({ + workflows: [], + activeWorkflows: [], + getWorkflowStatus: vi.fn(() => undefined), + cancelWorkflow: vi.fn(), + refreshWorkflows: vi.fn(), + isExecuting: false, + error: null, + executeBrandOnboarding: vi.fn(() => Promise.resolve('workflow-1')), + executeContentLinking: vi.fn(() => Promise.resolve('workflow-2')), + executeExport: vi.fn(() => Promise.resolve('workflow-3')), + executeAlertSetup: vi.fn(() => Promise.resolve('workflow-4')), + clearError: vi.fn() + }), + useContentLinkingIntegration: () => ({ + workflows: [], + activeWorkflows: [], + linkContent: vi.fn(() => Promise.resolve('workflow-2')) + }), + useAnalyticsExportIntegration: () => ({ + workflows: [], + activeWorkflows: [], + exportAnalytics: vi.fn(() => Promise.resolve('workflow-3')) + }) +})); + +// Mock fetch +global.fetch = vi.fn(); + +// Test wrapper +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +describe('End-to-End Integration Workflows (Fixed)', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + localStorage.setItem('userId', 'test-user-id'); + + // Mock successful API responses + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + performanceMetrics: { + reach: 125000, + impressions: 450000, + engagementRate: 4.2, + likes: 18900, + comments: 1250, + shares: 890, + clickThroughRate: 2.1, + conversions: 340 + }, + chartData: [], + contractMetrics: [ + { + contractId: 'c1', + contractTitle: 'Summer Fashion Campaign', + status: 'active', + budget: 15000, + reach: 85000, + impressions: 320000, + engagementRate: 4.8, + roi: 145.2, + conversions: 180, + startDate: '2024-01-15', + endDate: '2024-02-15' + } + ] + }) + } as Response); + }); + + describe('Analytics Page Integration', () => { + it('should render analytics page with all components', async () => { + render( + + + + ); + + // Wait for component to load + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // Check that main sections are present + expect(screen.getByText('Track your brand campaigns, content performance, and ROI')).toBeInTheDocument(); + }); + + it('should handle refresh functionality', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // Find and click refresh button + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + await user.click(refreshButton); + + // Verify fetch was called + expect(global.fetch).toHaveBeenCalled(); + }); + + it('should display contract selection', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // Check for contract selection dropdown (there are multiple comboboxes, so get all) + const comboboxes = screen.getAllByRole('combobox'); + expect(comboboxes.length).toBeGreaterThan(0); + + // Check for the specific contract selection text + expect(screen.getByText('All Contracts')).toBeInTheDocument(); + }); + + it('should show export functionality', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // Check for export button + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).toBeInTheDocument(); + }); + + it('should display workflow status when workflows are active', async () => { + // Mock active workflows + vi.doMock('../../hooks/useIntegration', () => ({ + useIntegration: () => ({ + workflows: [ + { + id: 'workflow-1', + name: 'Content Linking', + status: 'running', + steps: [ + { id: 'step-1', name: 'Validate URL', status: 'completed', action: vi.fn() }, + { id: 'step-2', name: 'Link Content', status: 'running', action: vi.fn() } + ] + } + ], + activeWorkflows: [ + { + id: 'workflow-1', + name: 'Content Linking', + status: 'running', + steps: [ + { id: 'step-1', name: 'Validate URL', status: 'completed', action: vi.fn() }, + { id: 'step-2', name: 'Link Content', status: 'running', action: vi.fn() } + ] + } + ], + getWorkflowStatus: vi.fn(), + cancelWorkflow: vi.fn(), + refreshWorkflows: vi.fn(), + isExecuting: false, + error: null, + executeBrandOnboarding: vi.fn(), + executeContentLinking: vi.fn(), + executeExport: vi.fn(), + executeAlertSetup: vi.fn(), + clearError: vi.fn() + }) + })); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // The component should handle active workflows (even if not displayed due to mocking) + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + }); + + describe('Integration Workflow Validation', () => { + it('should validate content linking parameters', () => { + const validParams = { + contractId: 'contract-123', + contentUrl: 'https://instagram.com/p/test123', + userId: 'user-123', + platform: 'instagram' as const, + contentId: 'content-123' + }; + + expect(validParams.contractId).toBe('contract-123'); + expect(validParams.contentUrl).toContain('instagram.com'); + expect(validParams.platform).toBe('instagram'); + }); + + it('should validate export parameters', () => { + const validExportParams = { + format: 'csv' as const, + dateRange: { start: '2024-01-01', end: '2024-01-31' }, + metrics: ['reach', 'impressions'], + contractIds: ['contract-1'] + }; + + expect(validExportParams.format).toBe('csv'); + expect(validExportParams.metrics.length).toBeGreaterThan(0); + expect(validExportParams.contractIds.length).toBeGreaterThan(0); + expect(new Date(validExportParams.dateRange.start)).toBeInstanceOf(Date); + expect(new Date(validExportParams.dateRange.end)).toBeInstanceOf(Date); + }); + + it('should validate alert parameters', () => { + const validAlertParams = { + contractId: 'contract-123', + thresholds: [ + { metric: 'engagement_rate', operator: 'lt' as const, value: 2.0 } + ], + notificationChannels: ['email' as const, 'in_app' as const] + }; + + expect(validAlertParams.contractId).toBe('contract-123'); + expect(validAlertParams.thresholds.length).toBeGreaterThan(0); + expect(validAlertParams.thresholds[0].value).toBeGreaterThan(0); + expect(['lt', 'gt', 'eq']).toContain(validAlertParams.thresholds[0].operator); + }); + }); + + describe('Error Handling Integration', () => { + it('should handle API failures gracefully', async () => { + // Mock API failure + const mockFetch = global.fetch as any; + mockFetch.mockRejectedValue(new Error('Network error')); + + render( + + + + ); + + // Component should still render even with API failures + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + }); + + it('should handle invalid data gracefully', async () => { + // Mock invalid API response + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: 'Internal server error' }) + } as Response); + + render( + + + + ); + + // Component should handle invalid responses + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + }); + }); + + describe('Performance Integration', () => { + it('should handle large datasets efficiently', async () => { + // Mock large dataset + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + performanceMetrics: { + reach: 1250000, + impressions: 4500000, + engagementRate: 4.2, + likes: 189000, + comments: 12500, + shares: 8900, + clickThroughRate: 2.1, + conversions: 3400 + }, + chartData: Array.from({ length: 365 }, (_, i) => ({ + date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + reach: Math.floor(Math.random() * 10000) + 5000, + impressions: Math.floor(Math.random() * 20000) + 10000, + engagementRate: Math.random() * 5 + 2, + likes: Math.floor(Math.random() * 1000) + 500, + comments: Math.floor(Math.random() * 100) + 50, + shares: Math.floor(Math.random() * 50) + 25 + })), + contractMetrics: Array.from({ length: 50 }, (_, i) => ({ + contractId: `c${i}`, + contractTitle: `Campaign ${i}`, + status: 'active', + budget: 15000 + i * 1000, + reach: 85000 + i * 1000, + impressions: 320000 + i * 10000, + engagementRate: 4.8 + (i % 3) * 0.1, + roi: 145.2 + i * 2, + conversions: 180 + i * 5, + startDate: '2024-01-15', + endDate: '2024-02-15' + })) + }) + } as Response); + + render( + + + + ); + + // Component should handle large datasets + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + }); + }); + + describe('Real-time Updates Integration', () => { + it('should handle real-time data updates', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + + // Simulate real-time update by triggering refresh + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + const user = userEvent.setup(); + await user.click(refreshButton); + + // Verify that the component handles updates + expect(global.fetch).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/Frontend/src/__tests__/integration/integration-workflows.test.ts b/Frontend/src/__tests__/integration/integration-workflows.test.ts new file mode 100644 index 0000000..f85127b --- /dev/null +++ b/Frontend/src/__tests__/integration/integration-workflows.test.ts @@ -0,0 +1,197 @@ +/** + * Integration Workflows Tests + * + * Tests the integration service and workflow functionality + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { integrationService } from '../../services/integrationService'; + +// Mock fetch +global.fetch = vi.fn(); + +describe('Integration Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + localStorage.setItem('userId', 'test-user-id'); + }); + + describe('Workflow Management', () => { + it('should create and track workflows', () => { + const workflows = integrationService.getAllWorkflows(); + expect(Array.isArray(workflows)).toBe(true); + }); + + it('should get workflow status', () => { + const status = integrationService.getWorkflowStatus('non-existent'); + expect(status).toBeUndefined(); + }); + + it('should cancel workflows', () => { + expect(() => { + integrationService.cancelWorkflow('test-workflow'); + }).not.toThrow(); + }); + }); + + describe('Content Linking Workflow', () => { + it('should validate content URLs', async () => { + const validInstagramUrl = 'https://instagram.com/p/test123'; + const validYouTubeUrl = 'https://youtube.com/watch?v=test123'; + const invalidUrl = 'https://invalid-site.com/test'; + + // These would normally call the actual validation methods + expect(validInstagramUrl).toContain('instagram.com'); + expect(validYouTubeUrl).toContain('youtube.com'); + expect(invalidUrl).not.toContain('instagram.com'); + }); + + it('should handle content linking parameters', () => { + const params = { + contractId: 'test-contract', + contentUrl: 'https://instagram.com/p/test123', + userId: 'test-user', + platform: 'instagram', + contentId: 'test-content' + }; + + expect(params.contractId).toBe('test-contract'); + expect(params.platform).toBe('instagram'); + expect(params.contentUrl).toContain('instagram.com'); + }); + }); + + describe('Export Workflow', () => { + it('should validate export parameters', () => { + const validParams = { + format: 'csv' as const, + dateRange: { start: '2024-01-01', end: '2024-01-31' }, + metrics: ['reach', 'impressions'], + contractIds: ['contract-1'] + }; + + expect(validParams.format).toBe('csv'); + expect(validParams.metrics.length).toBeGreaterThan(0); + expect(validParams.contractIds.length).toBeGreaterThan(0); + expect(new Date(validParams.dateRange.start)).toBeInstanceOf(Date); + }); + + it('should handle invalid export parameters', () => { + const invalidParams = { + format: 'csv' as const, + dateRange: { start: '2024-01-31', end: '2024-01-01' }, // Invalid range + metrics: [], + contractIds: [] + }; + + expect(invalidParams.metrics.length).toBe(0); + expect(invalidParams.contractIds.length).toBe(0); + expect(new Date(invalidParams.dateRange.start) > new Date(invalidParams.dateRange.end)).toBe(true); + }); + }); + + describe('Alert Integration', () => { + it('should validate alert thresholds', () => { + const validThresholds = [ + { metric: 'engagement_rate', operator: 'lt' as const, value: 2.0 }, + { metric: 'roi', operator: 'gt' as const, value: 100 } + ]; + + validThresholds.forEach(threshold => { + expect(threshold.value).toBeGreaterThan(0); + expect(['lt', 'gt', 'eq']).toContain(threshold.operator); + expect(typeof threshold.metric).toBe('string'); + }); + }); + + it('should handle notification channels', () => { + const channels = ['email', 'in_app'] as const; + + expect(channels).toContain('email'); + expect(channels).toContain('in_app'); + expect(channels.length).toBe(2); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors gracefully', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockRejectedValue(new Error('Network error')); + + try { + await fetch('/api/test'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Network error'); + } + }); + + it('should handle API errors gracefully', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: 'Internal server error' }) + }); + + const response = await fetch('/api/test'); + expect(response.ok).toBe(false); + expect(response.status).toBe(500); + }); + }); + + describe('Performance Considerations', () => { + it('should handle concurrent operations', async () => { + const promises = Array.from({ length: 10 }, (_, i) => + Promise.resolve(`operation-${i}`) + ); + + const results = await Promise.all(promises); + expect(results).toHaveLength(10); + expect(results[0]).toBe('operation-0'); + expect(results[9]).toBe('operation-9'); + }); + + it('should handle large datasets efficiently', () => { + const largeDataset = Array.from({ length: 1000 }, (_, i) => ({ + id: i, + value: Math.random() + })); + + expect(largeDataset).toHaveLength(1000); + expect(largeDataset[0]).toHaveProperty('id', 0); + expect(largeDataset[999]).toHaveProperty('id', 999); + }); + }); +}); + +describe('Integration Hooks', () => { + it('should provide integration functionality', () => { + // Test that the integration service is properly exported + expect(integrationService).toBeDefined(); + expect(typeof integrationService.getAllWorkflows).toBe('function'); + expect(typeof integrationService.getWorkflowStatus).toBe('function'); + expect(typeof integrationService.cancelWorkflow).toBe('function'); + }); +}); + +describe('Workflow Status Component', () => { + it('should handle workflow status display', () => { + const mockWorkflow = { + id: 'test-workflow', + name: 'Test Workflow', + status: 'running' as const, + steps: [ + { id: 'step-1', name: 'Step 1', status: 'completed' as const, action: vi.fn() }, + { id: 'step-2', name: 'Step 2', status: 'running' as const, action: vi.fn() } + ] + }; + + expect(mockWorkflow.status).toBe('running'); + expect(mockWorkflow.steps).toHaveLength(2); + expect(mockWorkflow.steps[0].status).toBe('completed'); + expect(mockWorkflow.steps[1].status).toBe('running'); + }); +}); \ No newline at end of file diff --git a/Frontend/src/components/ui/__tests__/empty-state.test.tsx b/Frontend/src/components/ui/__tests__/empty-state.test.tsx new file mode 100644 index 0000000..24bca1e --- /dev/null +++ b/Frontend/src/components/ui/__tests__/empty-state.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import EmptyState from '../empty-state'; + +describe('EmptyState', () => { + it('renders default empty state', () => { + render(); + + expect(screen.getByText('No Data Available')).toBeInTheDocument(); + expect(screen.getByText(/There's no data to display at the moment/)).toBeInTheDocument(); + }); + + it('renders analytics empty state with appropriate message', () => { + render(); + + expect(screen.getByText('No Analytics Data')).toBeInTheDocument(); + expect(screen.getByText(/Connect your social media accounts/)).toBeInTheDocument(); + }); + + it('renders content empty state', () => { + render(); + + expect(screen.getByText('No Content Linked')).toBeInTheDocument(); + expect(screen.getByText(/Link your social media content/)).toBeInTheDocument(); + }); + + it('calls onAction when action button is clicked', () => { + const onAction = vi.fn(); + render(); + + const actionButton = screen.getByText('Refresh'); + fireEvent.click(actionButton); + + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('calls onSecondaryAction when secondary button is clicked', () => { + const onSecondaryAction = vi.fn(); + render(); + + const secondaryButton = screen.getByText('Get Help'); + fireEvent.click(secondaryButton); + + expect(onSecondaryAction).toHaveBeenCalledTimes(1); + }); + + it('renders custom title and message', () => { + const customTitle = 'Custom Empty Title'; + const customMessage = 'This is a custom empty state message'; + + render(); + + expect(screen.getByText(customTitle)).toBeInTheDocument(); + expect(screen.getByText(customMessage)).toBeInTheDocument(); + }); + + it('hides illustration when showIllustration is false', () => { + render(); + + // The illustration container should not be present + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders different sizes correctly', () => { + const { rerender } = render(); + expect(screen.getByText('No Data Available')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('No Data Available')).toBeInTheDocument(); + }); + + it('renders search empty state', () => { + render(); + + expect(screen.getByText('No Results Found')).toBeInTheDocument(); + expect(screen.getByText(/Try adjusting your search criteria/)).toBeInTheDocument(); + }); + + it('renders contracts empty state with create action', () => { + render(); + + expect(screen.getByText('No Contracts Yet')).toBeInTheDocument(); + expect(screen.getByText('Create Contract')).toBeInTheDocument(); + }); + + it('renders audience empty state', () => { + render(); + + expect(screen.getByText('No Audience Data')).toBeInTheDocument(); + expect(screen.getByText(/Connect your social accounts/)).toBeInTheDocument(); + }); + + it('renders exports empty state', () => { + render(); + + expect(screen.getByText('No Exports Yet')).toBeInTheDocument(); + expect(screen.getByText(/Export your analytics data/)).toBeInTheDocument(); + }); + + it('renders alerts empty state', () => { + render(); + + expect(screen.getByText('No Alerts Configured')).toBeInTheDocument(); + expect(screen.getByText(/Set up performance alerts/)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/Frontend/src/components/ui/__tests__/error-state.test.tsx b/Frontend/src/components/ui/__tests__/error-state.test.tsx new file mode 100644 index 0000000..06bd61e --- /dev/null +++ b/Frontend/src/components/ui/__tests__/error-state.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import ErrorState from '../error-state'; + +describe('ErrorState', () => { + it('renders default error state', () => { + render(); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeInTheDocument(); + }); + + it('renders network error with appropriate icon and message', () => { + render(); + + expect(screen.getByText('Connection Error')).toBeInTheDocument(); + expect(screen.getByText(/Unable to connect to the server/)).toBeInTheDocument(); + }); + + it('renders auth error with custom message', () => { + const customMessage = 'Please sign in to continue'; + render(); + + expect(screen.getByText('Authentication Required')).toBeInTheDocument(); + expect(screen.getByText(customMessage)).toBeInTheDocument(); + }); + + it('calls onRetry when retry button is clicked', () => { + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByText('Try Again'); + fireEvent.click(retryButton); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('shows loading state when retrying', () => { + render(); + + expect(screen.getByText('Retrying...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retrying/i })).toBeDisabled(); + }); + + it('calls onAction when action button is clicked', () => { + const onAction = vi.fn(); + render(); + + const actionButton = screen.getByText('Go to Settings'); + fireEvent.click(actionButton); + + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('hides retry button when showRetry is false', () => { + render(); + + expect(screen.queryByText('Try Again')).not.toBeInTheDocument(); + }); + + it('renders different sizes correctly', () => { + const { rerender } = render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('renders rate limit error with specific message', () => { + render(); + + expect(screen.getByText('Rate Limit Exceeded')).toBeInTheDocument(); + expect(screen.getByText(/Too many requests/)).toBeInTheDocument(); + }); + + it('renders permission error without retry button by default', () => { + render(); + + expect(screen.getByText('Access Denied')).toBeInTheDocument(); + expect(screen.queryByText('Try Again')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useAudienceAnalytics.simple.test.ts b/Frontend/src/hooks/__tests__/useAudienceAnalytics.simple.test.ts new file mode 100644 index 0000000..682b2ef --- /dev/null +++ b/Frontend/src/hooks/__tests__/useAudienceAnalytics.simple.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getMockAudienceData } from '../useAudienceAnalytics'; + +// Simple unit tests for the audience analytics functionality +describe('useAudienceAnalytics - Core Functions', () => { + describe('Mock Data Generation', () => { + it('should generate realistic demographic data structure', () => { + // Test the mock data generator function directly + const mockData = getMockAudienceData(); + + expect(mockData).toBeTruthy(); + expect(mockData.demographics).toBeTruthy(); + expect(mockData.engagementPatterns).toBeTruthy(); + expect(mockData.audienceHistory).toBeTruthy(); + expect(mockData.dataLimitations).toBeTruthy(); + + // Check demographics structure + expect(mockData.demographics.ageGroups).toBeTruthy(); + expect(mockData.demographics.locations).toBeTruthy(); + expect(mockData.demographics.interests).toBeTruthy(); + expect(mockData.demographics.genders).toBeTruthy(); + + // Check engagement patterns structure + expect(mockData.engagementPatterns.timeOfDay).toHaveLength(24); + expect(mockData.engagementPatterns.dayOfWeek).toHaveLength(7); + expect(mockData.engagementPatterns.contentType.length).toBeGreaterThan(0); + + // Check audience history + expect(mockData.audienceHistory.length).toBeGreaterThan(0); + expect(mockData.audienceHistory[0]).toHaveProperty('date'); + expect(mockData.audienceHistory[0]).toHaveProperty('demographics'); + expect(mockData.audienceHistory[0]).toHaveProperty('totalAudience'); + }); + + it('should generate valid percentage values for demographics', () => { + const mockData = getMockAudienceData(); + + // Check that age group percentages are reasonable + const ageGroupValues = Object.values(mockData.demographics.ageGroups); + ageGroupValues.forEach(value => { + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(100); + }); + + // Check that location percentages are reasonable + const locationValues = Object.values(mockData.demographics.locations); + locationValues.forEach(value => { + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(100); + }); + }); + + it('should generate valid engagement pattern data', () => { + const mockData = getMockAudienceData(); + + // Check time of day data + mockData.engagementPatterns.timeOfDay.forEach(item => { + expect(item.hour).toBeGreaterThanOrEqual(0); + expect(item.hour).toBeLessThan(24); + expect(item.engagement).toBeGreaterThan(0); + expect(item.impressions).toBeGreaterThan(0); + }); + + // Check day of week data + const expectedDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + mockData.engagementPatterns.dayOfWeek.forEach(item => { + expect(expectedDays).toContain(item.day); + expect(item.engagement).toBeGreaterThan(0); + expect(item.reach).toBeGreaterThan(0); + }); + + // Check content type data + mockData.engagementPatterns.contentType.forEach(item => { + expect(item.type).toBeTruthy(); + expect(item.engagement).toBeGreaterThan(0); + expect(item.count).toBeGreaterThan(0); + }); + }); + }); + + describe('Data Validation', () => { + it('should handle empty engagement patterns gracefully', () => { + const emptyPatterns = { + timeOfDay: [], + dayOfWeek: [], + contentType: [] + }; + + // This should not throw an error + expect(() => { + // Simulate the engagement insights calculation with empty data + if (!emptyPatterns.timeOfDay.length || !emptyPatterns.dayOfWeek.length || !emptyPatterns.contentType.length) { + return null; + } + }).not.toThrow(); + }); + + it('should calculate demographic changes correctly', () => { + const current = { '18-24': 30.0, '25-34': 35.0 }; + const previous = { '18-24': 25.0, '25-34': 35.0 }; + + const calculateChange = (currentData: Record, previousData: Record) => { + const changes: Record = {}; + + Object.keys(currentData).forEach(key => { + const currentValue = currentData[key] || 0; + const previousValue = previousData[key] || 0; + changes[key] = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : 0; + }); + + return changes; + }; + + const changes = calculateChange(current, previous); + + // 18-24: from 25.0 to 30.0 = +20% change + expect(changes['18-24']).toBeCloseTo(20, 0); + + // 25-34: from 35.0 to 35.0 = 0% change + expect(changes['25-34']).toBe(0); + }); + }); + + describe('Data Formatting', () => { + it('should format numbers correctly', () => { + const formatNumber = (num: number) => { + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toString(); + }; + + expect(formatNumber(500)).toBe('500'); + expect(formatNumber(1500)).toBe('1.5K'); + expect(formatNumber(1500000)).toBe('1.5M'); + }); + + it('should format percentages correctly', () => { + const formatPercentage = (value: number) => `${value.toFixed(1)}%`; + + expect(formatPercentage(25.678)).toBe('25.7%'); + expect(formatPercentage(0)).toBe('0.0%'); + expect(formatPercentage(100)).toBe('100.0%'); + }); + }); +}); + +// Export the mock data generator for testing +export const getMockAudienceData = () => { + const generateDemographics = () => ({ + ageGroups: { + '18-24': 28.5, + '25-34': 35.2, + '35-44': 22.1, + '45-54': 10.8, + '55+': 3.4 + }, + locations: { + 'United States': 45.2, + 'United Kingdom': 18.7, + 'Canada': 12.3, + 'Australia': 8.9, + 'Germany': 6.2, + 'Other': 8.7 + }, + interests: { + 'Fashion': 32.1, + 'Technology': 24.8, + 'Travel': 18.5, + 'Food': 15.2, + 'Fitness': 9.4 + }, + genders: { + 'Female': 62.3, + 'Male': 35.7, + 'Other': 2.0 + } + }); + + const generateEngagementPatterns = () => ({ + timeOfDay: Array.from({ length: 24 }, (_, hour) => ({ + hour, + engagement: Math.random() * 8 + 2, // 2-10% engagement + impressions: Math.floor(Math.random() * 5000) + 1000 + })), + dayOfWeek: [ + { day: 'Monday', engagement: 4.2, reach: 12000 }, + { day: 'Tuesday', engagement: 3.8, reach: 11500 }, + { day: 'Wednesday', engagement: 4.5, reach: 13200 }, + { day: 'Thursday', engagement: 5.1, reach: 14800 }, + { day: 'Friday', engagement: 6.2, reach: 16500 }, + { day: 'Saturday', engagement: 7.8, reach: 18900 }, + { day: 'Sunday', engagement: 6.9, reach: 17200 } + ], + contentType: [ + { type: 'photo', engagement: 4.8, count: 45 }, + { type: 'video', engagement: 6.2, count: 23 }, + { type: 'carousel', engagement: 5.5, count: 18 }, + { type: 'story', engagement: 3.9, count: 67 }, + { type: 'reel', engagement: 7.1, count: 12 } + ] + }); + + const generateAudienceHistory = () => { + return Array.from({ length: 30 }, (_, i) => ({ + date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + demographics: generateDemographics(), + totalAudience: Math.floor(Math.random() * 10000) + 50000 + })); + }; + + return { + demographics: generateDemographics(), + engagementPatterns: generateEngagementPatterns(), + audienceHistory: generateAudienceHistory(), + dataLimitations: { + hasInsufficientData: Math.random() > 0.7, // 30% chance of insufficient data + missingPlatforms: Math.random() > 0.5 ? ['Twitter', 'TikTok'] : [], + dataQualityScore: Math.floor(Math.random() * 30) + 70, // 70-100 score + lastUpdated: new Date().toISOString() + } + }; +}; \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useAudienceAnalytics.test.ts b/Frontend/src/hooks/__tests__/useAudienceAnalytics.test.ts new file mode 100644 index 0000000..9e62541 --- /dev/null +++ b/Frontend/src/hooks/__tests__/useAudienceAnalytics.test.ts @@ -0,0 +1,436 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useAudienceAnalytics } from '../useAudienceAnalytics'; + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock localStorage +const mockLocalStorage = { + getItem: vi.fn(() => 'mock-token'), + setItem: vi.fn(), + removeItem: vi.fn() +}; +Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); + +const mockAudienceData = { + demographics: { + ageGroups: { + '18-24': 28.5, + '25-34': 35.2, + '35-44': 22.1, + '45-54': 10.8, + '55+': 3.4 + }, + locations: { + 'United States': 45.2, + 'United Kingdom': 18.7, + 'Canada': 12.3 + }, + interests: { + 'Fashion': 32.1, + 'Technology': 24.8, + 'Travel': 18.5 + }, + genders: { + 'Female': 62.3, + 'Male': 35.7, + 'Other': 2.0 + } + }, + engagementPatterns: { + timeOfDay: [ + { hour: 9, engagement: 5.2, impressions: 3500 }, + { hour: 12, engagement: 6.8, impressions: 4200 }, + { hour: 18, engagement: 7.9, impressions: 5100 } + ], + dayOfWeek: [ + { day: 'Monday', engagement: 4.2, reach: 12000 }, + { day: 'Tuesday', engagement: 3.8, reach: 11500 }, + { day: 'Friday', engagement: 6.2, reach: 16500 } + ], + contentType: [ + { type: 'photo', engagement: 4.8, count: 45 }, + { type: 'video', engagement: 6.2, count: 23 }, + { type: 'reel', engagement: 7.1, count: 12 } + ] + }, + audienceHistory: [ + { + date: '2024-01-01', + demographics: { + ageGroups: { '18-24': 25.0, '25-34': 35.0 }, + locations: { 'United States': 40.0, 'United Kingdom': 20.0 }, + interests: { 'Fashion': 30.0, 'Technology': 25.0 }, + genders: { 'Female': 60.0, 'Male': 38.0, 'Other': 2.0 } + }, + totalAudience: 48000 + }, + { + date: '2024-01-02', + demographics: { + ageGroups: { '18-24': 28.5, '25-34': 35.2 }, + locations: { 'United States': 45.2, 'United Kingdom': 18.7 }, + interests: { 'Fashion': 32.1, 'Technology': 24.8 }, + genders: { 'Female': 62.3, 'Male': 35.7, 'Other': 2.0 } + }, + totalAudience: 52000 + } + ], + dataLimitations: { + hasInsufficientData: false, + missingPlatforms: [], + dataQualityScore: 85, + lastUpdated: '2024-01-15T10:00:00Z' + } +}; + +describe('useAudienceAnalytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('Data Fetching', () => { + it('should fetch audience data successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockAudienceData); + expect(result.current.error).toBeNull(); + expect(result.current.lastUpdated).toBeInstanceOf(Date); + }); + + it('should handle API errors gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Internal Server Error' + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Failed to fetch audience data: Internal Server Error'); + expect(result.current.data).toBeTruthy(); // Should fallback to mock data + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.data).toBeTruthy(); // Should fallback to mock data + }); + }); + + describe('Options Handling', () => { + it('should include contractId in API request when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics({ contractId: 'contract-123' })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('contractId=contract-123'), + expect.any(Object) + ); + }); + }); + + it('should include timeRange in API request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics({ timeRange: '7d' })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('timeRange=7d'), + expect.any(Object) + ); + }); + }); + + it('should use default timeRange when not provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('timeRange=30d'), + expect.any(Object) + ); + }); + }); + }); + + describe('Data Refresh', () => { + it('should refresh data when refreshData is called', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockAudienceData + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Clear previous calls + mockFetch.mockClear(); + + // Call refresh + await result.current.refreshData(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should set loading state during refresh', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockAudienceData + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Start refresh + const refreshPromise = result.current.refreshData(); + + expect(result.current.loading).toBe(true); + + await refreshPromise; + + expect(result.current.loading).toBe(false); + }); + }); + + describe('Auto Refresh', () => { + it('should auto-refresh data at specified intervals', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics({ refreshInterval: 1000 })); + + // Initial call + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Advance timer + vi.advanceTimersByTime(1000); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + it('should not auto-refresh when refreshInterval is 0', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics({ refreshInterval: 0 })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Advance timer + vi.advanceTimersByTime(10000); + + // Should still be only 1 call (initial) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('Audience Changes Calculation', () => { + it('should calculate demographic changes correctly', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const changes = result.current.audienceChanges; + + expect(changes).toBeTruthy(); + expect(changes?.ageGroups).toBeTruthy(); + expect(changes?.locations).toBeTruthy(); + expect(changes?.interests).toBeTruthy(); + expect(changes?.genders).toBeTruthy(); + + // Check specific calculations + // 18-24: from 25.0 to 28.5 = +14% change + expect(changes?.ageGroups['18-24']).toBeCloseTo(14, 0); + + // United States: from 40.0 to 45.2 = +13% change + expect(changes?.locations['United States']).toBeCloseTo(13, 0); + }); + + it('should return null when insufficient history data', async () => { + const dataWithLimitedHistory = { + ...mockAudienceData, + audienceHistory: [mockAudienceData.audienceHistory[0]] // Only one data point + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => dataWithLimitedHistory + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.audienceChanges).toBeNull(); + }); + }); + + describe('Engagement Insights', () => { + it('should calculate engagement insights correctly', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const insights = result.current.engagementInsights; + + expect(insights).toBeTruthy(); + expect(insights?.peakHour).toBe(18); // Highest engagement at 18:00 (7.9%) + expect(insights?.peakDay).toBe('Friday'); // Highest engagement on Friday (6.2%) + expect(insights?.bestContentType).toBe('reel'); // Highest engagement for reels (7.1%) + expect(insights?.averageEngagement).toBeCloseTo(6.63, 1); // Average of 5.2, 6.8, 7.9 + }); + + it('should return null when no engagement data available', async () => { + const dataWithoutEngagement = { + ...mockAudienceData, + engagementPatterns: { + timeOfDay: [], + dayOfWeek: [], + contentType: [] + } + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => dataWithoutEngagement + }); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.engagementInsights).toBeNull(); + }); + }); + + describe('Authentication', () => { + it('should include authorization header in requests', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAudienceData + }); + + renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer mock-token' + }) + }) + ); + }); + }); + }); + + describe('Mock Data Fallback', () => { + it('should provide realistic mock data structure', async () => { + mockFetch.mockRejectedValueOnce(new Error('API unavailable')); + + const { result } = renderHook(() => useAudienceAnalytics()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const data = result.current.data; + + expect(data).toBeTruthy(); + expect(data?.demographics).toBeTruthy(); + expect(data?.engagementPatterns).toBeTruthy(); + expect(data?.audienceHistory).toBeTruthy(); + expect(data?.dataLimitations).toBeTruthy(); + + // Check data structure + expect(data?.demographics.ageGroups).toBeTruthy(); + expect(data?.demographics.locations).toBeTruthy(); + expect(data?.demographics.interests).toBeTruthy(); + expect(data?.demographics.genders).toBeTruthy(); + + expect(data?.engagementPatterns.timeOfDay).toHaveLength(24); + expect(data?.engagementPatterns.dayOfWeek).toHaveLength(7); + expect(data?.engagementPatterns.contentType.length).toBeGreaterThan(0); + + expect(data?.audienceHistory.length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useEmailPreferences.test.ts b/Frontend/src/hooks/__tests__/useEmailPreferences.test.ts new file mode 100644 index 0000000..c643810 --- /dev/null +++ b/Frontend/src/hooks/__tests__/useEmailPreferences.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for useEmailPreferences hook - Simple YES/NO toggle functionality + */ + +import { renderHook, act } from '@testing-library/react'; +import { useEmailPreferences } from '../useEmailPreferences'; +import { emailPreferencesService } from '../../services/emailPreferencesService'; + +// Mock the service +jest.mock('../../services/emailPreferencesService'); + +const mockEmailPreferencesService = emailPreferencesService as jest.Mocked; + +describe('useEmailPreferences', () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); // Mock console.error to avoid noise in tests + }); + + it('should initialize with default values', () => { + mockEmailPreferencesService.getEmailPreference.mockResolvedValue({ + email_notifications_enabled: true + }); + + const { result } = renderHook(() => useEmailPreferences()); + + expect(result.current.emailNotificationsEnabled).toBe(true); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(null); + }); + + it('should fetch email preference on mount', async () => { + mockEmailPreferencesService.getEmailPreference.mockResolvedValue({ + email_notifications_enabled: false + }); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockEmailPreferencesService.getEmailPreference).toHaveBeenCalledTimes(1); + expect(result.current.emailNotificationsEnabled).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch error', async () => { + const errorMessage = 'Failed to fetch preference'; + mockEmailPreferencesService.getEmailPreference.mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(errorMessage); + expect(result.current.emailNotificationsEnabled).toBe(true); // Should remain default + }); + + it('should update email preference successfully', async () => { + mockEmailPreferencesService.getEmailPreference.mockResolvedValue({ + email_notifications_enabled: true + }); + mockEmailPreferencesService.updateEmailPreference.mockResolvedValue({ + email_notifications_enabled: false + }); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + await act(async () => { + await result.current.updateEmailPreference(false); + }); + + expect(mockEmailPreferencesService.updateEmailPreference).toHaveBeenCalledWith(false); + expect(result.current.emailNotificationsEnabled).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle update error', async () => { + mockEmailPreferencesService.getEmailPreference.mockResolvedValue({ + email_notifications_enabled: true + }); + const errorMessage = 'Failed to update preference'; + mockEmailPreferencesService.updateEmailPreference.mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + await act(async () => { + try { + await result.current.updateEmailPreference(false); + } catch (error) { + // Expected to throw + } + }); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.emailNotificationsEnabled).toBe(true); // Should remain unchanged + }); + + it('should refetch data when refetch is called', async () => { + mockEmailPreferencesService.getEmailPreference + .mockResolvedValueOnce({ email_notifications_enabled: true }) + .mockResolvedValueOnce({ email_notifications_enabled: false }); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.emailNotificationsEnabled).toBe(true); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockEmailPreferencesService.getEmailPreference).toHaveBeenCalledTimes(2); + expect(result.current.emailNotificationsEnabled).toBe(false); + }); + + it('should handle string error messages', async () => { + mockEmailPreferencesService.getEmailPreference.mockRejectedValue('String error'); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.error).toBe('Failed to fetch email preference'); + }); + + it('should clear error on successful operations', async () => { + // First call fails + mockEmailPreferencesService.getEmailPreference.mockRejectedValueOnce(new Error('Initial error')); + + const { result } = renderHook(() => useEmailPreferences()); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.error).toBe('Initial error'); + + // Second call succeeds + mockEmailPreferencesService.getEmailPreference.mockResolvedValue({ + email_notifications_enabled: true + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.error).toBe(null); + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useExportData.test.ts b/Frontend/src/hooks/__tests__/useExportData.test.ts new file mode 100644 index 0000000..62f00ce --- /dev/null +++ b/Frontend/src/hooks/__tests__/useExportData.test.ts @@ -0,0 +1,464 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useExportData, useExportStatusPolling } from '../useExportData'; +import { ExportConfig } from '@/components/analytics/export-configuration'; + +// Mock fetch +global.fetch = jest.fn(); + +// Mock environment variable +Object.defineProperty(import.meta, 'env', { + value: { + VITE_API_URL: 'http://localhost:8000' + } +}); + +describe('useExportData', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fetch as jest.Mock).mockClear(); + }); + + describe('createExport', () => { + it('successfully creates an export', async () => { + const mockResponse = { + export_id: 'export-123', + status: 'pending', + message: 'Export created successfully' + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse + }); + + const { result } = renderHook(() => useExportData()); + + const exportConfig: ExportConfig = { + format: 'csv', + dateRange: { + start: new Date('2024-01-01'), + end: new Date('2024-01-31') + }, + metrics: ['reach', 'engagement_rate'], + campaignIds: ['campaign-1'] + }; + + let exportId: string; + await act(async () => { + exportId = await result.current.createExport(exportConfig); + }); + + expect(exportId!).toBe('export-123'); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/exports/create', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + format: 'csv', + dateRange: { + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-31T00:00:00.000Z' + }, + metrics: ['reach', 'engagement_rate'], + campaignIds: ['campaign-1'] + }) + }) + ); + }); + + it('handles API error during export creation', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ detail: 'Invalid request' }) + }); + + const { result } = renderHook(() => useExportData()); + + const exportConfig: ExportConfig = { + format: 'csv', + dateRange: { + start: new Date('2024-01-01'), + end: new Date('2024-01-31') + }, + metrics: ['reach'], + campaignIds: [] + }; + + await act(async () => { + await expect(result.current.createExport(exportConfig)).rejects.toThrow('Invalid request'); + }); + + expect(result.current.error).toBe('Invalid request'); + }); + + it('handles network error during export creation', async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useExportData()); + + const exportConfig: ExportConfig = { + format: 'pdf', + dateRange: { + start: new Date('2024-01-01'), + end: new Date('2024-01-31') + }, + metrics: ['roi'], + campaignIds: [] + }; + + await act(async () => { + await expect(result.current.createExport(exportConfig)).rejects.toThrow('Network error'); + }); + + expect(result.current.error).toBe('Network error'); + }); + }); + + describe('getExportStatus', () => { + it('successfully gets export status', async () => { + const mockStatus = { + id: 'export-123', + status: 'completed', + format: 'csv', + created_at: '2024-01-15T10:30:00Z', + completed_at: '2024-01-15T10:35:00Z', + file_url: '/downloads/export-123.csv' + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockStatus + }); + + const { result } = renderHook(() => useExportData()); + + let status: any; + await act(async () => { + status = await result.current.getExportStatus('export-123'); + }); + + expect(status).toEqual(mockStatus); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/exports/export-123/status', + expect.objectContaining({ + headers: {} + }) + ); + }); + + it('returns null for 404 status', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + const { result } = renderHook(() => useExportData()); + + let status: any; + await act(async () => { + status = await result.current.getExportStatus('nonexistent-export'); + }); + + expect(status).toBeNull(); + }); + + it('handles API error when getting status', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ detail: 'Internal server error' }) + }); + + const { result } = renderHook(() => useExportData()); + + await act(async () => { + await expect(result.current.getExportStatus('export-123')).rejects.toThrow('Internal server error'); + }); + }); + }); + + describe('getUserExports', () => { + it('successfully gets user exports', async () => { + const mockExports = { + exports: [ + { + id: 'export-1', + status: 'completed', + format: 'csv', + created_at: '2024-01-15T10:30:00Z' + }, + { + id: 'export-2', + status: 'pending', + format: 'pdf', + created_at: '2024-01-16T10:30:00Z' + } + ], + total: 2 + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockExports + }); + + const { result } = renderHook(() => useExportData()); + + let exports: any; + await act(async () => { + exports = await result.current.getUserExports(); + }); + + expect(exports).toEqual(mockExports.exports); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/exports/user/exports', + expect.objectContaining({ + headers: {} + }) + ); + }); + }); + + describe('deleteExport', () => { + it('successfully deletes export', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: 'Export deleted successfully' }) + }); + + const { result } = renderHook(() => useExportData()); + + await act(async () => { + await result.current.deleteExport('export-123'); + }); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/exports/export-123', + expect.objectContaining({ + method: 'DELETE', + headers: {} + }) + ); + }); + + it('handles error when deleting export', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ detail: 'Export not found' }) + }); + + const { result } = renderHook(() => useExportData()); + + await act(async () => { + await expect(result.current.deleteExport('nonexistent-export')).rejects.toThrow('Export not found'); + }); + }); + }); + + describe('downloadFile', () => { + it('creates download link and triggers download', () => { + // Mock DOM methods + const mockLink = { + href: '', + download: '', + target: '', + click: jest.fn() + }; + + const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any); + const appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation(); + const removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation(); + + const { result } = renderHook(() => useExportData()); + + act(() => { + result.current.downloadFile('/downloads/export-123.csv', 'my-export.csv'); + }); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(mockLink.href).toBe('http://localhost:8000/downloads/export-123.csv'); + expect(mockLink.download).toBe('my-export.csv'); + expect(mockLink.target).toBe('_blank'); + expect(mockLink.click).toHaveBeenCalled(); + expect(appendChildSpy).toHaveBeenCalledWith(mockLink); + expect(removeChildSpy).toHaveBeenCalledWith(mockLink); + + // Cleanup + createElementSpy.mockRestore(); + appendChildSpy.mockRestore(); + removeChildSpy.mockRestore(); + }); + }); + + describe('loading and error states', () => { + it('sets loading state during API calls', async () => { + (fetch as jest.Mock).mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ + ok: true, + json: async () => ({ export_id: 'test' }) + }), 100)) + ); + + const { result } = renderHook(() => useExportData()); + + expect(result.current.isLoading).toBe(false); + + const exportConfig: ExportConfig = { + format: 'csv', + dateRange: { start: new Date(), end: new Date() }, + metrics: ['reach'], + campaignIds: [] + }; + + act(() => { + result.current.createExport(exportConfig); + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('clears error on successful API call', async () => { + const { result } = renderHook(() => useExportData()); + + // First, cause an error + (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const exportConfig: ExportConfig = { + format: 'csv', + dateRange: { start: new Date(), end: new Date() }, + metrics: ['reach'], + campaignIds: [] + }; + + await act(async () => { + try { + await result.current.createExport(exportConfig); + } catch (e) { + // Expected error + } + }); + + expect(result.current.error).toBe('Network error'); + + // Then, make a successful call + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ export_id: 'test' }) + }); + + await act(async () => { + await result.current.createExport(exportConfig); + }); + + expect(result.current.error).toBeNull(); + }); + }); +}); + +describe('useExportStatusPolling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('polls export status until completion', async () => { + const mockGetExportStatus = jest.fn() + .mockResolvedValueOnce({ id: 'export-123', status: 'pending' }) + .mockResolvedValueOnce({ id: 'export-123', status: 'processing' }) + .mockResolvedValueOnce({ id: 'export-123', status: 'completed' }); + + // Mock the useExportData hook + jest.doMock('../useExportData', () => ({ + useExportData: () => ({ + getExportStatus: mockGetExportStatus + }) + })); + + const { result } = renderHook(() => useExportStatusPolling('export-123', 1000)); + + act(() => { + result.current.startPolling(); + }); + + expect(result.current.isPolling).toBe(true); + + // First poll + await act(async () => { + await Promise.resolve(); + }); + + expect(mockGetExportStatus).toHaveBeenCalledWith('export-123'); + expect(result.current.status).toEqual({ id: 'export-123', status: 'pending' }); + + // Advance timer and second poll + act(() => { + jest.advanceTimersByTime(1000); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.status).toEqual({ id: 'export-123', status: 'processing' }); + + // Advance timer and third poll (should stop after completion) + act(() => { + jest.advanceTimersByTime(1000); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.status).toEqual({ id: 'export-123', status: 'completed' }); + expect(result.current.isPolling).toBe(false); + }); + + it('stops polling when stopPolling is called', async () => { + const mockGetExportStatus = jest.fn() + .mockResolvedValue({ id: 'export-123', status: 'processing' }); + + jest.doMock('../useExportData', () => ({ + useExportData: () => ({ + getExportStatus: mockGetExportStatus + }) + })); + + const { result } = renderHook(() => useExportStatusPolling('export-123', 1000)); + + act(() => { + result.current.startPolling(); + }); + + expect(result.current.isPolling).toBe(true); + + act(() => { + result.current.stopPolling(); + }); + + expect(result.current.isPolling).toBe(false); + }); + + it('does not start polling when exportId is null', () => { + const { result } = renderHook(() => useExportStatusPolling(null, 1000)); + + act(() => { + result.current.startPolling(); + }); + + expect(result.current.isPolling).toBe(false); + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useIntegration.test.ts b/Frontend/src/hooks/__tests__/useIntegration.test.ts new file mode 100644 index 0000000..46399d6 --- /dev/null +++ b/Frontend/src/hooks/__tests__/useIntegration.test.ts @@ -0,0 +1,148 @@ +/** + * Integration Hook Tests + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useIntegration } from '../useIntegration'; + +// Mock the integration service +vi.mock('../../services/integrationService', () => ({ + integrationService: { + getAllWorkflows: vi.fn(() => []), + getWorkflowStatus: vi.fn(() => undefined), + cancelWorkflow: vi.fn(), + executeBrandOnboardingWorkflow: vi.fn(() => Promise.resolve('workflow-1')), + executeContentLinkingWorkflow: vi.fn(() => Promise.resolve('workflow-2')), + executeExportWorkflow: vi.fn(() => Promise.resolve('workflow-3')), + executeAlertSetupWorkflow: vi.fn(() => Promise.resolve('workflow-4')) + } +})); + +// Mock sonner +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn() + } +})); + +describe('useIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + localStorage.setItem('userId', 'test-user-id'); + }); + + it('should initialize with empty workflows', () => { + const { result } = renderHook(() => useIntegration()); + + expect(result.current.workflows).toEqual([]); + expect(result.current.activeWorkflows).toEqual([]); + expect(result.current.isExecuting).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should provide workflow execution functions', () => { + const { result } = renderHook(() => useIntegration()); + + expect(typeof result.current.executeBrandOnboarding).toBe('function'); + expect(typeof result.current.executeContentLinking).toBe('function'); + expect(typeof result.current.executeExport).toBe('function'); + expect(typeof result.current.executeAlertSetup).toBe('function'); + }); + + it('should provide workflow monitoring functions', () => { + const { result } = renderHook(() => useIntegration()); + + expect(typeof result.current.getWorkflowStatus).toBe('function'); + expect(typeof result.current.cancelWorkflow).toBe('function'); + expect(typeof result.current.refreshWorkflows).toBe('function'); + }); + + it('should handle brand onboarding execution', async () => { + const { result } = renderHook(() => useIntegration()); + + await act(async () => { + const workflowId = await result.current.executeBrandOnboarding('brand-123'); + expect(workflowId).toBe('workflow-1'); + }); + }); + + it('should handle content linking execution', async () => { + const { result } = renderHook(() => useIntegration()); + + const params = { + contractId: 'contract-123', + contentUrl: 'https://instagram.com/p/test', + userId: 'user-123', + platform: 'instagram' as const, + contentId: 'content-123' + }; + + await act(async () => { + const workflowId = await result.current.executeContentLinking(params); + expect(workflowId).toBe('workflow-2'); + }); + }); + + it('should handle export execution', async () => { + const { result } = renderHook(() => useIntegration()); + + const params = { + format: 'csv' as const, + dateRange: { start: '2024-01-01', end: '2024-01-31' }, + metrics: ['reach', 'impressions'], + contractIds: ['contract-1'] + }; + + await act(async () => { + const workflowId = await result.current.executeExport(params); + expect(workflowId).toBe('workflow-3'); + }); + }); + + it('should handle alert setup execution', async () => { + const { result } = renderHook(() => useIntegration()); + + const params = { + contractId: 'contract-123', + thresholds: [ + { metric: 'engagement_rate', operator: 'lt' as const, value: 2.0 } + ], + notificationChannels: ['email' as const, 'in_app' as const] + }; + + await act(async () => { + const workflowId = await result.current.executeAlertSetup(params); + expect(workflowId).toBe('workflow-4'); + }); + }); + + it('should handle workflow status retrieval', () => { + const { result } = renderHook(() => useIntegration()); + + const status = result.current.getWorkflowStatus('workflow-123'); + expect(status).toBeUndefined(); + }); + + it('should handle workflow cancellation', () => { + const { result } = renderHook(() => useIntegration()); + + expect(() => { + result.current.cancelWorkflow('workflow-123'); + }).not.toThrow(); + }); + + it('should handle error clearing', () => { + const { result } = renderHook(() => useIntegration()); + + act(() => { + result.current.clearError(); + }); + + expect(result.current.error).toBe(null); + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useRealTimeAnalytics.performance.test.ts b/Frontend/src/hooks/__tests__/useRealTimeAnalytics.performance.test.ts new file mode 100644 index 0000000..a71d763 --- /dev/null +++ b/Frontend/src/hooks/__tests__/useRealTimeAnalytics.performance.test.ts @@ -0,0 +1,235 @@ +/** + * Performance tests for useRealTimeAnalytics hook + * Tests caching functionality and load times + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the cache functionality directly +const mockCache = new Map(); + +describe('useRealTimeAnalytics Performance', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCache.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have fast cache operations', () => { + const testData = { + timestamp: new Date(), + metrics: { + reach: 10000, + impressions: 20000, + engagementRate: 5.2, + likes: 500, + comments: 50, + shares: 25, + conversions: 10 + }, + cacheHit: false, + loadTime: 25 + }; + + // Test cache set performance + const setStartTime = performance.now(); + mockCache.set('test-key', { + data: testData, + timestamp: Date.now(), + ttl: 5 * 60 * 1000 + }); + const setTime = performance.now() - setStartTime; + + expect(setTime).toBeLessThan(10); // Should be very fast + expect(mockCache.has('test-key')).toBe(true); + + // Test cache get performance + const getStartTime = performance.now(); + const cached = mockCache.get('test-key'); + const getTime = performance.now() - getStartTime; + + expect(getTime).toBeLessThan(5); // Should be extremely fast + expect(cached.data).toEqual(testData); + }); + + it('should handle cache TTL correctly', () => { + const testData = { + timestamp: new Date(), + metrics: { + reach: 10000, + impressions: 20000, + engagementRate: 5.2, + likes: 500, + comments: 50, + shares: 25, + conversions: 10 + } + }; + + const now = Date.now(); + const ttl = 1000; // 1 second + + // Set cache entry + mockCache.set('test-key', { + data: testData, + timestamp: now, + ttl: ttl + }); + + // Should be valid immediately + const cached = mockCache.get('test-key'); + expect(cached).toBeDefined(); + expect(now - cached.timestamp).toBeLessThan(ttl); + + // Simulate expired cache + const expiredEntry = { + data: testData, + timestamp: now - (ttl + 100), // Expired + ttl: ttl + }; + mockCache.set('expired-key', expiredEntry); + + const expiredCached = mockCache.get('expired-key'); + expect(Date.now() - expiredCached.timestamp).toBeGreaterThan(ttl); + }); + + it('should generate consistent cache keys', () => { + const generateCacheKey = (campaignId?: string, contractId?: string) => { + return campaignId ? `campaign:${campaignId}` : `contract:${contractId}`; + }; + + const key1 = generateCacheKey('campaign-123'); + const key2 = generateCacheKey('campaign-123'); + const key3 = generateCacheKey(undefined, 'contract-456'); + + expect(key1).toBe(key2); + expect(key1).toBe('campaign:campaign-123'); + expect(key3).toBe('contract:contract-456'); + expect(key1).not.toBe(key3); + }); + + it('should handle cache size limits', () => { + const maxSize = 100; + + // Fill cache beyond limit + for (let i = 0; i < maxSize + 10; i++) { + mockCache.set(`key-${i}`, { + data: { test: i }, + timestamp: Date.now(), + ttl: 5 * 60 * 1000 + }); + } + + expect(mockCache.size).toBe(maxSize + 10); + + // Simulate cache cleanup (would normally be automatic) + if (mockCache.size > maxSize) { + const keysToDelete = Array.from(mockCache.keys()).slice(0, mockCache.size - maxSize); + keysToDelete.forEach(key => mockCache.delete(key)); + } + + expect(mockCache.size).toBe(maxSize); + }); + + it('should provide performance metrics structure', () => { + const mockStats = { hits: 5, misses: 3 }; + const mockData = { loadTime: 25 }; + const useCache = true; + + const getPerformanceMetrics = () => { + const cacheHitRate = mockStats.hits + mockStats.misses > 0 + ? (mockStats.hits / (mockStats.hits + mockStats.misses)) * 100 + : 0; + + return { + cacheHitRate: Math.round(cacheHitRate), + totalRequests: mockStats.hits + mockStats.misses, + cacheSize: mockCache.size, + lastLoadTime: mockData.loadTime || 0, + usingCache: useCache + }; + }; + + const metrics = getPerformanceMetrics(); + + expect(metrics).toHaveProperty('cacheHitRate'); + expect(metrics).toHaveProperty('totalRequests'); + expect(metrics).toHaveProperty('cacheSize'); + expect(metrics).toHaveProperty('lastLoadTime'); + expect(metrics).toHaveProperty('usingCache'); + + expect(metrics.cacheHitRate).toBe(63); // 5/8 * 100 = 62.5, rounded to 63 + expect(metrics.totalRequests).toBe(8); + expect(metrics.lastLoadTime).toBe(25); + expect(metrics.usingCache).toBe(true); + }); + + it('should meet dashboard performance requirements', () => { + // Simulate dashboard load components + const components = [ + 'roi-metrics', + 'audience-demographics', + 'portfolio-data', + 'content-list', + 'performance-summary' + ]; + + const startTime = performance.now(); + + // Simulate loading each component (with cache hits) + components.forEach(component => { + const componentStartTime = performance.now(); + + // Simulate cache lookup (very fast) + const cached = mockCache.get(component); + if (!cached) { + // Simulate API call (slower) + const mockApiTime = Math.random() * 100 + 50; // 50-150ms + // Would normally be async, but simulating the time + } + + const componentTime = performance.now() - componentStartTime; + expect(componentTime).toBeLessThan(200); // Each component should load quickly + }); + + const totalLoadTime = performance.now() - startTime; + + // Total dashboard load should be under 2 seconds (requirement) + expect(totalLoadTime).toBeLessThan(2000); + + // With caching, should be much faster + expect(totalLoadTime).toBeLessThan(500); + }); + + it('should handle concurrent cache operations', async () => { + const concurrentOperations = 10; + const promises = []; + + const startTime = performance.now(); + + // Simulate concurrent cache operations + for (let i = 0; i < concurrentOperations; i++) { + const promise = new Promise(resolve => { + setTimeout(() => { + mockCache.set(`concurrent-${i}`, { + data: { id: i }, + timestamp: Date.now(), + ttl: 5 * 60 * 1000 + }); + resolve(mockCache.get(`concurrent-${i}`)); + }, Math.random() * 10); // Random delay up to 10ms + }); + promises.push(promise); + } + + const results = await Promise.all(promises); + const totalTime = performance.now() - startTime; + + expect(results).toHaveLength(concurrentOperations); + expect(mockCache.size).toBeGreaterThanOrEqual(concurrentOperations); + expect(totalTime).toBeLessThan(100); // Should handle concurrent ops quickly + }); +}); \ No newline at end of file diff --git a/Frontend/src/hooks/__tests__/useRetry.test.ts b/Frontend/src/hooks/__tests__/useRetry.test.ts new file mode 100644 index 0000000..791abd5 --- /dev/null +++ b/Frontend/src/hooks/__tests__/useRetry.test.ts @@ -0,0 +1,327 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useRetry, useApiWithRetry } from '../useRetry'; + +// Mock timers +vi.useFakeTimers(); + +describe('useRetry', () => { + beforeEach(() => { + vi.clearAllTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.useFakeTimers(); + }); + + it('should initialize with correct default state', () => { + const mockOperation = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useRetry(mockOperation)); + + expect(result.current.state.isRetrying).toBe(false); + expect(result.current.state.retryCount).toBe(0); + expect(result.current.state.lastError).toBe(null); + expect(result.current.state.canRetry).toBe(true); + }); + + it('should execute operation successfully on first try', async () => { + const mockOperation = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useRetry(mockOperation)); + + await act(async () => { + await result.current.retry(); + }); + + expect(mockOperation).toHaveBeenCalledTimes(1); + expect(result.current.state.isRetrying).toBe(false); + expect(result.current.state.retryCount).toBe(0); + expect(result.current.state.lastError).toBe(null); + expect(result.current.state.canRetry).toBe(true); + }); + + it('should retry on failure with exponential backoff', async () => { + const error = new Error('Test error'); + const mockOperation = vi.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(undefined); + + const onRetry = vi.fn(); + const { result } = renderHook(() => + useRetry(mockOperation, { maxRetries: 3, baseDelay: 1000, onRetry }) + ); + + // Start the retry process + act(() => { + result.current.retry(); + }); + + // Wait for first failure + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.state.retryCount).toBe(1); + expect(result.current.state.lastError).toBe(error); + expect(onRetry).toHaveBeenCalledWith(1); + + // Wait for first retry (after 1000ms delay) + act(() => { + vi.advanceTimersByTime(1000); + }); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.state.retryCount).toBe(2); + expect(onRetry).toHaveBeenCalledWith(2); + + // Wait for second retry (after 2000ms delay) + act(() => { + vi.advanceTimersByTime(2000); + }); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + // Should succeed on third attempt + expect(result.current.state.retryCount).toBe(0); + expect(result.current.state.lastError).toBe(null); + expect(result.current.state.canRetry).toBe(true); + }); + + it('should stop retrying after max retries reached', async () => { + const error = new Error('Test error'); + const mockOperation = vi.fn().mockRejectedValue(error); + const onMaxRetriesReached = vi.fn(); + + const { result } = renderHook(() => + useRetry(mockOperation, { maxRetries: 2, baseDelay: 100, onMaxRetriesReached }) + ); + + // Start the retry process + act(() => { + result.current.retry(); + }); + + // Wait for all retries to complete + await act(async () => { + vi.advanceTimersByTime(1000); + await vi.runOnlyPendingTimersAsync(); + }); + + expect(mockOperation).toHaveBeenCalledTimes(3); // Initial + 2 retries + expect(result.current.state.canRetry).toBe(false); + expect(result.current.state.retryCount).toBe(2); + expect(onMaxRetriesReached).toHaveBeenCalled(); + }); + + it('should reset state correctly', async () => { + const error = new Error('Test error'); + const mockOperation = vi.fn().mockRejectedValue(error); + + const { result } = renderHook(() => useRetry(mockOperation)); + + // Trigger a failure + act(() => { + result.current.retry(); + }); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.state.retryCount).toBe(1); + expect(result.current.state.lastError).toBe(error); + + // Reset + act(() => { + result.current.reset(); + }); + + expect(result.current.state.retryCount).toBe(0); + expect(result.current.state.lastError).toBe(null); + expect(result.current.state.canRetry).toBe(true); + expect(result.current.state.isRetrying).toBe(false); + }); + + it('should respect max delay configuration', async () => { + const error = new Error('Test error'); + const mockOperation = vi.fn().mockRejectedValue(error); + + const { result } = renderHook(() => + useRetry(mockOperation, { + maxRetries: 5, + baseDelay: 1000, + backoffMultiplier: 2, + maxDelay: 3000 + }) + ); + + act(() => { + result.current.retry(); + }); + + // First retry should be after 1000ms + await act(async () => { + vi.advanceTimersByTime(1000); + await vi.runOnlyPendingTimersAsync(); + }); + + // Second retry should be after 2000ms + await act(async () => { + vi.advanceTimersByTime(2000); + await vi.runOnlyPendingTimersAsync(); + }); + + // Third retry should be capped at 3000ms (not 4000ms) + await act(async () => { + vi.advanceTimersByTime(3000); + await vi.runOnlyPendingTimersAsync(); + }); + + expect(mockOperation).toHaveBeenCalledTimes(4); // Initial + 3 retries + }); +}); + +describe('useApiWithRetry', () => { + beforeEach(() => { + vi.clearAllTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.useFakeTimers(); + }); + + it('should handle successful API call', async () => { + const mockData = { id: 1, name: 'Test' }; + const mockApiCall = vi.fn().mockResolvedValue(mockData); + const onSuccess = vi.fn(); + + const { result } = renderHook(() => + useApiWithRetry(mockApiCall, { onSuccess }) + ); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.data).toEqual(mockData); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(onSuccess).toHaveBeenCalledWith(mockData); + }); + + it('should handle API call failure', async () => { + const error = new Error('API Error'); + const mockApiCall = vi.fn().mockRejectedValue(error); + const onError = vi.fn(); + + const { result } = renderHook(() => + useApiWithRetry(mockApiCall, { onError }) + ); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.data).toBe(null); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(error); + expect(onError).toHaveBeenCalledWith(error); + }); + + it('should show loading state during execution', async () => { + const mockApiCall = vi.fn().mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve('data'), 100)) + ); + + const { result } = renderHook(() => useApiWithRetry(mockApiCall)); + + act(() => { + result.current.execute(); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(100); + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.loading).toBe(false); + }); + + it('should retry failed API calls', async () => { + const error = new Error('API Error'); + const mockData = { id: 1, name: 'Test' }; + const mockApiCall = vi.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(mockData); + + const { result } = renderHook(() => + useApiWithRetry(mockApiCall, { maxRetries: 2 }) + ); + + act(() => { + result.current.execute(); + }); + + // Wait for initial failure and first retry + await act(async () => { + vi.advanceTimersByTime(2000); + await vi.runOnlyPendingTimersAsync(); + }); + + expect(mockApiCall).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBe(null); + }); + + it('should allow manual retry', async () => { + const error = new Error('API Error'); + const mockApiCall = vi.fn().mockRejectedValue(error); + + const { result } = renderHook(() => useApiWithRetry(mockApiCall)); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.error).toBe(error); + + // Manual retry + await act(async () => { + await result.current.retry(); + }); + + expect(mockApiCall).toHaveBeenCalledTimes(2); + }); + + it('should reset state correctly', async () => { + const error = new Error('API Error'); + const mockApiCall = vi.fn().mockRejectedValue(error); + + const { result } = renderHook(() => useApiWithRetry(mockApiCall)); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.error).toBe(error); + + act(() => { + result.current.reset(); + }); + + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + expect(result.current.retryState.retryCount).toBe(0); + }); +}); \ No newline at end of file diff --git a/Frontend/src/pages/__tests__/Analytics.test.tsx b/Frontend/src/pages/__tests__/Analytics.test.tsx new file mode 100644 index 0000000..bde305b --- /dev/null +++ b/Frontend/src/pages/__tests__/Analytics.test.tsx @@ -0,0 +1,270 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import Analytics from '../Analytics'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { it } from 'node:test'; +import { beforeEach } from 'node:test'; +import { describe } from 'node:test'; + +// Mock the auth context +const mockUser = { id: '1', email: 'test@example.com', role: 'brand' }; +jest.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser }) +})); + +// Mock sonner toast +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + error: jest.fn() + } +})); + +// Mock the analytics components +jest.mock('@/components/analytics/performance-overview', () => { + return function MockPerformanceOverview({ loading }: { loading?: boolean }) { + if (loading) return
Loading performance overview...
; + return
Performance Overview Component
; + }; +}); + +jest.mock('@/components/analytics/metrics-chart', () => { + return function MockMetricsChart({ title }: { title?: string }) { + return
Metrics Chart: {title}
; + }; +}); + +jest.mock('@/components/analytics/contract-comparison', () => { + return function MockContractComparison({ loading }: { loading?: boolean }) { + if (loading) return
Loading contract comparison...
; + return
Contract Comparison Component
; + }; +}); + +// Mock fetch +global.fetch = jest.fn(); + +const AnalyticsWrapper = () => ( + + + +); + +describe('Analytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fetch as jest.Mock).mockClear(); + }); + + it('renders analytics dashboard correctly', async () => { + // Mock successful API response + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, // This will trigger fallback to mock data + json: async () => ({}) + }); + + render(); + + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + expect(screen.getByText('Track your brand campaigns, content performance, and ROI')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + expect(screen.getByText('Export')).toBeInTheDocument(); + }); + + it('shows loading state initially', () => { + // Mock a delayed response + (fetch as jest.Mock).mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getByText('Loading analytics...')).toBeInTheDocument(); + }); + + it('renders tabs correctly', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Performance Overview')).toBeInTheDocument(); + expect(screen.getByText('Detailed Charts')).toBeInTheDocument(); + expect(screen.getByText('Contract Comparison')).toBeInTheDocument(); + }); + }); + + it('handles time range selection', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + // Should have time range selector + expect(screen.getByDisplayValue('Last 30 days')).toBeInTheDocument(); + }); + }); + + it('handles contract selection', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + // Should have contract selector + expect(screen.getByDisplayValue('All Contracts')).toBeInTheDocument(); + }); + }); + + it('handles refresh functionality', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + // Should call fetch again + expect(fetch).toHaveBeenCalledTimes(2); + }); + }); + + it('handles export functionality', async () => { + // Mock URL.createObjectURL and related methods + const mockCreateObjectURL = jest.fn(() => 'mock-url'); + const mockRevokeObjectURL = jest.fn(); + Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL }); + Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL }); + + // Mock document.createElement and appendChild/removeChild + const mockLink = { + href: '', + download: '', + click: jest.fn() + }; + const mockAppendChild = jest.fn(); + const mockRemoveChild = jest.fn(); + + jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any); + jest.spyOn(document.body, 'appendChild').mockImplementation(mockAppendChild); + jest.spyOn(document.body, 'removeChild').mockImplementation(mockRemoveChild); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + const exportButton = screen.getByText('Export'); + fireEvent.click(exportButton); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + expect(mockRevokeObjectURL).toHaveBeenCalled(); + }); + }); + + it('switches between tabs correctly', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + // Click on Detailed Charts tab + const chartsTab = screen.getByText('Detailed Charts'); + fireEvent.click(chartsTab); + + expect(screen.getByText('Metrics Chart: Reach Over Time')).toBeInTheDocument(); + expect(screen.getByText('Metrics Chart: Engagement Rate')).toBeInTheDocument(); + }); + }); + + it('shows connection status correctly', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Data Sources')).toBeInTheDocument(); + expect(screen.getByText('Instagram Connected')).toBeInTheDocument(); + expect(screen.getByText('YouTube Connected')).toBeInTheDocument(); + expect(screen.getByText('Manage Connections')).toBeInTheDocument(); + }); + }); + + it('navigates to brand settings when manage connections is clicked', async () => { + // Mock window.location.href + delete (window as any).location; + window.location = { href: '' } as any; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }); + + render(); + + await waitFor(() => { + const manageButton = screen.getByText('Manage Connections'); + fireEvent.click(manageButton); + + expect(window.location.href).toBe('/brand/settings'); + }); + }); + + it('handles API errors gracefully', async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error('API Error')); + + render(); + + await waitFor(() => { + // Should still render with mock data + expect(screen.getByText('Brand Analytics & Tracking')).toBeInTheDocument(); + }); + }); + + it('uses mock data when API is unavailable', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: 'Server error' }) + }); + + render(); + + await waitFor(() => { + // Should render components with mock data + expect(screen.getByText('Performance Overview Component')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/Frontend/src/services/__tests__/emailPreferencesService.test.ts b/Frontend/src/services/__tests__/emailPreferencesService.test.ts new file mode 100644 index 0000000..9aa94a6 --- /dev/null +++ b/Frontend/src/services/__tests__/emailPreferencesService.test.ts @@ -0,0 +1,155 @@ +/** + * Tests for Email Preferences Service - Simple YES/NO toggle functionality + */ + +import { emailPreferencesService } from '../emailPreferencesService'; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe('EmailPreferencesService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getEmailPreference', () => { + it('should fetch email preference successfully', async () => { + const mockResponse = { + email_notifications_enabled: true + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await emailPreferencesService.getEmailPreference(); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/email-preferences/', + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + expect(result).toEqual(mockResponse); + }); + + it('should handle fetch error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ detail: 'Server error' }), + }); + + await expect(emailPreferencesService.getEmailPreference()).rejects.toThrow( + 'Server error' + ); + }); + + it('should handle network error', async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + await expect(emailPreferencesService.getEmailPreference()).rejects.toThrow( + 'Network error' + ); + }); + }); + + describe('updateEmailPreference', () => { + it('should update email preference successfully', async () => { + const mockResponse = { + email_notifications_enabled: false + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await emailPreferencesService.updateEmailPreference(false); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/email-preferences/', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email_notifications_enabled: false }), + } + ); + expect(result).toEqual(mockResponse); + }); + + it('should handle update error', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid request' }), + }); + + await expect(emailPreferencesService.updateEmailPreference(true)).rejects.toThrow( + 'Invalid request' + ); + }); + + it('should handle update with enabled preference', async () => { + const mockResponse = { + email_notifications_enabled: true + }; + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await emailPreferencesService.updateEmailPreference(true); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8000/api/email-preferences/', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email_notifications_enabled: true }), + } + ); + expect(result.email_notifications_enabled).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle malformed JSON response', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => { + throw new Error('Invalid JSON'); + }, + }); + + await expect(emailPreferencesService.getEmailPreference()).rejects.toThrow( + 'HTTP 500: Internal Server Error' + ); + }); + + it('should use default error message when detail is not provided', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({}), + }); + + await expect(emailPreferencesService.getEmailPreference()).rejects.toThrow( + 'HTTP 404: Not Found' + ); + }); + }); +}); \ No newline at end of file diff --git a/Frontend/src/services/__tests__/errorHandlingService.test.ts b/Frontend/src/services/__tests__/errorHandlingService.test.ts new file mode 100644 index 0000000..c63c76b --- /dev/null +++ b/Frontend/src/services/__tests__/errorHandlingService.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from 'vitest'; +import { ErrorHandlingService, handleApiError, isRetryableError, getErrorMessage } from '../errorHandlingService'; + +describe('ErrorHandlingService', () => { + const service = ErrorHandlingService.getInstance(); + + describe('parseError', () => { + it('parses network errors correctly', () => { + const networkError = { message: 'Network Error' }; + const result = service.parseError(networkError); + + expect(result.type).toBe('network'); + expect(result.retryable).toBe(true); + expect(result.userMessage).toContain('Unable to connect'); + }); + + it('parses timeout errors correctly', () => { + const timeoutError = { code: 'ECONNABORTED', message: 'timeout of 5000ms exceeded' }; + const result = service.parseError(timeoutError); + + expect(result.type).toBe('network'); + expect(result.retryable).toBe(true); + expect(result.userMessage).toContain('took too long'); + }); + + it('parses 401 authentication errors correctly', () => { + const authError = { + response: { + status: 401, + data: { message: 'Token expired' } + } + }; + const result = service.parseError(authError); + + expect(result.type).toBe('auth'); + expect(result.statusCode).toBe(401); + expect(result.retryable).toBe(false); + expect(result.userMessage).toContain('session has expired'); + }); + + it('parses 403 permission errors correctly', () => { + const permissionError = { + response: { + status: 403, + data: { message: 'Access denied' } + } + }; + const result = service.parseError(permissionError); + + expect(result.type).toBe('permission'); + expect(result.statusCode).toBe(403); + expect(result.retryable).toBe(false); + expect(result.userMessage).toContain('don\'t have permission'); + }); + + it('parses 404 not found errors correctly', () => { + const notFoundError = { + response: { + status: 404, + data: { message: 'Resource not found' } + } + }; + const result = service.parseError(notFoundError); + + expect(result.type).toBe('not-found'); + expect(result.statusCode).toBe(404); + expect(result.retryable).toBe(false); + expect(result.userMessage).toContain('could not be found'); + }); + + it('parses 429 rate limit errors correctly', () => { + const rateLimitError = { + response: { + status: 429, + data: { message: 'Rate limit exceeded' }, + headers: { 'retry-after': '60' } + } + }; + const result = service.parseError(rateLimitError); + + expect(result.type).toBe('rate-limit'); + expect(result.statusCode).toBe(429); + expect(result.retryable).toBe(true); + expect(result.userMessage).toContain('Too many requests'); + expect(result.details?.retryAfter).toBe('60'); + }); + + it('parses 400 validation errors correctly', () => { + const validationError = { + response: { + status: 400, + data: { message: 'Invalid input data' } + } + }; + const result = service.parseError(validationError); + + expect(result.type).toBe('validation'); + expect(result.statusCode).toBe(400); + expect(result.retryable).toBe(false); + expect(result.userMessage).toContain('Invalid input data'); + }); + + it('parses 500 server errors correctly', () => { + const serverError = { + response: { + status: 500, + data: { message: 'Internal server error' } + } + }; + const result = service.parseError(serverError); + + expect(result.type).toBe('server'); + expect(result.statusCode).toBe(500); + expect(result.retryable).toBe(true); + expect(result.userMessage).toContain('technical difficulties'); + }); + }); + + describe('getUserMessage', () => { + it('returns context-specific messages for analytics', () => { + const authError = service.parseError({ + response: { status: 401, data: { message: 'Token expired' } } + }); + + const message = service.getUserMessage(authError, 'analytics'); + expect(message).toContain('reconnect your social media accounts'); + }); + + it('returns context-specific messages for content linking', () => { + const notFoundError = service.parseError({ + response: { status: 404, data: { message: 'Content not found' } } + }); + + const message = service.getUserMessage(notFoundError, 'content-linking'); + expect(message).toContain('Content not found or may have been deleted'); + }); + + it('falls back to default message when no context match', () => { + const genericError = service.parseError({ + response: { status: 500, data: { message: 'Server error' } } + }); + + const message = service.getUserMessage(genericError, 'unknown-context'); + expect(message).toBe(genericError.userMessage); + }); + }); + + describe('getSuggestedAction', () => { + it('returns context-specific actions for auth errors', () => { + const authError = service.parseError({ + response: { status: 401, data: { message: 'Token expired' } } + }); + + const action = service.getSuggestedAction(authError, 'analytics'); + expect(action).toContain('Settings → Social Accounts'); + }); + + it('returns null when no specific action available', () => { + const genericError = service.parseError({ + response: { status: 500, data: { message: 'Server error' } } + }); + + const action = service.getSuggestedAction(genericError, 'unknown-context'); + expect(action).toBe(genericError.suggestedAction); + }); + }); + + describe('shouldRetry', () => { + it('returns false when max retries exceeded', () => { + const retryableError = service.parseError({ + response: { status: 500, data: { message: 'Server error' } } + }); + + const shouldRetry = service.shouldRetry(retryableError, 3, 3); + expect(shouldRetry).toBe(false); + }); + + it('returns false for non-retryable errors', () => { + const authError = service.parseError({ + response: { status: 401, data: { message: 'Token expired' } } + }); + + const shouldRetry = service.shouldRetry(authError, 0, 3); + expect(shouldRetry).toBe(false); + }); + + it('returns true for retryable errors within limit', () => { + const serverError = service.parseError({ + response: { status: 500, data: { message: 'Server error' } } + }); + + const shouldRetry = service.shouldRetry(serverError, 1, 3); + expect(shouldRetry).toBe(true); + }); + + it('returns true for rate limit errors', () => { + const rateLimitError = service.parseError({ + response: { status: 429, data: { message: 'Rate limit exceeded' } } + }); + + const shouldRetry = service.shouldRetry(rateLimitError, 1, 3); + expect(shouldRetry).toBe(true); + }); + }); + + describe('getRetryDelay', () => { + it('calculates exponential backoff correctly', () => { + const delay1 = service.getRetryDelay(0, 1000); + const delay2 = service.getRetryDelay(1, 1000); + const delay3 = service.getRetryDelay(2, 1000); + + expect(delay1).toBe(1000); + expect(delay2).toBe(2000); + expect(delay3).toBe(4000); + }); + + it('caps delay at maximum value', () => { + const delay = service.getRetryDelay(10, 1000, 5000); + expect(delay).toBe(5000); + }); + }); + + describe('createErrorResponse', () => { + it('creates complete error response', () => { + const error = { + response: { status: 401, data: { message: 'Token expired' } } + }; + + const response = service.createErrorResponse(error, 'analytics'); + + expect(response.error.type).toBe('auth'); + expect(response.userMessage).toContain('reconnect your social media accounts'); + expect(response.suggestedAction).toContain('Settings → Social Accounts'); + expect(response.canRetry).toBe(false); + }); + }); +}); + +describe('Utility functions', () => { + describe('handleApiError', () => { + it('returns formatted error response', () => { + const error = { + response: { status: 500, data: { message: 'Server error' } } + }; + + const result = handleApiError(error, 'analytics'); + + expect(result.error.type).toBe('server'); + expect(result.userMessage).toBeDefined(); + expect(result.canRetry).toBe(true); + }); + }); + + describe('isRetryableError', () => { + it('returns true for retryable errors', () => { + const serverError = { + response: { status: 500, data: { message: 'Server error' } } + }; + + expect(isRetryableError(serverError)).toBe(true); + }); + + it('returns false for non-retryable errors', () => { + const authError = { + response: { status: 401, data: { message: 'Token expired' } } + }; + + expect(isRetryableError(authError)).toBe(false); + }); + }); + + describe('getErrorMessage', () => { + it('returns user-friendly error message', () => { + const error = { + response: { status: 401, data: { message: 'Token expired' } } + }; + + const message = getErrorMessage(error, 'analytics'); + expect(message).toContain('reconnect your social media accounts'); + }); + }); +}); \ No newline at end of file diff --git a/Frontend/src/services/__tests__/integrationService.test.ts b/Frontend/src/services/__tests__/integrationService.test.ts new file mode 100644 index 0000000..14aabaa --- /dev/null +++ b/Frontend/src/services/__tests__/integrationService.test.ts @@ -0,0 +1,136 @@ +/** + * Integration Service Tests + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { integrationService } from '../integrationService'; + +// Mock fetch +global.fetch = vi.fn(); + +describe('IntegrationService', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + localStorage.setItem('userId', 'test-user-id'); + }); + + it('should be defined', () => { + expect(integrationService).toBeDefined(); + }); + + it('should have workflow management methods', () => { + expect(typeof integrationService.getAllWorkflows).toBe('function'); + expect(typeof integrationService.getWorkflowStatus).toBe('function'); + expect(typeof integrationService.cancelWorkflow).toBe('function'); + }); + + it('should initialize with empty workflows', () => { + const workflows = integrationService.getAllWorkflows(); + expect(Array.isArray(workflows)).toBe(true); + expect(workflows.length).toBe(0); + }); + + it('should return undefined for non-existent workflow status', () => { + const status = integrationService.getWorkflowStatus('non-existent'); + expect(status).toBeUndefined(); + }); + + it('should handle workflow cancellation', () => { + expect(() => { + integrationService.cancelWorkflow('test-workflow'); + }).not.toThrow(); + }); + + it('should handle brand onboarding workflow execution', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ authUrl: 'https://test.com/oauth' }) + }); + + // Mock window.open to avoid "Not implemented" error + const mockWindow = { + closed: true, + close: vi.fn() + }; + global.window.open = vi.fn().mockReturnValue(mockWindow); + + try { + await integrationService.executeBrandOnboardingWorkflow('brand-123'); + } catch (error) { + // Expected to fail due to OAuth verification + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('OAuth'); + } + }, 10000); + + it('should handle content linking workflow execution', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }) + }); + + const params = { + contractId: 'contract-123', + contentUrl: 'https://instagram.com/p/test', + userId: 'user-123', + platform: 'instagram', + contentId: 'content-123' + }; + + try { + await integrationService.executeContentLinkingWorkflow(params); + } catch (error) { + // Expected to fail due to validation + expect(error).toBeInstanceOf(Error); + } + }); + + it('should handle export workflow execution', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ job_id: 'export-123', status: 'completed' }) + }); + + const params = { + format: 'csv' as const, + dateRange: { start: '2024-01-01', end: '2024-01-31' }, + metrics: ['reach', 'impressions'], + contractIds: ['contract-1'] + }; + + try { + await integrationService.executeExportWorkflow(params); + } catch (error) { + // Expected to fail due to validation or API calls + expect(error).toBeInstanceOf(Error); + } + }, 10000); + + it('should handle alert setup workflow execution', async () => { + const mockFetch = global.fetch as any; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ alert_id: 'alert-123' }) + }); + + const params = { + contractId: 'contract-123', + thresholds: [ + { metric: 'engagement_rate', operator: 'lt' as const, value: 2.0 } + ], + notificationChannels: ['email' as const, 'in_app' as const] + }; + + try { + await integrationService.executeAlertSetupWorkflow(params); + } catch (error) { + // Expected to fail due to validation or API calls + expect(error).toBeInstanceOf(Error); + } + }); +}); \ No newline at end of file diff --git a/Frontend/src/test-setup.md b/Frontend/src/test-setup.md new file mode 100644 index 0000000..d46e7b7 --- /dev/null +++ b/Frontend/src/test-setup.md @@ -0,0 +1,166 @@ +# Test Setup Guide + +This document explains how to set up and run tests for the analytics and brand settings components. + +## Prerequisites + +To run the tests, you'll need to install testing dependencies: + +```bash +npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom +``` + +## Test Configuration + +Create a `vitest.config.ts` file in the Frontend directory: + +```typescript +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) +``` + +Create a `src/test-setup.ts` file: + +```typescript +import '@testing-library/jest-dom' + +// Mock localStorage +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +global.localStorage = localStorageMock; + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); +``` + +## Running Tests + +Add test scripts to your `package.json`: + +```json +{ + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" + } +} +``` + +Run tests: + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test + +# Run tests once +npm run test:run + +# Run tests with coverage +npm run test:coverage + +# Run tests with UI +npm run test:ui +``` + +## Test Files + +The following test files have been created: + +### Analytics Components +- `src/components/analytics/__tests__/performance-overview.test.tsx` +- `src/components/analytics/__tests__/metrics-chart.test.tsx` + +### Content Components +- `src/components/content/__tests__/content-linking.test.tsx` + +### Brand Components +- `src/components/brand/__tests__/social-account-connection.test.tsx` + +### Pages +- `src/pages/__tests__/Analytics.test.tsx` + +## Test Coverage + +The tests cover: + +1. **Component Rendering**: Ensures components render correctly with different props +2. **User Interactions**: Tests button clicks, form submissions, and user input +3. **State Management**: Verifies component state changes and updates +4. **Error Handling**: Tests error scenarios and fallback behavior +5. **Loading States**: Ensures loading indicators work correctly +6. **API Integration**: Mocks API calls and tests response handling + +## Mocking Strategy + +The tests use various mocking strategies: + +- **External Libraries**: Recharts, Sonner toast notifications +- **API Calls**: Fetch requests with different response scenarios +- **Browser APIs**: localStorage, window.open, URL.createObjectURL +- **React Context**: Auth context for user authentication +- **Component Dependencies**: Child components are mocked for isolation + +## Best Practices + +1. **Isolation**: Each test is independent and doesn't rely on others +2. **Cleanup**: Tests clean up after themselves using beforeEach/afterEach +3. **Realistic Data**: Tests use realistic mock data that matches expected formats +4. **Error Scenarios**: Tests include both success and error cases +5. **Accessibility**: Tests use accessible queries when possible +6. **Performance**: Tests are fast and don't make real network requests + +## Troubleshooting + +Common issues and solutions: + +1. **Import Errors**: Ensure path aliases are configured correctly in vitest.config.ts +2. **Component Not Found**: Check that components are exported correctly +3. **Mock Issues**: Verify mocks are cleared between tests +4. **Async Issues**: Use waitFor for async operations +5. **DOM Issues**: Ensure jsdom environment is configured + +## Future Improvements + +Consider adding: + +1. **E2E Tests**: Cypress or Playwright for full user workflows +2. **Visual Regression Tests**: Chromatic or similar for UI consistency +3. **Performance Tests**: Bundle size and runtime performance monitoring +4. **Integration Tests**: Test component interactions with real APIs +5. **Accessibility Tests**: Automated a11y testing with jest-axe \ No newline at end of file diff --git a/Frontend/src/test-setup.ts b/Frontend/src/test-setup.ts new file mode 100644 index 0000000..c38ca53 --- /dev/null +++ b/Frontend/src/test-setup.ts @@ -0,0 +1,37 @@ +import '@testing-library/jest-dom' +import { vi } from 'vitest' + +// Mock localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; +global.localStorage = localStorageMock; + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock window.URL.createObjectURL +Object.defineProperty(window.URL, 'createObjectURL', { + writable: true, + value: vi.fn(() => 'mock-url'), +}); + +Object.defineProperty(window.URL, 'revokeObjectURL', { + writable: true, + value: vi.fn(), +}); \ No newline at end of file diff --git a/Frontend/vitest.config.ts b/Frontend/vitest.config.ts new file mode 100644 index 0000000..8642d66 --- /dev/null +++ b/Frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) \ No newline at end of file diff --git a/test_roi.db b/test_roi.db new file mode 100644 index 0000000..18683dd Binary files /dev/null and b/test_roi.db differ