feat(hosting,prospecting): add hosting unit tests and fix template bugs
All checks were successful
CI / ruff (push) Successful in 10s
CI / pytest (push) Successful in 48m48s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 38s
CI / deploy (push) Successful in 51s

- Add 55 unit tests for hosting module (hosted site service, client
  service service, stats service) with full fixture setup
- Fix table_empty_state macro: add x_message param for dynamic Alpine.js
  expressions rendered via x-text instead of server-side Jinja
- Fix hosting templates (sites, clients) using message= with Alpine
  expressions that rendered as literal text
- Fix prospecting templates (leads, scan-jobs, prospects) using
  nonexistent subtitle= param, migrated to x_message=
- Align hosting and prospecting admin templates with shared design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 06:18:26 +01:00
parent 8136739233
commit 2287f4597d
21 changed files with 2014 additions and 1118 deletions

View File

@@ -0,0 +1,219 @@
# app/modules/hosting/tests/unit/test_client_service_service.py
"""
Unit tests for ClientServiceService.
"""
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.hosting.exceptions import ClientServiceNotFoundException
from app.modules.hosting.models import ClientServiceStatus, ServiceType
from app.modules.hosting.services.client_service_service import ClientServiceService
@pytest.mark.unit
@pytest.mark.hosting
class TestClientServiceService:
"""Tests for ClientServiceService CRUD operations."""
def setup_method(self):
self.service = ClientServiceService()
def test_create_domain_service(self, db, hosted_site):
"""Test creating a domain service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "domain",
"name": "example.lu",
"domain_name": "example.lu",
"registrar": "Gandi",
"billing_period": "annual",
"price_cents": 1500,
},
)
db.commit()
assert svc.id is not None
assert svc.service_type == ServiceType.DOMAIN
assert svc.status == ClientServiceStatus.PENDING
assert svc.domain_name == "example.lu"
assert svc.registrar == "Gandi"
assert svc.price_cents == 1500
def test_create_email_service(self, db, hosted_site):
"""Test creating an email service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "email",
"name": "Email Hosting",
"mailbox_count": 10,
"billing_period": "monthly",
"price_cents": 900,
},
)
db.commit()
assert svc.service_type == ServiceType.EMAIL
assert svc.mailbox_count == 10
def test_create_ssl_service(self, db, hosted_site):
"""Test creating an SSL service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "ssl",
"name": "Let's Encrypt SSL",
"price_cents": 0,
"expires_at": datetime.now(UTC) + timedelta(days=90),
},
)
db.commit()
assert svc.service_type == ServiceType.SSL
assert svc.expires_at is not None
def test_create_hosting_service(self, db, hosted_site):
"""Test creating a hosting service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "hosting",
"name": "Shared Hosting",
"billing_period": "monthly",
"price_cents": 2000,
},
)
db.commit()
assert svc.service_type == ServiceType.HOSTING
def test_create_maintenance_service(self, db, hosted_site):
"""Test creating a website maintenance service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "website_maintenance",
"name": "Monthly Maintenance",
"billing_period": "monthly",
"price_cents": 5000,
"notes": "Includes security updates",
},
)
db.commit()
assert svc.service_type == ServiceType.WEBSITE_MAINTENANCE
assert svc.notes == "Includes security updates"
def test_get_by_id(self, db, client_service_domain):
"""Test getting a service by ID."""
result = self.service.get_by_id(db, client_service_domain.id)
assert result.id == client_service_domain.id
def test_get_by_id_not_found(self, db):
"""Test getting non-existent service raises exception."""
with pytest.raises(ClientServiceNotFoundException):
self.service.get_by_id(db, 99999)
def test_get_for_site(self, db, hosted_site, client_service_domain, client_service_email):
"""Test getting all services for a site."""
services = self.service.get_for_site(db, hosted_site.id)
assert len(services) >= 2
def test_update_service(self, db, client_service_domain):
"""Test updating a service."""
updated = self.service.update(
db,
client_service_domain.id,
{"name": "updated-domain.lu", "price_cents": 3000},
)
db.commit()
assert updated.name == "updated-domain.lu"
assert updated.price_cents == 3000
def test_update_service_status(self, db, client_service_domain):
"""Test updating a service status."""
updated = self.service.update(
db,
client_service_domain.id,
{"status": ClientServiceStatus.SUSPENDED},
)
db.commit()
assert updated.status == ClientServiceStatus.SUSPENDED
def test_delete_service(self, db, client_service_domain):
"""Test deleting a service."""
svc_id = client_service_domain.id
self.service.delete(db, svc_id)
db.commit()
with pytest.raises(ClientServiceNotFoundException):
self.service.get_by_id(db, svc_id)
def test_get_expiring_soon(self, db, client_service_expiring):
"""Test getting services expiring within 30 days."""
expiring = self.service.get_expiring_soon(db, days=30)
assert len(expiring) >= 1
assert all(s.expires_at is not None for s in expiring)
def test_get_expiring_soon_excludes_distant(self, db, client_service_domain):
"""Test that services expiring far in the future are excluded."""
# client_service_domain expires in 365 days
expiring = self.service.get_expiring_soon(db, days=30)
ids = [s.id for s in expiring]
assert client_service_domain.id not in ids
def test_get_all(self, db, client_service_domain, client_service_email):
"""Test listing all services with pagination."""
services, total = self.service.get_all(db)
assert total >= 2
assert len(services) >= 2
def test_get_all_filter_type(self, db, client_service_domain, client_service_email):
"""Test filtering services by type."""
services, total = self.service.get_all(db, service_type="domain")
assert total >= 1
assert all(s.service_type == ServiceType.DOMAIN for s in services)
def test_get_all_filter_status(self, db, client_service_domain):
"""Test filtering services by status."""
services, total = self.service.get_all(db, status="active")
assert total >= 1
assert all(s.status == ClientServiceStatus.ACTIVE for s in services)
def test_get_all_pagination(self, db, client_service_domain, client_service_email):
"""Test pagination."""
services, total = self.service.get_all(db, page=1, per_page=1)
assert len(services) == 1
assert total >= 2
def test_default_currency(self, db, hosted_site):
"""Test that default currency is EUR."""
svc = self.service.create(
db,
hosted_site.id,
{"service_type": "hosting", "name": "Test"},
)
db.commit()
assert svc.currency == "EUR"
def test_auto_renew_default(self, db, hosted_site):
"""Test that auto_renew defaults to True."""
svc = self.service.create(
db,
hosted_site.id,
{"service_type": "hosting", "name": "Test"},
)
db.commit()
assert svc.auto_renew is True

View File

@@ -0,0 +1,325 @@
# app/modules/hosting/tests/unit/test_hosted_site_service.py
"""
Unit tests for HostedSiteService.
"""
import uuid
import pytest
from app.modules.hosting.exceptions import (
HostedSiteNotFoundException,
InvalidStatusTransitionException,
)
from app.modules.hosting.models import HostedSiteStatus
from app.modules.hosting.services.hosted_site_service import (
HostedSiteService,
_slugify,
)
@pytest.mark.unit
@pytest.mark.hosting
class TestSlugify:
"""Tests for the _slugify helper."""
def test_basic_slug(self):
assert _slugify("My Business") == "my-business"
def test_special_characters_removed(self):
assert _slugify("Café & Brasserie!") == "caf-brasserie"
def test_multiple_spaces_collapsed(self):
assert _slugify(" Too Many Spaces ") == "too-many-spaces"
def test_max_length(self):
long_name = "A" * 100
assert len(_slugify(long_name)) <= 50
def test_empty_string(self):
assert _slugify("") == ""
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteService:
"""Tests for HostedSiteService CRUD operations."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_site(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site with auto-created store."""
unique = uuid.uuid4().hex[:8]
site = self.service.create(
db,
{
"business_name": f"New Business {unique}",
"contact_email": f"test-{unique}@example.com",
},
)
db.commit()
assert site.id is not None
assert site.business_name == f"New Business {unique}"
assert site.status == HostedSiteStatus.DRAFT
assert site.store_id is not None
def test_create_site_with_all_fields(self, db, hosting_platform, system_merchant):
"""Test creating a site with all optional fields."""
unique = uuid.uuid4().hex[:8]
site = self.service.create(
db,
{
"business_name": f"Full Business {unique}",
"contact_name": "Jane Doe",
"contact_email": f"jane-{unique}@example.com",
"contact_phone": "+352 999 888",
"internal_notes": "VIP client",
},
)
db.commit()
assert site.contact_name == "Jane Doe"
assert site.contact_phone == "+352 999 888"
assert site.internal_notes == "VIP client"
def test_get_by_id(self, db, hosted_site):
"""Test getting a hosted site by ID."""
result = self.service.get_by_id(db, hosted_site.id)
assert result.id == hosted_site.id
assert result.business_name == hosted_site.business_name
def test_get_by_id_not_found(self, db):
"""Test getting non-existent site raises exception."""
with pytest.raises(HostedSiteNotFoundException):
self.service.get_by_id(db, 99999)
def test_get_all(self, db, hosted_site):
"""Test listing all hosted sites."""
sites, total = self.service.get_all(db)
assert total >= 1
assert len(sites) >= 1
def test_get_all_search(self, db, hosted_site):
"""Test searching hosted sites by business name."""
name_part = hosted_site.business_name[:12]
sites, total = self.service.get_all(db, search=name_part)
assert total >= 1
def test_get_all_filter_status(self, db, hosted_site):
"""Test filtering sites by status."""
sites, total = self.service.get_all(db, status="draft")
assert total >= 1
assert all(s.status == HostedSiteStatus.DRAFT for s in sites)
def test_get_all_pagination(self, db, hosted_site):
"""Test pagination returns correct subset."""
sites, total = self.service.get_all(db, page=1, per_page=1)
assert len(sites) <= 1
def test_update(self, db, hosted_site):
"""Test updating a hosted site."""
updated = self.service.update(
db,
hosted_site.id,
{"business_name": "Updated Name", "internal_notes": "Updated notes"},
)
db.commit()
assert updated.business_name == "Updated Name"
assert updated.internal_notes == "Updated notes"
def test_delete(self, db, hosted_site):
"""Test deleting a hosted site."""
site_id = hosted_site.id
self.service.delete(db, site_id)
db.commit()
with pytest.raises(HostedSiteNotFoundException):
self.service.get_by_id(db, site_id)
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteLifecycle:
"""Tests for hosted site lifecycle transitions."""
def setup_method(self):
self.service = HostedSiteService()
def test_mark_poc_ready(self, db, hosted_site):
"""Test DRAFT → POC_READY transition."""
site = self.service.mark_poc_ready(db, hosted_site.id)
db.commit()
assert site.status == HostedSiteStatus.POC_READY
def test_send_proposal(self, db, hosted_site_poc_ready):
"""Test POC_READY → PROPOSAL_SENT transition."""
site = self.service.send_proposal(
db, hosted_site_poc_ready.id, notes="Proposal notes"
)
db.commit()
assert site.status == HostedSiteStatus.PROPOSAL_SENT
assert site.proposal_sent_at is not None
assert site.proposal_notes == "Proposal notes"
def test_send_proposal_without_notes(self, db, hosted_site_poc_ready):
"""Test sending proposal without notes."""
site = self.service.send_proposal(db, hosted_site_poc_ready.id)
db.commit()
assert site.status == HostedSiteStatus.PROPOSAL_SENT
assert site.proposal_sent_at is not None
def test_cancel_from_draft(self, db, hosted_site):
"""Test DRAFT → CANCELLED transition."""
site = self.service.cancel(db, hosted_site.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_cancel_from_poc_ready(self, db, hosted_site_poc_ready):
"""Test POC_READY → CANCELLED transition."""
site = self.service.cancel(db, hosted_site_poc_ready.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_cancel_from_proposal_sent(self, db, hosted_site_proposal_sent):
"""Test PROPOSAL_SENT → CANCELLED transition."""
site = self.service.cancel(db, hosted_site_proposal_sent.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_invalid_transition_draft_to_live(self, db, hosted_site):
"""Test that DRAFT → LIVE is not allowed."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.LIVE)
def test_invalid_transition_draft_to_accepted(self, db, hosted_site):
"""Test that DRAFT → ACCEPTED is not allowed."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.ACCEPTED)
def test_invalid_transition_cancelled_to_anything(self, db, hosted_site):
"""Test that CANCELLED state has no valid transitions."""
self.service.cancel(db, hosted_site.id)
db.commit()
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.DRAFT)
def test_invalid_transition_poc_ready_to_accepted(self, db, hosted_site_poc_ready):
"""Test that POC_READY → ACCEPTED is not allowed (must go through PROPOSAL_SENT)."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(
db, hosted_site_poc_ready.id, HostedSiteStatus.ACCEPTED
)
def test_suspend_from_live(self, db, hosted_site_proposal_sent):
"""Test LIVE → SUSPENDED transition."""
# Need to get to LIVE first: accepted → live
site = self.service._transition(
db, hosted_site_proposal_sent.id, HostedSiteStatus.ACCEPTED
)
db.flush()
# Manually set to LIVE (bypass go_live which needs StoreDomainService)
site.status = HostedSiteStatus.LIVE
db.flush()
site = self.service.suspend(db, site.id)
db.commit()
assert site.status == HostedSiteStatus.SUSPENDED
def test_reactivate_from_suspended(self, db, hosted_site_proposal_sent):
"""Test SUSPENDED → LIVE transition."""
site = self.service._transition(
db, hosted_site_proposal_sent.id, HostedSiteStatus.ACCEPTED
)
db.flush()
site.status = HostedSiteStatus.LIVE
db.flush()
site = self.service.suspend(db, site.id)
db.flush()
site = self.service._transition(db, site.id, HostedSiteStatus.LIVE)
db.commit()
assert site.status == HostedSiteStatus.LIVE
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteFromProspect:
"""Tests for creating hosted sites from prospects."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site from a prospect."""
from app.modules.prospecting.models import Prospect
prospect = Prospect(
channel="digital",
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
business_name="Prospect Business",
status="active",
has_website=True,
)
db.add(prospect)
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.id is not None
assert site.prospect_id == prospect.id
assert site.business_name == "Prospect Business"
def test_create_from_prospect_with_contacts(
self, db, hosting_platform, system_merchant
):
"""Test creating from prospect pre-fills contact info."""
from app.modules.prospecting.models import Prospect, ProspectContact
prospect = Prospect(
channel="digital",
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
business_name="Contact Business",
status="active",
)
db.add(prospect)
db.flush()
email_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value="hello@test.lu",
is_primary=True,
)
phone_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value="+352 111 222",
is_primary=True,
)
db.add_all([email_contact, phone_contact])
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.contact_email == "hello@test.lu"
assert site.contact_phone == "+352 111 222"
def test_create_from_nonexistent_prospect(
self, db, hosting_platform, system_merchant
):
"""Test creating from non-existent prospect raises exception."""
from app.modules.prospecting.exceptions import ProspectNotFoundException
with pytest.raises(ProspectNotFoundException):
self.service.create_from_prospect(db, 99999)

View File

@@ -0,0 +1,66 @@
# app/modules/hosting/tests/unit/test_stats_service.py
"""
Unit tests for hosting StatsService.
"""
import pytest
from app.modules.hosting.services.stats_service import StatsService
@pytest.mark.unit
@pytest.mark.hosting
class TestStatsService:
"""Tests for hosting StatsService."""
def setup_method(self):
self.service = StatsService()
def test_dashboard_stats_empty(self, db):
"""Test dashboard stats with no data."""
stats = self.service.get_dashboard_stats(db)
assert stats["total_sites"] == 0
assert stats["live_sites"] == 0
assert stats["poc_sites"] == 0
assert stats["active_services"] == 0
assert stats["monthly_revenue_cents"] == 0
assert stats["upcoming_renewals"] == 0
def test_dashboard_stats_with_site(self, db, hosted_site):
"""Test dashboard stats with a draft site."""
stats = self.service.get_dashboard_stats(db)
assert stats["total_sites"] >= 1
assert stats["poc_sites"] >= 1 # draft counts as POC
assert stats["sites_by_status"].get("draft", 0) >= 1
def test_dashboard_stats_with_services(
self, db, hosted_site, client_service_domain, client_service_email
):
"""Test dashboard stats with active services."""
stats = self.service.get_dashboard_stats(db)
assert stats["active_services"] >= 2
assert stats["monthly_revenue_cents"] >= 3000 # 2500 + 500
def test_dashboard_stats_services_by_type(
self, db, hosted_site, client_service_domain, client_service_email
):
"""Test services_by_type breakdown."""
stats = self.service.get_dashboard_stats(db)
assert "domain" in stats["services_by_type"]
assert "email" in stats["services_by_type"]
def test_dashboard_stats_upcoming_renewals(
self, db, hosted_site, client_service_expiring
):
"""Test upcoming renewals count."""
stats = self.service.get_dashboard_stats(db)
assert stats["upcoming_renewals"] >= 1
def test_dashboard_stats_sites_by_status(
self, db, hosted_site, hosted_site_poc_ready
):
"""Test sites_by_status breakdown."""
stats = self.service.get_dashboard_stats(db)
assert isinstance(stats["sites_by_status"], dict)
total_from_breakdown = sum(stats["sites_by_status"].values())
assert total_from_breakdown == stats["total_sites"]