Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete. Co-authored-by: Cursor <cursoragent@cursor.com>
464 lines
16 KiB
Python
464 lines
16 KiB
Python
"""Architecture tests — Experience module boundary enforcement."""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
from app.models import foundation as models
|
|
from app.models import sites as site_models
|
|
from app.models import components as component_models
|
|
from app.models import themes as theme_models
|
|
from app.models import templates as template_models
|
|
from app.models import localization as localization_models
|
|
from app.models import forms as form_models
|
|
from app.models import publishing as publishing_models
|
|
from app.models import bundles as bundle_models
|
|
from app.models import integrations as integration_models
|
|
from app.models import analytics as analytics_models
|
|
from app.models import ai_hooks as ai_hooks_models
|
|
|
|
|
|
FORBIDDEN_IMPORT_PREFIXES = (
|
|
"backend.services.accounting",
|
|
"backend.services.crm",
|
|
"backend.services.loyalty",
|
|
"backend.services.communication",
|
|
"backend.services.sports_center",
|
|
"backend.services.hospitality",
|
|
"backend.services.delivery",
|
|
"backend.services.automation",
|
|
"backend.services.notification",
|
|
"backend.services.file_storage",
|
|
"backend.services.identity_access",
|
|
"backend.core_service",
|
|
)
|
|
|
|
FOUNDATION_MODELS = [
|
|
models.ExperienceWorkspace,
|
|
models.ExperienceLocaleProfile,
|
|
models.ExperienceRole,
|
|
models.ExperiencePermission,
|
|
models.ExternalProviderConfig,
|
|
models.RenderEngineRegistration,
|
|
models.ExperienceConfiguration,
|
|
models.ExperienceSetting,
|
|
models.ExperienceAuditLog,
|
|
]
|
|
|
|
SITE_MODELS = [
|
|
site_models.ExperienceSite,
|
|
site_models.ExperiencePage,
|
|
site_models.ExperienceSiteDomain,
|
|
]
|
|
|
|
COMPONENT_MODELS = [
|
|
component_models.ExperienceComponent,
|
|
component_models.ExperienceComponentVersion,
|
|
component_models.PageComponentPlacement,
|
|
]
|
|
|
|
THEME_MODELS = [
|
|
theme_models.ExperienceTheme,
|
|
theme_models.ExperienceThemeVersion,
|
|
theme_models.ExperienceLayout,
|
|
theme_models.ExperienceLayoutVersion,
|
|
theme_models.SiteThemeBinding,
|
|
theme_models.PageLayoutBinding,
|
|
]
|
|
|
|
TEMPLATE_MODELS = [
|
|
template_models.ExperienceTemplate,
|
|
template_models.ExperienceTemplateVersion,
|
|
template_models.TemplateInstantiation,
|
|
]
|
|
|
|
LOCALIZATION_MODELS = [
|
|
localization_models.SiteLocaleBinding,
|
|
localization_models.ExperienceLocalization,
|
|
localization_models.ExperienceMediaRef,
|
|
]
|
|
|
|
FORM_MODELS = [
|
|
form_models.ExperienceForm,
|
|
form_models.FormSubmission,
|
|
form_models.ExperienceSurvey,
|
|
form_models.SurveyResponse,
|
|
form_models.ExperienceAppointmentPage,
|
|
form_models.AppointmentBookingRef,
|
|
]
|
|
PUBLISHING_MODELS = [
|
|
publishing_models.PublishRelease,
|
|
publishing_models.DomainEdgeBinding,
|
|
publishing_models.SeoMetadata,
|
|
publishing_models.SitemapShell,
|
|
publishing_models.RobotsShell,
|
|
publishing_models.PwaManifest,
|
|
]
|
|
BUNDLE_MODELS = [
|
|
bundle_models.ExperienceBundleDefinition,
|
|
bundle_models.ExperienceTenantBundle,
|
|
bundle_models.ExperienceLicenseBinding,
|
|
bundle_models.ExperienceFeatureToggle,
|
|
]
|
|
INTEGRATION_MODELS = [
|
|
integration_models.ExperienceConsumerConnectorRegistration,
|
|
integration_models.ExperienceConsumerConnectorDispatch,
|
|
integration_models.ExperienceWidgetDefinition,
|
|
integration_models.ExperienceWidgetInstance,
|
|
integration_models.ExperienceNotifyDispatch,
|
|
]
|
|
|
|
ANALYTICS_MODELS = [
|
|
analytics_models.ExperienceAnalyticsReportDefinition,
|
|
analytics_models.ExperienceAnalyticsSnapshot,
|
|
]
|
|
|
|
AI_HOOKS_MODELS = [
|
|
ai_hooks_models.ExperienceAiContentHookRegistration,
|
|
ai_hooks_models.ExperienceAiContentDispatch,
|
|
]
|
|
|
|
|
|
def test_all_models_have_tenant_id():
|
|
from app.core.database import Base
|
|
import app.models # noqa: F401
|
|
|
|
skip = {"alembic_version"}
|
|
for table in Base.metadata.tables.values():
|
|
if table.name in skip:
|
|
continue
|
|
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
|
|
|
|
|
|
def test_foundation_aggregates_are_independent():
|
|
assert len(FOUNDATION_MODELS) == 9
|
|
for model in FOUNDATION_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_site_aggregates_are_independent():
|
|
assert len(SITE_MODELS) == 3
|
|
for model in SITE_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_component_aggregates_are_independent():
|
|
assert len(COMPONENT_MODELS) == 3
|
|
for model in COMPONENT_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_theme_aggregates_are_independent():
|
|
assert len(THEME_MODELS) == 6
|
|
for model in THEME_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_template_aggregates_are_independent():
|
|
assert len(TEMPLATE_MODELS) == 3
|
|
for model in TEMPLATE_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_localization_aggregates_are_independent():
|
|
assert len(LOCALIZATION_MODELS) == 3
|
|
for model in LOCALIZATION_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_form_aggregates_are_independent():
|
|
assert len(FORM_MODELS) == 6
|
|
for model in FORM_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_publishing_aggregates_are_independent():
|
|
assert len(PUBLISHING_MODELS) == 6
|
|
for model in PUBLISHING_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_bundle_aggregates_are_independent():
|
|
assert len(BUNDLE_MODELS) == 4
|
|
for model in BUNDLE_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "version")
|
|
assert hasattr(model, "is_deleted")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_integration_aggregates_are_independent():
|
|
assert len(INTEGRATION_MODELS) == 5
|
|
for model in INTEGRATION_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "version")
|
|
assert hasattr(model, "is_deleted")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_analytics_aggregates_are_independent():
|
|
assert len(ANALYTICS_MODELS) == 2
|
|
for model in ANALYTICS_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "version")
|
|
assert hasattr(model, "is_deleted")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_ai_hooks_aggregates_are_independent():
|
|
assert len(AI_HOOKS_MODELS) == 2
|
|
for model in AI_HOOKS_MODELS:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "version")
|
|
assert hasattr(model, "is_deleted")
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_no_page_builder_engine_columns():
|
|
forbidden = {
|
|
"page_builder",
|
|
"theme_engine",
|
|
"template_engine",
|
|
"cms_blob",
|
|
"section_tree",
|
|
"layout_engine",
|
|
"forms_engine",
|
|
"survey_engine",
|
|
"booking_engine",
|
|
"calendar_engine",
|
|
"schedule_engine",
|
|
}
|
|
for model in (
|
|
FOUNDATION_MODELS
|
|
+ SITE_MODELS
|
|
+ COMPONENT_MODELS
|
|
+ THEME_MODELS
|
|
+ TEMPLATE_MODELS
|
|
+ LOCALIZATION_MODELS
|
|
+ FORM_MODELS
|
|
+ PUBLISHING_MODELS
|
|
+ BUNDLE_MODELS
|
|
+ INTEGRATION_MODELS
|
|
+ ANALYTICS_MODELS
|
|
+ AI_HOOKS_MODELS
|
|
):
|
|
columns = set(model.__table__.columns.keys())
|
|
assert forbidden.isdisjoint(columns), model.__tablename__
|
|
|
|
|
|
def test_media_ref_has_no_binary_columns():
|
|
columns = set(localization_models.ExperienceMediaRef.__table__.columns.keys())
|
|
forbidden = {
|
|
"binary",
|
|
"blob",
|
|
"bytes",
|
|
"content_bytes",
|
|
"file_bytes",
|
|
"base64",
|
|
"data_uri",
|
|
"raw_content",
|
|
}
|
|
assert forbidden.isdisjoint(columns)
|
|
assert "storage_file_ref" in columns
|
|
|
|
|
|
def test_permissions_defined():
|
|
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
|
|
|
assert "experience.view" in ALL_PERMISSIONS
|
|
assert "experience.workspaces.create" in ALL_PERMISSIONS
|
|
assert "experience.locale_profiles.manage" in ALL_PERMISSIONS
|
|
assert "experience.render_engines.manage" in ALL_PERMISSIONS
|
|
assert "experience.sites.create" in ALL_PERMISSIONS
|
|
assert "experience.pages.manage" in ALL_PERMISSIONS
|
|
assert "experience.domains.view" in ALL_PERMISSIONS
|
|
assert "experience.sites.view" in ALL_PERMISSIONS
|
|
assert "experience.components.create" in ALL_PERMISSIONS
|
|
assert "experience.components.manage" in ALL_PERMISSIONS
|
|
assert "experience.themes.create" in ALL_PERMISSIONS
|
|
assert "experience.layouts.manage" in ALL_PERMISSIONS
|
|
assert "experience.templates.create" in ALL_PERMISSIONS
|
|
assert "experience.templates.manage" in ALL_PERMISSIONS
|
|
assert "experience.locales.create" in ALL_PERMISSIONS
|
|
assert "experience.locales.manage" in ALL_PERMISSIONS
|
|
assert "experience.media_refs.create" in ALL_PERMISSIONS
|
|
assert "experience.media_refs.manage" in ALL_PERMISSIONS
|
|
assert "experience.forms.create" in ALL_PERMISSIONS
|
|
assert "experience.forms.manage" in ALL_PERMISSIONS
|
|
assert "experience.surveys.create" in ALL_PERMISSIONS
|
|
assert "experience.appointments.manage" in ALL_PERMISSIONS
|
|
assert "experience.publishing.manage" in ALL_PERMISSIONS
|
|
assert "experience.seo.manage" in ALL_PERMISSIONS
|
|
assert "experience.pwa.manage" in ALL_PERMISSIONS
|
|
assert "experience.bundles.manage" in ALL_PERMISSIONS
|
|
assert "experience.feature_toggles.manage" in ALL_PERMISSIONS
|
|
assert "experience.consumer_connectors.manage" in ALL_PERMISSIONS
|
|
assert "experience.widgets.manage" in ALL_PERMISSIONS
|
|
assert "experience.notify.manage" in ALL_PERMISSIONS
|
|
assert "experience.analytics.manage" in ALL_PERMISSIONS
|
|
assert "experience.ai_hooks.manage" in ALL_PERMISSIONS
|
|
assert "experience.audit.view" in ALL_PERMISSIONS
|
|
for prefix in PERMISSION_PREFIXES:
|
|
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
|
|
|
|
|
|
def test_events_defined():
|
|
from app.events.types import ExperienceEventType
|
|
|
|
assert ExperienceEventType.WORKSPACE_CREATED.value == "experience.workspace.created"
|
|
assert (
|
|
ExperienceEventType.LOCALE_PROFILE_CREATED.value
|
|
== "experience.locale_profile.created"
|
|
)
|
|
assert (
|
|
ExperienceEventType.RENDER_ENGINE_REGISTERED.value
|
|
== "experience.render_engine.registered"
|
|
)
|
|
assert ExperienceEventType.SITE_CREATED.value == "experience.site.created"
|
|
assert ExperienceEventType.PAGE_PUBLISHED.value == "experience.page.published"
|
|
assert ExperienceEventType.SITE_DOMAIN_BOUND.value == "experience.site_domain.bound"
|
|
assert ExperienceEventType.COMPONENT_CREATED.value == "experience.component.created"
|
|
assert (
|
|
ExperienceEventType.COMPONENT_VERSION_PUBLISHED.value
|
|
== "experience.component_version.published"
|
|
)
|
|
assert (
|
|
ExperienceEventType.PAGE_COMPONENT_PLACED.value
|
|
== "experience.page_component.placed"
|
|
)
|
|
assert ExperienceEventType.THEME_CREATED.value == "experience.theme.created"
|
|
assert ExperienceEventType.LAYOUT_ACTIVATED.value == "experience.layout.activated"
|
|
assert (
|
|
ExperienceEventType.SITE_THEME_ASSIGNED.value
|
|
== "experience.site_theme.assigned"
|
|
)
|
|
assert (
|
|
ExperienceEventType.PAGE_LAYOUT_ASSIGNED.value
|
|
== "experience.page_layout.assigned"
|
|
)
|
|
assert ExperienceEventType.TEMPLATE_CREATED.value == "experience.template.created"
|
|
assert (
|
|
ExperienceEventType.TEMPLATE_VERSION_PUBLISHED.value
|
|
== "experience.template_version.published"
|
|
)
|
|
assert (
|
|
ExperienceEventType.TEMPLATE_INSTANTIATED.value
|
|
== "experience.template.instantiated"
|
|
)
|
|
assert (
|
|
ExperienceEventType.SITE_LOCALE_ASSIGNED.value
|
|
== "experience.site_locale.assigned"
|
|
)
|
|
assert (
|
|
ExperienceEventType.LOCALIZATION_UPSERTED.value
|
|
== "experience.localization.upserted"
|
|
)
|
|
assert ExperienceEventType.MEDIA_REF_CREATED.value == "experience.media_ref.created"
|
|
assert ExperienceEventType.FORM_CREATED.value == "experience.form.created"
|
|
assert (
|
|
ExperienceEventType.FORM_SUBMISSION_RECEIVED.value
|
|
== "experience.form_submission.received"
|
|
)
|
|
assert ExperienceEventType.SURVEY_CREATED.value == "experience.survey.created"
|
|
assert (
|
|
ExperienceEventType.APPOINTMENT_BOOKING_REF_CREATED.value
|
|
== "experience.appointment_booking_ref.created"
|
|
)
|
|
assert ExperienceEventType.PUBLISH_RELEASE_COMPLETED.value == "experience.publish_release.completed"
|
|
assert ExperienceEventType.DOMAIN_EDGE_BOUND.value == "experience.domain_edge.bound"
|
|
assert ExperienceEventType.SEO_METADATA_UPSERTED.value == "experience.seo_metadata.upserted"
|
|
assert ExperienceEventType.PWA_MANIFEST_UPSERTED.value == "experience.pwa_manifest.upserted"
|
|
assert ExperienceEventType.BUNDLE_DEFINITION_CREATED.value == "experience.bundle_definition.created"
|
|
assert ExperienceEventType.FEATURE_TOGGLE_CREATED.value == "experience.feature_toggle.created"
|
|
assert (
|
|
ExperienceEventType.CONSUMER_CONNECTOR_REGISTRATION_CREATED.value
|
|
== "experience.consumer_connector_registration.created"
|
|
)
|
|
assert (
|
|
ExperienceEventType.WIDGET_DEFINITION_CREATED.value
|
|
== "experience.widget_definition.created"
|
|
)
|
|
assert ExperienceEventType.NOTIFY_DISPATCH_SENT.value == "experience.notify_dispatch.sent"
|
|
assert (
|
|
ExperienceEventType.ANALYTICS_SNAPSHOT_REFRESHED.value
|
|
== "experience.analytics_snapshot.refreshed"
|
|
)
|
|
assert (
|
|
ExperienceEventType.AI_CONTENT_DISPATCH_GENERATED.value
|
|
== "experience.ai_content_dispatch.generated"
|
|
)
|
|
|
|
|
|
def test_no_duplicate_event_values():
|
|
from app.events.types import ExperienceEventType
|
|
|
|
values = [e.value for e in ExperienceEventType]
|
|
assert len(values) == len(set(values))
|
|
|
|
|
|
def test_platform_provider_contracts_exist():
|
|
from app.providers import (
|
|
AIProvider,
|
|
AccountingProvider,
|
|
AutomationProvider,
|
|
CRMProvider,
|
|
CommunicationProvider,
|
|
DeliveryProvider,
|
|
HospitalityProvider,
|
|
IdentityProvider,
|
|
LoyaltyProvider,
|
|
MarketplaceProvider,
|
|
NotificationProvider,
|
|
RenderEngineProvider,
|
|
SportsCenterProvider,
|
|
StorageProvider,
|
|
ThemeMarketplaceProvider,
|
|
)
|
|
|
|
assert AccountingProvider is not None
|
|
assert CRMProvider is not None
|
|
assert StorageProvider is not None
|
|
assert HospitalityProvider is not None
|
|
assert MarketplaceProvider is not None
|
|
assert RenderEngineProvider is not None
|
|
assert ThemeMarketplaceProvider is not None
|
|
assert AutomationProvider is not None
|
|
assert DeliveryProvider is not None
|
|
assert SportsCenterProvider is not None
|
|
assert AIProvider is not None
|
|
assert CommunicationProvider is not None
|
|
assert LoyaltyProvider is not None
|
|
assert IdentityProvider is not None
|
|
assert NotificationProvider is not None
|
|
|
|
|
|
def test_no_foreign_service_imports():
|
|
root = Path(__file__).resolve().parents[1]
|
|
for path in root.rglob("*.py"):
|
|
if "tests" in path.parts:
|
|
continue
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
for prefix in FORBIDDEN_IMPORT_PREFIXES:
|
|
assert not alias.name.startswith(prefix), path
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
for prefix in FORBIDDEN_IMPORT_PREFIXES:
|
|
assert not node.module.startswith(prefix), path
|