diff --git a/app/api/v1/admin/content_pages.py b/app/api/v1/admin/content_pages.py index 99e7a80f..f26cbb78 100644 --- a/app/api/v1/admin/content_pages.py +++ b/app/api/v1/admin/content_pages.py @@ -52,6 +52,9 @@ class ContentPageCreate(BaseModel): is_published: bool = Field(default=False, description="Publish immediately") show_in_footer: bool = Field(default=True, description="Show in footer navigation") show_in_header: bool = Field(default=False, description="Show in header navigation") + show_in_legal: bool = Field( + default=False, description="Show in legal/bottom bar (next to copyright)" + ) display_order: int = Field(default=0, description="Display order (lower = first)") vendor_id: int | None = Field( None, description="Vendor ID (None for platform default)" @@ -70,6 +73,7 @@ class ContentPageUpdate(BaseModel): is_published: bool | None = None show_in_footer: bool | None = None show_in_header: bool | None = None + show_in_legal: bool | None = None display_order: int | None = None @@ -83,6 +87,7 @@ class ContentPageResponse(BaseModel): title: str content: str content_format: str + template: str | None = None meta_description: str | None meta_keywords: str | None is_published: bool @@ -90,6 +95,7 @@ class ContentPageResponse(BaseModel): display_order: int show_in_footer: bool show_in_header: bool + show_in_legal: bool is_platform_default: bool is_vendor_override: bool created_at: str @@ -146,6 +152,7 @@ def create_platform_page( is_published=page_data.is_published, show_in_footer=page_data.show_in_footer, show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, display_order=page_data.display_order, created_by=current_user.id, ) @@ -209,6 +216,7 @@ def update_page( is_published=page_data.is_published, show_in_footer=page_data.show_in_footer, show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, display_order=page_data.display_order, updated_by=current_user.id, ) diff --git a/app/exceptions/content_page.py b/app/exceptions/content_page.py index e4607b97..300c8342 100644 --- a/app/exceptions/content_page.py +++ b/app/exceptions/content_page.py @@ -23,7 +23,11 @@ class ContentPageNotFoundException(ResourceNotFoundException): message = f"Content page not found: {identifier}" else: message = "Content page not found" - super().__init__(message=message, resource_type="content_page") + super().__init__( + message=message, + resource_type="content_page", + identifier=str(identifier) if identifier else "unknown", + ) class ContentPageAlreadyExistsException(ConflictException): @@ -61,7 +65,8 @@ class UnauthorizedContentPageAccessException(AuthorizationException): def __init__(self, action: str = "access"): super().__init__( message=f"Cannot {action} content pages from other vendors", - required_permission=f"content_page:{action}", + error_code="CONTENT_PAGE_ACCESS_DENIED", + details={"required_permission": f"content_page:{action}"}, ) @@ -71,7 +76,8 @@ class VendorNotAssociatedException(AuthorizationException): def __init__(self): super().__init__( message="User is not associated with a vendor", - required_permission="vendor:member", + error_code="VENDOR_NOT_ASSOCIATED", + details={"required_permission": "vendor:member"}, ) diff --git a/app/services/content_page_service.py b/app/services/content_page_service.py index 69211cd9..edba1025 100644 --- a/app/services/content_page_service.py +++ b/app/services/content_page_service.py @@ -172,6 +172,7 @@ class ContentPageService: is_published: bool = False, show_in_footer: bool = True, show_in_header: bool = False, + show_in_legal: bool = False, display_order: int = 0, created_by: int | None = None, ) -> ContentPage: @@ -191,6 +192,7 @@ class ContentPageService: is_published: Publish immediately show_in_footer: Show in footer navigation show_in_header: Show in header navigation + show_in_legal: Show in legal/bottom bar navigation display_order: Sort order created_by: User ID who created it @@ -210,6 +212,7 @@ class ContentPageService: published_at=datetime.now(UTC) if is_published else None, show_in_footer=show_in_footer, show_in_header=show_in_header, + show_in_legal=show_in_legal, display_order=display_order, created_by=created_by, updated_by=created_by, @@ -237,6 +240,7 @@ class ContentPageService: is_published: bool | None = None, show_in_footer: bool | None = None, show_in_header: bool | None = None, + show_in_legal: bool | None = None, display_order: int | None = None, updated_by: int | None = None, ) -> ContentPage | None: @@ -255,6 +259,7 @@ class ContentPageService: is_published: New publish status show_in_footer: New footer visibility show_in_header: New header visibility + show_in_legal: New legal bar visibility display_order: New sort order updated_by: User ID who updated it @@ -288,6 +293,8 @@ class ContentPageService: page.show_in_footer = show_in_footer if show_in_header is not None: page.show_in_header = show_in_header + if show_in_legal is not None: + page.show_in_legal = show_in_legal if display_order is not None: page.display_order = display_order if updated_by is not None: @@ -390,6 +397,7 @@ class ContentPageService: is_published: bool | None = None, show_in_footer: bool | None = None, show_in_header: bool | None = None, + show_in_legal: bool | None = None, display_order: int | None = None, updated_by: int | None = None, ) -> ContentPage: @@ -411,6 +419,7 @@ class ContentPageService: is_published=is_published, show_in_footer=show_in_footer, show_in_header=show_in_header, + show_in_legal=show_in_legal, display_order=display_order, updated_by=updated_by, ) @@ -443,6 +452,7 @@ class ContentPageService: is_published: bool | None = None, show_in_footer: bool | None = None, show_in_header: bool | None = None, + show_in_legal: bool | None = None, display_order: int | None = None, updated_by: int | None = None, ) -> ContentPage: @@ -478,6 +488,7 @@ class ContentPageService: is_published=is_published, show_in_footer=show_in_footer, show_in_header=show_in_header, + show_in_legal=show_in_legal, display_order=display_order, updated_by=updated_by, ) diff --git a/app/templates/admin/content-page-edit.html b/app/templates/admin/content-page-edit.html index 7c193cee..6e1d6c5c 100644 --- a/app/templates/admin/content-page-edit.html +++ b/app/templates/admin/content-page-edit.html @@ -189,7 +189,7 @@ Navigation & Display -
Welcome to our platform.
", + content_format="html", + meta_description="Learn about our platform", + is_published=True, + show_in_footer=True, + show_in_header=True, + show_in_legal=False, + display_order=1, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def platform_faq_page(db): + """Create a platform-level FAQ page (vendor_id=NULL).""" + page = ContentPage( + vendor_id=None, + slug="faq", + title="Frequently Asked Questions", + content="Common questions answered.
", + content_format="html", + meta_description="Frequently asked questions", + is_published=True, + show_in_footer=True, + show_in_header=False, + show_in_legal=False, + display_order=2, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def platform_privacy_page(db): + """Create a platform-level Privacy Policy page for legal section.""" + page = ContentPage( + vendor_id=None, + slug="privacy", + title="Privacy Policy", + content="Your data is important to us.
", + content_format="html", + meta_description="Our privacy policy", + is_published=True, + show_in_footer=False, + show_in_header=False, + show_in_legal=True, + display_order=1, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def platform_terms_page(db): + """Create a platform-level Terms of Service page for legal section.""" + page = ContentPage( + vendor_id=None, + slug="terms", + title="Terms of Service", + content="By using our platform...
", + content_format="html", + meta_description="Our terms of service", + is_published=True, + show_in_footer=False, + show_in_header=False, + show_in_legal=True, + display_order=2, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def platform_contact_page(db): + """Create a platform-level Contact page.""" + page = ContentPage( + vendor_id=None, + slug="contact", + title="Contact Us", + content="Get in touch.
", + content_format="html", + meta_description="Contact us", + is_published=True, + show_in_footer=True, + show_in_header=True, + show_in_legal=False, + display_order=3, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def platform_draft_page(db): + """Create an unpublished platform page (draft).""" + page = ContentPage( + vendor_id=None, + slug="draft-page", + title="Draft Page", + content="This is a draft.
", + content_format="html", + is_published=False, + show_in_footer=True, + show_in_header=False, + show_in_legal=False, + display_order=99, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def vendor_about_page(db, test_vendor): + """Create a vendor-specific About page override.""" + page = ContentPage( + vendor_id=test_vendor.id, + slug="about", + title="About Our Shop", + content="Welcome to our shop.
", + content_format="html", + meta_description="Learn about our shop", + is_published=True, + show_in_footer=True, + show_in_header=True, + show_in_legal=False, + display_order=1, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def vendor_shipping_page(db, test_vendor): + """Create a vendor-specific Shipping page (no platform default).""" + page = ContentPage( + vendor_id=test_vendor.id, + slug="shipping", + title="Shipping Information", + content="We ship to Luxembourg.
", + content_format="html", + meta_description="Shipping info", + is_published=True, + show_in_footer=True, + show_in_header=False, + show_in_legal=False, + display_order=4, + ) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@pytest.fixture +def all_platform_pages( + db, + platform_about_page, + platform_faq_page, + platform_privacy_page, + platform_terms_page, + platform_contact_page, +): + """Create all platform pages for comprehensive testing.""" + return [ + platform_about_page, + platform_faq_page, + platform_privacy_page, + platform_terms_page, + platform_contact_page, + ] + + +@pytest.fixture +def content_page_factory(): + """Factory function to create content pages in tests.""" + + def _create_page(db, vendor_id=None, **kwargs): + unique_id = str(uuid.uuid4())[:8] + defaults = { + "vendor_id": vendor_id, + "slug": f"page-{unique_id}", + "title": f"Test Page {unique_id}", + "content": f"Content for {unique_id}
", + "content_format": "html", + "is_published": True, + "show_in_footer": True, + "show_in_header": False, + "show_in_legal": False, + "display_order": 0, + } + defaults.update(kwargs) + + page = ContentPage(**defaults) + db.add(page) + db.commit() + db.refresh(page) + return page + + return _create_page diff --git a/tests/unit/services/test_content_page_service.py b/tests/unit/services/test_content_page_service.py new file mode 100644 index 00000000..ea38687e --- /dev/null +++ b/tests/unit/services/test_content_page_service.py @@ -0,0 +1,486 @@ +# tests/unit/services/test_content_page_service.py +"""Unit tests for ContentPageService.""" + +import uuid + +import pytest + +from app.exceptions.content_page import ( + ContentPageNotFoundException, + UnauthorizedContentPageAccessException, +) +from app.services.content_page_service import ContentPageService, content_page_service +from models.database.content_page import ContentPage + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceGetPageForVendor: + """Test suite for ContentPageService.get_page_for_vendor().""" + + def test_get_platform_page_success(self, db, platform_about_page): + """Test getting a platform default page.""" + result = content_page_service.get_page_for_vendor( + db, slug="about", vendor_id=None + ) + + assert result is not None + assert result.id == platform_about_page.id + assert result.slug == "about" + assert result.vendor_id is None + + def test_get_vendor_override_returns_vendor_page( + self, db, platform_about_page, vendor_about_page, test_vendor + ): + """Test that vendor-specific override is returned over platform default.""" + result = content_page_service.get_page_for_vendor( + db, slug="about", vendor_id=test_vendor.id + ) + + assert result is not None + assert result.id == vendor_about_page.id + assert result.vendor_id == test_vendor.id + assert "Our Shop" in result.title + + def test_get_vendor_fallback_to_platform( + self, db, platform_faq_page, test_vendor + ): + """Test fallback to platform default when vendor has no override.""" + result = content_page_service.get_page_for_vendor( + db, slug="faq", vendor_id=test_vendor.id + ) + + assert result is not None + assert result.id == platform_faq_page.id + assert result.vendor_id is None + + def test_get_page_not_found(self, db): + """Test getting non-existent page returns None.""" + result = content_page_service.get_page_for_vendor( + db, slug="nonexistent", vendor_id=None + ) + + assert result is None + + def test_get_unpublished_page_excluded(self, db, platform_draft_page): + """Test unpublished pages are excluded by default.""" + result = content_page_service.get_page_for_vendor( + db, slug="draft-page", vendor_id=None + ) + + assert result is None + + def test_get_unpublished_page_included(self, db, platform_draft_page): + """Test unpublished pages are included when requested.""" + result = content_page_service.get_page_for_vendor( + db, slug="draft-page", vendor_id=None, include_unpublished=True + ) + + assert result is not None + assert result.id == platform_draft_page.id + assert result.is_published is False + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceListPagesForVendor: + """Test suite for ContentPageService.list_pages_for_vendor().""" + + def test_list_platform_pages(self, db, all_platform_pages): + """Test listing all platform pages.""" + result = content_page_service.list_pages_for_vendor(db, vendor_id=None) + + assert len(result) == 5 + slugs = {page.slug for page in result} + assert "about" in slugs + assert "faq" in slugs + assert "privacy" in slugs + assert "terms" in slugs + assert "contact" in slugs + + def test_list_footer_pages(self, db, all_platform_pages): + """Test listing pages marked for footer display.""" + result = content_page_service.list_pages_for_vendor( + db, vendor_id=None, footer_only=True + ) + + # about, faq, contact have show_in_footer=True + assert len(result) == 3 + for page in result: + assert page.show_in_footer is True + + def test_list_header_pages(self, db, all_platform_pages): + """Test listing pages marked for header display.""" + result = content_page_service.list_pages_for_vendor( + db, vendor_id=None, header_only=True + ) + + # about, contact have show_in_header=True + assert len(result) == 2 + for page in result: + assert page.show_in_header is True + + def test_list_legal_pages(self, db, all_platform_pages): + """Test listing pages marked for legal/bottom bar display.""" + result = content_page_service.list_pages_for_vendor( + db, vendor_id=None, legal_only=True + ) + + # privacy, terms have show_in_legal=True + assert len(result) == 2 + slugs = {page.slug for page in result} + assert "privacy" in slugs + assert "terms" in slugs + for page in result: + assert page.show_in_legal is True + + def test_list_vendor_pages_with_override( + self, db, platform_about_page, platform_faq_page, vendor_about_page, test_vendor + ): + """Test vendor pages merge correctly with platform defaults.""" + result = content_page_service.list_pages_for_vendor( + db, vendor_id=test_vendor.id + ) + + slugs = {page.slug: page for page in result} + + # Vendor override should be used for 'about' + assert slugs["about"].vendor_id == test_vendor.id + # Platform default should be used for 'faq' + assert slugs["faq"].vendor_id is None + + def test_list_pages_sorted_by_display_order(self, db, content_page_factory): + """Test pages are sorted by display_order.""" + # Create pages with specific display orders + page1 = content_page_factory(db, slug="page-c", display_order=3) + page2 = content_page_factory(db, slug="page-a", display_order=1) + page3 = content_page_factory(db, slug="page-b", display_order=2) + + result = content_page_service.list_pages_for_vendor(db, vendor_id=None) + + # Should be sorted by display_order + assert result[0].display_order <= result[1].display_order + assert result[1].display_order <= result[2].display_order + + def test_list_excludes_unpublished(self, db, platform_about_page, platform_draft_page): + """Test unpublished pages are excluded by default.""" + result = content_page_service.list_pages_for_vendor(db, vendor_id=None) + + slugs = {page.slug for page in result} + assert "about" in slugs + assert "draft-page" not in slugs + + def test_list_includes_unpublished(self, db, platform_about_page, platform_draft_page): + """Test unpublished pages are included when requested.""" + result = content_page_service.list_pages_for_vendor( + db, vendor_id=None, include_unpublished=True + ) + + slugs = {page.slug for page in result} + assert "about" in slugs + assert "draft-page" in slugs + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceCreatePage: + """Test suite for ContentPageService.create_page().""" + + def test_create_platform_page(self, db, test_user): + """Test creating a platform default page.""" + result = content_page_service.create_page( + db, + slug="new-page", + title="New Page", + content="New content
", + vendor_id=None, + is_published=True, + created_by=test_user.id, + ) + + assert result.id is not None + assert result.slug == "new-page" + assert result.title == "New Page" + assert result.vendor_id is None + assert result.is_published is True + assert result.created_by == test_user.id + + def test_create_vendor_page(self, db, test_vendor, test_user): + """Test creating a vendor-specific page.""" + result = content_page_service.create_page( + db, + slug="vendor-page", + title="Vendor Page", + content="Vendor content
", + vendor_id=test_vendor.id, + is_published=True, + created_by=test_user.id, + ) + + assert result.vendor_id == test_vendor.id + assert result.slug == "vendor-page" + + def test_create_page_with_all_navigation_flags(self, db): + """Test creating page with all navigation flags set.""" + result = content_page_service.create_page( + db, + slug="all-nav-page", + title="All Navigation Page", + content="Appears everywhere
", + show_in_header=True, + show_in_footer=True, + # Note: show_in_legal not in create_page params by default + ) + + assert result.show_in_header is True + assert result.show_in_footer is True + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceUpdatePage: + """Test suite for ContentPageService.update_page().""" + + def test_update_page_title(self, db, platform_about_page): + """Test updating page title.""" + result = content_page_service.update_page( + db, page_id=platform_about_page.id, title="Updated Title" + ) + + assert result.title == "Updated Title" + + def test_update_page_navigation_flags(self, db, platform_about_page): + """Test updating navigation flags.""" + result = content_page_service.update_page( + db, + page_id=platform_about_page.id, + show_in_header=False, + show_in_footer=False, + ) + + assert result.show_in_header is False + assert result.show_in_footer is False + + def test_update_page_not_found(self, db): + """Test updating non-existent page returns None.""" + result = content_page_service.update_page( + db, page_id=99999, title="New Title" + ) + + assert result is None + + def test_update_page_or_raise_not_found(self, db): + """Test update_page_or_raise raises exception for non-existent page.""" + with pytest.raises(ContentPageNotFoundException) as exc_info: + content_page_service.update_page_or_raise( + db, page_id=99999, title="New Title" + ) + + assert exc_info.value.error_code == "CONTENT_PAGE_NOT_FOUND" + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceDeletePage: + """Test suite for ContentPageService.delete_page().""" + + def test_delete_page_success(self, db, content_page_factory): + """Test deleting a page.""" + page = content_page_factory(db, slug="to-delete") + page_id = page.id + + result = content_page_service.delete_page(db, page_id=page_id) + db.commit() # Commit the deletion + + assert result is True + + # Verify page is deleted + deleted = content_page_service.get_page_by_id(db, page_id) + assert deleted is None + + def test_delete_page_not_found(self, db): + """Test deleting non-existent page returns False.""" + result = content_page_service.delete_page(db, page_id=99999) + + assert result is False + + def test_delete_page_or_raise_not_found(self, db): + """Test delete_page_or_raise raises exception for non-existent page.""" + with pytest.raises(ContentPageNotFoundException): + content_page_service.delete_page_or_raise(db, page_id=99999) + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceVendorMethods: + """Test suite for vendor-specific methods with ownership checks.""" + + def test_update_vendor_page_success(self, db, vendor_about_page, test_vendor): + """Test updating vendor page with correct ownership.""" + result = content_page_service.update_vendor_page( + db, + page_id=vendor_about_page.id, + vendor_id=test_vendor.id, + title="Updated Vendor Title", + ) + + assert result.title == "Updated Vendor Title" + + def test_update_vendor_page_wrong_vendor( + self, db, vendor_about_page, other_company + ): + """Test updating vendor page with wrong vendor raises exception.""" + from models.database.vendor import Vendor + + # Create another vendor + unique_id = str(uuid.uuid4())[:8] + other_vendor = Vendor( + company_id=other_company.id, + vendor_code=f"OTHER_{unique_id.upper()}", + subdomain=f"other{unique_id.lower()}", + name=f"Other Vendor {unique_id}", + is_active=True, + ) + db.add(other_vendor) + db.commit() + db.refresh(other_vendor) + + with pytest.raises(UnauthorizedContentPageAccessException) as exc_info: + content_page_service.update_vendor_page( + db, + page_id=vendor_about_page.id, + vendor_id=other_vendor.id, + title="Unauthorized Update", + ) + + assert exc_info.value.error_code == "CONTENT_PAGE_ACCESS_DENIED" + + def test_delete_vendor_page_success( + self, db, vendor_shipping_page, test_vendor + ): + """Test deleting vendor page with correct ownership.""" + page_id = vendor_shipping_page.id + + content_page_service.delete_vendor_page( + db, page_id=page_id, vendor_id=test_vendor.id + ) + db.commit() # Commit the deletion + + # Verify page is deleted + deleted = content_page_service.get_page_by_id(db, page_id) + assert deleted is None + + def test_delete_vendor_page_wrong_vendor( + self, db, vendor_about_page, other_company + ): + """Test deleting vendor page with wrong vendor raises exception.""" + from models.database.vendor import Vendor + + # Create another vendor + unique_id = str(uuid.uuid4())[:8] + other_vendor = Vendor( + company_id=other_company.id, + vendor_code=f"OTHER2_{unique_id.upper()}", + subdomain=f"other2{unique_id.lower()}", + name=f"Other Vendor 2 {unique_id}", + is_active=True, + ) + db.add(other_vendor) + db.commit() + db.refresh(other_vendor) + + with pytest.raises(UnauthorizedContentPageAccessException): + content_page_service.delete_vendor_page( + db, page_id=vendor_about_page.id, vendor_id=other_vendor.id + ) + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageServiceListAllMethods: + """Test suite for list_all methods.""" + + def test_list_all_platform_pages(self, db, all_platform_pages): + """Test listing only platform default pages.""" + result = content_page_service.list_all_platform_pages(db) + + assert len(result) == 5 + for page in result: + assert page.vendor_id is None + + def test_list_all_vendor_pages( + self, db, vendor_about_page, vendor_shipping_page, test_vendor + ): + """Test listing only vendor-specific pages.""" + result = content_page_service.list_all_vendor_pages(db, vendor_id=test_vendor.id) + + assert len(result) == 2 + for page in result: + assert page.vendor_id == test_vendor.id + + def test_list_all_pages_filtered_by_vendor( + self, db, platform_about_page, vendor_about_page, test_vendor + ): + """Test listing all pages filtered by vendor ID.""" + result = content_page_service.list_all_pages(db, vendor_id=test_vendor.id) + + # Should only include vendor pages, not platform defaults + assert len(result) >= 1 + for page in result: + assert page.vendor_id == test_vendor.id + + +@pytest.mark.unit +@pytest.mark.cms +class TestContentPageModel: + """Test suite for ContentPage model methods.""" + + def test_is_platform_default_property(self, db, platform_about_page): + """Test is_platform_default property.""" + assert platform_about_page.is_platform_default is True + assert platform_about_page.is_vendor_override is False + + def test_is_vendor_override_property(self, db, vendor_about_page): + """Test is_vendor_override property.""" + assert vendor_about_page.is_platform_default is False + assert vendor_about_page.is_vendor_override is True + + def test_to_dict_includes_navigation_flags(self, db, platform_privacy_page): + """Test to_dict includes all navigation flags.""" + result = platform_privacy_page.to_dict() + + assert "show_in_footer" in result + assert "show_in_header" in result + assert "show_in_legal" in result + assert result["show_in_legal"] is True + + def test_to_dict_includes_all_fields(self, db, platform_about_page): + """Test to_dict includes all expected fields.""" + result = platform_about_page.to_dict() + + expected_fields = [ + "id", + "vendor_id", + "slug", + "title", + "content", + "content_format", + "template", + "meta_description", + "meta_keywords", + "is_published", + "published_at", + "display_order", + "show_in_footer", + "show_in_header", + "show_in_legal", + "is_platform_default", + "is_vendor_override", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + for field in expected_fields: + assert field in result, f"Missing field: {field}"