From 592a4fd7c2cf696c1a0e89f3897bfdec4613216f Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 28 Dec 2025 20:27:20 +0100 Subject: [PATCH] feat: add show_in_legal to admin content page editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Show in Legal" checkbox to content page editor UI - Update API schemas (ContentPageCreate, ContentPageUpdate, ContentPageResponse) - Add show_in_legal parameter to service methods (create_page, update_page, etc.) - Fix ContentPageNotFoundException to pass identifier correctly - Fix UnauthorizedContentPageAccessException to use correct AuthorizationException API - Add comprehensive unit tests for ContentPageService (35 tests) - Add content page fixtures for testing - Update CMS documentation with navigation categories diagram 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/admin/content_pages.py | 8 + app/exceptions/content_page.py | 12 +- app/services/content_page_service.py | 11 + app/templates/admin/content-page-edit.html | 17 +- docs/features/content-management-system.md | 94 +++- static/admin/js/content-page-edit.js | 3 + tests/conftest.py | 1 + tests/fixtures/content_page_fixtures.py | 236 +++++++++ .../services/test_content_page_service.py | 486 ++++++++++++++++++ 9 files changed, 838 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/content_page_fixtures.py create mode 100644 tests/unit/services/test_content_page_service.py 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 -
+
+ + +
+ + (?) +
diff --git a/docs/features/content-management-system.md b/docs/features/content-management-system.md index ebe7d3c1..b849513f 100644 --- a/docs/features/content-management-system.md +++ b/docs/features/content-management-system.md @@ -67,10 +67,11 @@ CREATE TABLE content_pages ( is_published BOOLEAN DEFAULT FALSE NOT NULL, published_at TIMESTAMP WITH TIME ZONE, - -- Navigation + -- Navigation placement display_order INTEGER DEFAULT 0, - show_in_footer BOOLEAN DEFAULT TRUE, - show_in_header BOOLEAN DEFAULT FALSE, + show_in_footer BOOLEAN DEFAULT TRUE, -- Quick Links column + show_in_header BOOLEAN DEFAULT FALSE, -- Top navigation + show_in_legal BOOLEAN DEFAULT FALSE, -- Bottom bar with copyright -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, @@ -105,7 +106,9 @@ POST /api/v1/admin/content-pages/platform "content_format": "html", "meta_description": "Learn more about our marketplace", "is_published": true, + "show_in_header": true, "show_in_footer": true, + "show_in_legal": false, "display_order": 1 } ``` @@ -217,10 +220,16 @@ Automatically uses vendor context from middleware: **2. Get Navigation Links** ```bash +# Get all navigation pages GET /api/v1/shop/content-pages/navigation + +# Filter by placement +GET /api/v1/shop/content-pages/navigation?header_only=true +GET /api/v1/shop/content-pages/navigation?footer_only=true +GET /api/v1/shop/content-pages/navigation?legal_only=true ``` -Returns all published pages for footer/header navigation. +Returns published pages filtered by navigation placement. ## File Structure @@ -408,18 +417,50 @@ Always provide meta descriptions: ### 4. Navigation Management -Use `display_order` to control link ordering: +The CMS supports three navigation placement categories: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HEADER (show_in_header=true) │ +│ [Logo] About Us Contact [Login] [Sign Up] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ PAGE CONTENT │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ FOOTER (show_in_footer=true) │ +│ ┌──────────────┬──────────────┬────────────┬──────────────┐ │ +│ │ Quick Links │ Platform │ Contact │ Social │ │ +│ │ • About │ • Admin │ • Email │ • Twitter │ │ +│ │ • FAQ │ • Vendor │ • Phone │ • LinkedIn │ │ +│ │ • Contact │ │ │ │ │ +│ │ • Shipping │ │ │ │ │ +│ └──────────────┴──────────────┴────────────┴──────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ LEGAL BAR (show_in_legal=true) │ +│ © 2025 Wizamart Privacy Policy │ Terms │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Navigation Categories:** + +| Category | Field | Location | Typical Pages | +|----------|-------|----------|---------------| +| Header | `show_in_header` | Top navigation bar | About, Contact | +| Footer | `show_in_footer` | Quick Links column | FAQ, Shipping, Returns | +| Legal | `show_in_legal` | Bottom bar with © | Privacy, Terms | + +**Use `display_order` to control link ordering within each category:** ```python -# Platform defaults -"about": display_order=1 -"shipping": display_order=2 -"returns": display_order=3 -"privacy": display_order=4 -"terms": display_order=5 - -# Result in footer: -About | Shipping | Returns | Privacy | Terms +# Platform defaults with navigation placement +"about": display_order=1, show_in_header=True, show_in_footer=True +"contact": display_order=2, show_in_header=True, show_in_footer=True +"faq": display_order=3, show_in_footer=True +"shipping": display_order=4, show_in_footer=True +"returns": display_order=5, show_in_footer=True +"privacy": display_order=6, show_in_legal=True +"terms": display_order=7, show_in_legal=True ``` ### 5. Content Reversion @@ -437,18 +478,19 @@ DELETE /api/v1/vendor/{code}/content-pages/15 Standard slugs to implement: -| Slug | Title | Description | Show in Footer | -|------|-------|-------------|----------------| -| `about` | About Us | Company/vendor information | Yes | -| `contact` | Contact Us | Contact information and form | Yes | -| `faq` | FAQ | Frequently asked questions | Yes | -| `shipping` | Shipping Info | Shipping policies and rates | Yes | -| `returns` | Returns | Return and refund policy | Yes | -| `privacy` | Privacy Policy | Privacy and data protection | Yes | -| `terms` | Terms of Service | Terms and conditions | Yes | -| `help` | Help Center | Support resources | Yes | -| `size-guide` | Size Guide | Product sizing information | No | -| `careers` | Careers | Job opportunities | No | +| Slug | Title | Header | Footer | Legal | Order | +|------|-------|--------|--------|-------|-------| +| `about` | About Us | ✅ | ✅ | ❌ | 1 | +| `contact` | Contact Us | ✅ | ✅ | ❌ | 2 | +| `faq` | FAQ | ❌ | ✅ | ❌ | 3 | +| `shipping` | Shipping Info | ❌ | ✅ | ❌ | 4 | +| `returns` | Returns | ❌ | ✅ | ❌ | 5 | +| `privacy` | Privacy Policy | ❌ | ❌ | ✅ | 6 | +| `terms` | Terms of Service | ❌ | ❌ | ✅ | 7 | +| `help` | Help Center | ❌ | ✅ | ❌ | 8 | +| `size-guide` | Size Guide | ❌ | ❌ | ❌ | - | +| `careers` | Careers | ❌ | ❌ | ❌ | - | +| `cookies` | Cookie Policy | ❌ | ❌ | ✅ | 8 | ## Security Considerations diff --git a/static/admin/js/content-page-edit.js b/static/admin/js/content-page-edit.js index 1e769a1e..31d18cc1 100644 --- a/static/admin/js/content-page-edit.js +++ b/static/admin/js/content-page-edit.js @@ -27,6 +27,7 @@ function contentPageEditor(pageId) { is_published: false, show_in_header: false, show_in_footer: true, + show_in_legal: false, display_order: 0, vendor_id: null }, @@ -89,6 +90,7 @@ function contentPageEditor(pageId) { is_published: page.is_published || false, show_in_header: page.show_in_header || false, show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true, + show_in_legal: page.show_in_legal || false, display_order: page.display_order || 0, vendor_id: page.vendor_id }; @@ -125,6 +127,7 @@ function contentPageEditor(pageId) { is_published: this.form.is_published, show_in_header: this.form.show_in_header, show_in_footer: this.form.show_in_footer, + show_in_legal: this.form.show_in_legal, display_order: this.form.display_order, vendor_id: this.form.vendor_id }; diff --git a/tests/conftest.py b/tests/conftest.py index e84c0741..bfa79ab5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,4 +130,5 @@ pytest_plugins = [ "tests.fixtures.marketplace_import_job_fixtures", "tests.fixtures.message_fixtures", "tests.fixtures.testing_fixtures", + "tests.fixtures.content_page_fixtures", ] diff --git a/tests/fixtures/content_page_fixtures.py b/tests/fixtures/content_page_fixtures.py new file mode 100644 index 00000000..2001288c --- /dev/null +++ b/tests/fixtures/content_page_fixtures.py @@ -0,0 +1,236 @@ +# tests/fixtures/content_page_fixtures.py +""" +Content page test fixtures. + +Note: Fixtures should NOT use db.expunge() as it breaks lazy loading. +See tests/conftest.py for details on fixture best practices. +""" + +import uuid + +import pytest + +from models.database.content_page import ContentPage + + +@pytest.fixture +def platform_about_page(db): + """Create a platform-level About page (vendor_id=NULL).""" + page = ContentPage( + vendor_id=None, + slug="about", + title="About Us", + content="

About Our Platform

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="

FAQ

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="

Privacy Policy

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="

Terms of Service

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="

Contact

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="

Draft

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="

About Our Shop

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="

Shipping

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}"