From 6a182aecafb0ac5955c9f1fc1f2169b0eec2dd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Mon, 18 May 2026 14:36:16 +0530 Subject: [PATCH] Support multi-source content asset filtering end-to-end --- backend/api/content_assets/router.py | 42 ++++++++++++---- backend/services/content_asset_service.py | 6 ++- .../tests/api/test_content_assets_router.py | 31 ++++++++++++ .../services/test_content_asset_service.py | 50 +++++++++++++++++++ .../hooks/__tests__/useContentAssets.test.ts | 35 +++++++++++++ frontend/src/hooks/useContentAssets.ts | 8 +-- 6 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 backend/tests/api/test_content_assets_router.py create mode 100644 backend/tests/services/test_content_asset_service.py create mode 100644 frontend/src/hooks/__tests__/useContentAssets.test.ts diff --git a/backend/api/content_assets/router.py b/backend/api/content_assets/router.py index 65b96a1b..e40effc9 100644 --- a/backend/api/content_assets/router.py +++ b/backend/api/content_assets/router.py @@ -5,7 +5,7 @@ API endpoints for managing unified content assets across all modules. from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Set from pydantic import BaseModel, Field from datetime import datetime @@ -47,6 +47,33 @@ class AssetResponse(BaseModel): from_attributes = True + + +def _parse_source_modules(source_module: Optional[List[str]]) -> Optional[List[AssetSource]]: + """Parse source_module query values from repeated params and/or comma-separated values.""" + if not source_module: + return None + + parsed_values: List[AssetSource] = [] + seen: Set[AssetSource] = set() + + for raw_value in source_module: + for value in raw_value.split(","): + normalized = value.strip().lower() + if not normalized: + continue + try: + module = AssetSource(normalized) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid source module: {value.strip()}") + + if module not in seen: + seen.add(module) + parsed_values.append(module) + + return parsed_values or None + + class AssetListResponse(BaseModel): """Response model for asset list.""" assets: List[AssetResponse] @@ -58,7 +85,7 @@ class AssetListResponse(BaseModel): @router.get("/", response_model=AssetListResponse) async def get_assets( asset_type: Optional[str] = Query(None, description="Filter by asset type"), - source_module: Optional[str] = Query(None, description="Filter by source module"), + source_module: Optional[List[str]] = Query(None, description="Filter by source module(s); supports repeated params and comma-separated values"), search: Optional[str] = Query(None, description="Search query"), tags: Optional[str] = Query(None, description="Comma-separated tags"), favorites_only: bool = Query(False, description="Only favorites"), @@ -89,12 +116,7 @@ async def get_assets( except ValueError: raise HTTPException(status_code=400, detail=f"Invalid asset type: {asset_type}") - source_module_enum = None - if source_module: - try: - source_module_enum = AssetSource(source_module.lower()) - except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid source module: {source_module}") + source_modules_enum = _parse_source_modules(source_module) tags_list = None if tags: @@ -126,7 +148,7 @@ async def get_assets( assets, total = service.get_user_assets( user_id=user_id, asset_type=asset_type_enum, - source_module=source_module_enum, + source_modules=source_modules_enum, search_query=search, tags=tags_list, favorites_only=favorites_only, @@ -200,7 +222,7 @@ async def create_asset( asset = service.create_asset( user_id=user_id, asset_type=asset_type_enum, - source_module=source_module_enum, + source_modules=source_modules_enum, filename=asset_data.filename, file_url=asset_data.file_url, file_path=asset_data.file_path, diff --git a/backend/services/content_asset_service.py b/backend/services/content_asset_service.py index 2300f254..9bc234d6 100644 --- a/backend/services/content_asset_service.py +++ b/backend/services/content_asset_service.py @@ -107,6 +107,7 @@ class ContentAssetService: user_id: str, asset_type: Optional[AssetType] = None, source_module: Optional[AssetSource] = None, + source_modules: Optional[List[AssetSource]] = None, search_query: Optional[str] = None, tags: Optional[List[str]] = None, favorites_only: bool = False, @@ -125,6 +126,7 @@ class ContentAssetService: user_id: Clerk user ID asset_type: Filter by asset type (optional) source_module: Filter by source module (optional) + source_modules: Filter by multiple source modules (optional) search_query: Search in title, description, prompt (optional) tags: Filter by tags (optional) favorites_only: Only return favorites (optional) @@ -142,7 +144,9 @@ class ContentAssetService: if asset_type: query = query.filter(ContentAsset.asset_type == asset_type) - if source_module: + if source_modules: + query = query.filter(ContentAsset.source_module.in_(source_modules)) + elif source_module: query = query.filter(ContentAsset.source_module == source_module) if favorites_only: diff --git a/backend/tests/api/test_content_assets_router.py b/backend/tests/api/test_content_assets_router.py new file mode 100644 index 00000000..68137e8f --- /dev/null +++ b/backend/tests/api/test_content_assets_router.py @@ -0,0 +1,31 @@ +import importlib.util +from pathlib import Path +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[3] +ROUTER_PATH = ROOT / 'backend' / 'api' / 'content_assets' / 'router.py' +MODELS_PATH = ROOT / 'backend' / 'models' / 'content_asset_models.py' + +models_spec = importlib.util.spec_from_file_location('content_asset_models', MODELS_PATH) +models = importlib.util.module_from_spec(models_spec) +models_spec.loader.exec_module(models) +AssetSource = models.AssetSource + +router_spec = importlib.util.spec_from_file_location('content_assets_router', ROUTER_PATH) +router = importlib.util.module_from_spec(router_spec) +router_spec.loader.exec_module(router) + + +def test_parse_source_modules_supports_repeated_and_csv_values(): + parsed = router._parse_source_modules(["blog_writer", "youtube,podcast"]) + assert parsed == [AssetSource.BLOG_WRITER, AssetSource.YOUTUBE, AssetSource.PODCAST] + + +def test_parse_source_modules_raises_for_invalid_values(): + try: + router._parse_source_modules(["blog_writer,unknown"]) + except HTTPException as exc: + assert exc.status_code == 400 + assert "Invalid source module" in exc.detail + else: + raise AssertionError("Expected HTTPException for invalid source module") diff --git a/backend/tests/services/test_content_asset_service.py b/backend/tests/services/test_content_asset_service.py new file mode 100644 index 00000000..69cf525e --- /dev/null +++ b/backend/tests/services/test_content_asset_service.py @@ -0,0 +1,50 @@ +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +SERVICE_PATH = ROOT / 'backend' / 'services' / 'content_asset_service.py' +MODELS_PATH = ROOT / 'backend' / 'models' / 'content_asset_models.py' + +models_spec = importlib.util.spec_from_file_location('content_asset_models', MODELS_PATH) +models = importlib.util.module_from_spec(models_spec) +models_spec.loader.exec_module(models) +AssetSource = models.AssetSource + +service_spec = importlib.util.spec_from_file_location('content_asset_service', SERVICE_PATH) +service_module = importlib.util.module_from_spec(service_spec) +service_spec.loader.exec_module(service_module) +ContentAssetService = service_module.ContentAssetService + + +class DummyQuery: + def __init__(self): + self.filters = [] + + def filter(self, expr): + self.filters.append(expr) + return self + + def count(self): return 0 + def order_by(self, *_args, **_kwargs): return self + def limit(self, *_args, **_kwargs): return self + def offset(self, *_args, **_kwargs): return self + def all(self): return [] + + +class DummyDB: + def __init__(self): self.query_obj = DummyQuery() + def query(self, *_args, **_kwargs): return self.query_obj + + +def test_get_user_assets_accepts_multiple_source_modules_filter(): + db = DummyDB() + service = ContentAssetService(db) + + assets, total = service.get_user_assets( + user_id="user-1", + source_modules=[AssetSource.BLOG_WRITER, AssetSource.YOUTUBE], + ) + + assert assets == [] + assert total == 0 + assert len(db.query_obj.filters) >= 2 diff --git a/frontend/src/hooks/__tests__/useContentAssets.test.ts b/frontend/src/hooks/__tests__/useContentAssets.test.ts new file mode 100644 index 00000000..738e59fe --- /dev/null +++ b/frontend/src/hooks/__tests__/useContentAssets.test.ts @@ -0,0 +1,35 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useContentAssets } from '../useContentAssets'; + +const getTokenMock = jest.fn(); + +jest.mock('@clerk/clerk-react', () => ({ + useAuth: () => ({ getToken: getTokenMock }), +})); + +describe('useContentAssets', () => { + beforeEach(() => { + getTokenMock.mockResolvedValue('test-token'); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ assets: [], total: 0, limit: 100, offset: 0 }), + } as Response); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sends all source_module values as repeated query params', async () => { + renderHook(() => + useContentAssets({ source_module: ['blog_writer', 'youtube'], limit: 50, offset: 0 }) + ); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + + const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; + const params = new URL(calledUrl).searchParams; + + expect(params.getAll('source_module')).toEqual(['blog_writer', 'youtube']); + }); +}); diff --git a/frontend/src/hooks/useContentAssets.ts b/frontend/src/hooks/useContentAssets.ts index f566a5aa..d91a1947 100644 --- a/frontend/src/hooks/useContentAssets.ts +++ b/frontend/src/hooks/useContentAssets.ts @@ -29,7 +29,7 @@ export interface ContentAsset { export interface AssetFilters { asset_type?: 'text' | 'image' | 'video' | 'audio'; - source_module?: string | string[]; // Support single or multiple source modules + source_module?: string | string[]; // Supports single or multiple source modules search?: string; tags?: string[]; favorites_only?: boolean; @@ -146,8 +146,10 @@ export const useContentAssets = (filters: AssetFilters = {}) => { if (currentFilters.source_module) { // Handle both string and array cases if (Array.isArray(currentFilters.source_module)) { - // For arrays, use the first value (backend doesn't support multiple yet) - params.append('source_module', currentFilters.source_module[0]); + // Send every selected source module as repeated query params + currentFilters.source_module.forEach((module) => { + params.append('source_module', module); + }); } else { params.append('source_module', currentFilters.source_module); }