feat: complete modular platform architecture (Phases 1-5)

Phase 1 - Vendor Router Integration:
- Wire up vendor module routers in app/api/v1/vendor/__init__.py
- Use lazy imports via __getattr__ to avoid circular dependencies

Phase 2 - Extract Remaining Modules:
- Create 6 new module directories: customers, cms, analytics, messaging,
  dev_tools, monitoring
- Each module has definition.py and route wrappers
- Update registry to import from extracted modules

Phase 3 - Database Table Migration:
- Add PlatformModule junction table for auditable module tracking
- Add migration zc2m3n4o5p6q7_add_platform_modules_table.py
- Add modules relationship to Platform model
- Update ModuleService with JSON-to-junction-table migration

Phase 4 - Module-Specific Configuration UI:
- Add /api/v1/admin/module-config/* endpoints
- Add module-config.html template and JS

Phase 5 - Integration Tests:
- Add tests/fixtures/module_fixtures.py
- Add tests/integration/api/v1/admin/test_modules.py
- Add tests/integration/api/v1/modules/test_module_access.py

Architecture fixes:
- Fix JS-003 errors: use ...data() directly in Alpine components
- Fix JS-005 warnings: add init() guards to prevent duplicate init
- Fix API-001 errors: add MenuActionResponse Pydantic model
- Add FE-008 noqa for dynamic number input in template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 18:19:00 +01:00
parent f29f1113cd
commit c419090531
55 changed files with 4059 additions and 206 deletions

159
tests/fixtures/module_fixtures.py vendored Normal file
View File

@@ -0,0 +1,159 @@
# tests/fixtures/module_fixtures.py
"""
Module system test fixtures.
Provides fixtures for testing platform module enablement, configuration,
and access control.
"""
import uuid
from datetime import datetime, timezone
import pytest
from models.database.platform import Platform
from models.database.platform_module import PlatformModule
@pytest.fixture
def platform_with_modules(db, test_super_admin):
"""Create a test platform with specific modules enabled."""
unique_id = str(uuid.uuid4())[:8]
platform = Platform(
code=f"modtest_{unique_id}",
name=f"Module Test Platform {unique_id}",
description="A test platform with module configuration",
path_prefix=f"modtest{unique_id}",
is_active=True,
is_public=True,
default_language="en",
supported_languages=["en", "fr"],
)
db.add(platform)
db.flush()
# Enable specific modules via junction table
enabled_modules = ["billing", "inventory", "orders"]
for module_code in enabled_modules:
pm = PlatformModule(
platform_id=platform.id,
module_code=module_code,
is_enabled=True,
enabled_at=datetime.now(timezone.utc),
enabled_by_user_id=test_super_admin.id,
config={},
)
db.add(pm)
# Add a disabled module
pm_disabled = PlatformModule(
platform_id=platform.id,
module_code="marketplace",
is_enabled=False,
disabled_at=datetime.now(timezone.utc),
disabled_by_user_id=test_super_admin.id,
config={},
)
db.add(pm_disabled)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def platform_with_config(db, test_super_admin):
"""Create a test platform with module configuration."""
unique_id = str(uuid.uuid4())[:8]
platform = Platform(
code=f"cfgtest_{unique_id}",
name=f"Config Test Platform {unique_id}",
description="A test platform with module config",
path_prefix=f"cfgtest{unique_id}",
is_active=True,
is_public=True,
default_language="en",
supported_languages=["en"],
)
db.add(platform)
db.flush()
# Add module with configuration
pm = PlatformModule(
platform_id=platform.id,
module_code="billing",
is_enabled=True,
enabled_at=datetime.now(timezone.utc),
enabled_by_user_id=test_super_admin.id,
config={
"stripe_mode": "test",
"default_trial_days": 30,
"allow_free_tier": True,
},
)
db.add(pm)
# Add inventory module with config
pm_inv = PlatformModule(
platform_id=platform.id,
module_code="inventory",
is_enabled=True,
enabled_at=datetime.now(timezone.utc),
enabled_by_user_id=test_super_admin.id,
config={
"low_stock_threshold": 5,
"enable_locations": True,
},
)
db.add(pm_inv)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def platform_all_modules_disabled(db, test_super_admin):
"""Create a test platform with all optional modules disabled."""
unique_id = str(uuid.uuid4())[:8]
platform = Platform(
code=f"nomod_{unique_id}",
name=f"No Modules Platform {unique_id}",
description="A test platform with minimal modules",
path_prefix=f"nomod{unique_id}",
is_active=True,
is_public=True,
default_language="en",
supported_languages=["en"],
settings={"enabled_modules": []}, # Legacy format for testing
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def module_factory(db, test_super_admin):
"""Factory for creating PlatformModule records."""
def _create_module(
platform_id: int,
module_code: str,
is_enabled: bool = True,
config: dict | None = None,
):
pm = PlatformModule(
platform_id=platform_id,
module_code=module_code,
is_enabled=is_enabled,
enabled_at=datetime.now(timezone.utc) if is_enabled else None,
enabled_by_user_id=test_super_admin.id if is_enabled else None,
disabled_at=None if is_enabled else datetime.now(timezone.utc),
disabled_by_user_id=None if is_enabled else test_super_admin.id,
config=config or {},
)
db.add(pm)
db.commit()
db.refresh(pm)
return pm
return _create_module

View File

@@ -0,0 +1,328 @@
# tests/integration/api/v1/admin/test_modules.py
"""
Integration tests for admin module management endpoints.
Tests the /api/v1/admin/modules/* and /api/v1/admin/module-config/* endpoints.
All endpoints require super admin access.
"""
import pytest
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.modules
class TestAdminModulesAPI:
"""Tests for admin module management endpoints."""
# ========================================================================
# List Modules Tests
# ========================================================================
def test_list_all_modules(self, client, super_admin_headers):
"""Test super admin listing all modules."""
response = client.get("/api/v1/admin/modules", headers=super_admin_headers)
assert response.status_code == 200
data = response.json()
assert "modules" in data
assert "total" in data
assert data["total"] >= 10 # At least 10 modules defined
# Check expected modules exist
module_codes = [m["code"] for m in data["modules"]]
assert "core" in module_codes
assert "billing" in module_codes
assert "inventory" in module_codes
def test_list_modules_requires_super_admin(self, client, admin_headers):
"""Test that listing modules requires super admin."""
response = client.get("/api/v1/admin/modules", headers=admin_headers)
# Should require super admin
assert response.status_code == 403
def test_list_modules_unauthenticated(self, client):
"""Test that listing modules requires authentication."""
response = client.get("/api/v1/admin/modules")
assert response.status_code == 401
# ========================================================================
# Get Platform Modules Tests
# ========================================================================
def test_get_platform_modules(self, client, super_admin_headers, test_platform):
"""Test getting modules for a specific platform."""
response = client.get(
f"/api/v1/admin/modules/platforms/{test_platform.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["platform_id"] == test_platform.id
assert data["platform_code"] == test_platform.code
assert "modules" in data
assert "enabled" in data
assert "disabled" in data
def test_get_platform_modules_not_found(self, client, super_admin_headers):
"""Test getting modules for non-existent platform."""
response = client.get(
"/api/v1/admin/modules/platforms/99999",
headers=super_admin_headers,
)
assert response.status_code == 404
# ========================================================================
# Enable/Disable Module Tests
# ========================================================================
def test_enable_module(self, client, super_admin_headers, test_platform, db):
"""Test enabling a module for a platform."""
# First disable the module via settings
test_platform.settings = {"enabled_modules": ["core", "platform-admin"]}
db.commit()
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/enable",
headers=super_admin_headers,
json={"module_code": "billing"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "billing" in data["message"].lower() or "enabled" in data["message"].lower()
def test_disable_module(self, client, super_admin_headers, test_platform, db):
"""Test disabling a module for a platform."""
# Ensure module is enabled
test_platform.settings = {"enabled_modules": ["billing", "inventory"]}
db.commit()
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/disable",
headers=super_admin_headers,
json={"module_code": "billing"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
def test_cannot_disable_core_module(self, client, super_admin_headers, test_platform):
"""Test that core modules cannot be disabled."""
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/disable",
headers=super_admin_headers,
json={"module_code": "core"},
)
assert response.status_code == 400
data = response.json()
assert "core" in data.get("message", "").lower() or "cannot" in data.get("message", "").lower()
def test_enable_invalid_module(self, client, super_admin_headers, test_platform):
"""Test enabling a non-existent module."""
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/enable",
headers=super_admin_headers,
json={"module_code": "invalid_module"},
)
assert response.status_code == 400
# ========================================================================
# Bulk Operations Tests
# ========================================================================
def test_update_platform_modules(self, client, super_admin_headers, test_platform):
"""Test updating all enabled modules at once."""
response = client.put(
f"/api/v1/admin/modules/platforms/{test_platform.id}",
headers=super_admin_headers,
json={"module_codes": ["billing", "inventory", "orders"]},
)
assert response.status_code == 200
data = response.json()
assert data["platform_id"] == test_platform.id
# Check that specified modules are enabled
enabled_codes = [m["code"] for m in data["modules"] if m["is_enabled"]]
assert "billing" in enabled_codes
assert "inventory" in enabled_codes
assert "orders" in enabled_codes
# Core modules should always be enabled
assert "core" in enabled_codes
def test_enable_all_modules(self, client, super_admin_headers, test_platform):
"""Test enabling all modules for a platform."""
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/enable-all",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["enabled_count"] >= 10
def test_disable_optional_modules(self, client, super_admin_headers, test_platform):
"""Test disabling all optional modules."""
response = client.post(
f"/api/v1/admin/modules/platforms/{test_platform.id}/disable-optional",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "core" in data["core_modules"]
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.modules
class TestAdminModuleConfigAPI:
"""Tests for admin module configuration endpoints."""
# ========================================================================
# Get Module Config Tests
# ========================================================================
def test_get_module_config(self, client, super_admin_headers, test_platform):
"""Test getting module configuration."""
response = client.get(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["module_code"] == "billing"
assert "config" in data
assert "schema_info" in data
assert "defaults" in data
def test_get_module_config_has_defaults(self, client, super_admin_headers, test_platform):
"""Test that module config includes default values."""
response = client.get(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
# Should have default billing config
assert "stripe_mode" in data["config"]
assert "default_trial_days" in data["config"]
def test_get_module_config_invalid_module(self, client, super_admin_headers, test_platform):
"""Test getting config for invalid module."""
response = client.get(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/invalid_module/config",
headers=super_admin_headers,
)
assert response.status_code == 400
# ========================================================================
# Update Module Config Tests
# ========================================================================
def test_update_module_config(self, client, super_admin_headers, test_platform):
"""Test updating module configuration."""
response = client.put(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config",
headers=super_admin_headers,
json={
"config": {
"stripe_mode": "live",
"default_trial_days": 7,
}
},
)
assert response.status_code == 200
data = response.json()
assert data["config"]["stripe_mode"] == "live"
assert data["config"]["default_trial_days"] == 7
def test_update_module_config_persists(self, client, super_admin_headers, test_platform):
"""Test that config updates persist across requests."""
# Update config
client.put(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/inventory/config",
headers=super_admin_headers,
json={
"config": {
"low_stock_threshold": 25,
}
},
)
# Fetch again
response = client.get(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/inventory/config",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["config"]["low_stock_threshold"] == 25
# ========================================================================
# Reset Config Tests
# ========================================================================
def test_reset_module_config(self, client, super_admin_headers, test_platform):
"""Test resetting module config to defaults."""
# First set custom config
client.put(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config",
headers=super_admin_headers,
json={
"config": {
"stripe_mode": "live",
"default_trial_days": 1,
}
},
)
# Reset to defaults
response = client.post(
f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/reset",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Config should be reset to defaults
assert data["config"]["stripe_mode"] == "test"
assert data["config"]["default_trial_days"] == 14
# ========================================================================
# Get Defaults Tests
# ========================================================================
def test_get_config_defaults(self, client, super_admin_headers):
"""Test getting default config for a module."""
response = client.get(
"/api/v1/admin/module-config/defaults/billing",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["module_code"] == "billing"
assert "defaults" in data
assert "schema_info" in data
assert data["defaults"]["stripe_mode"] == "test"

View File

@@ -0,0 +1,2 @@
# tests/integration/api/v1/modules/__init__.py
"""Integration tests for module access control."""

View File

@@ -0,0 +1,253 @@
# tests/integration/api/v1/modules/test_module_access.py
"""
Integration tests for module-based access control.
Tests verify that:
- Disabled modules return 403 Forbidden
- Enabled modules allow access
- Core modules are always accessible
- Module dependencies are enforced
"""
import pytest
from models.database.platform import Platform
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.modules
class TestModuleAccessControl:
"""Tests for module-based access control on API endpoints."""
# ========================================================================
# Billing Module Access Tests
# ========================================================================
def test_billing_accessible_when_enabled(
self, client, auth_headers, test_vendor, db
):
"""Test billing endpoints accessible when module enabled."""
# Ensure billing module is enabled (default - no config means all enabled)
response = client.get(
"/api/v1/vendor/billing/subscription",
headers=auth_headers,
)
# Should succeed (200) or have other error, but NOT 403 for module
assert response.status_code != 403 or "module" not in response.json().get("message", "").lower()
def test_billing_forbidden_when_disabled(
self, client, auth_headers, test_vendor, db, test_platform
):
"""Test billing endpoints return 403 when module disabled."""
# Disable billing module
test_platform.settings = {"enabled_modules": ["core", "platform-admin", "inventory"]}
db.commit()
response = client.get(
"/api/v1/vendor/billing/subscription",
headers=auth_headers,
)
# Should return 403 with module disabled message
assert response.status_code == 403
data = response.json()
assert "module" in data.get("message", "").lower() or data.get("error_code") == "MODULE_DISABLED"
# ========================================================================
# Inventory Module Access Tests
# ========================================================================
def test_inventory_accessible_when_enabled(
self, client, auth_headers, test_inventory
):
"""Test inventory endpoints accessible when module enabled."""
response = client.get(
"/api/v1/vendor/inventory",
headers=auth_headers,
)
# Should succeed
assert response.status_code == 200
def test_inventory_forbidden_when_disabled(
self, client, auth_headers, db, test_platform
):
"""Test inventory endpoints return 403 when module disabled."""
# Disable inventory module
test_platform.settings = {"enabled_modules": ["core", "platform-admin", "billing"]}
db.commit()
response = client.get(
"/api/v1/vendor/inventory",
headers=auth_headers,
)
# Should return 403
assert response.status_code == 403
data = response.json()
assert "module" in data.get("message", "").lower() or data.get("error_code") == "MODULE_DISABLED"
# ========================================================================
# Orders Module Access Tests
# ========================================================================
def test_orders_accessible_when_enabled(
self, client, auth_headers, test_order
):
"""Test orders endpoints accessible when module enabled."""
response = client.get(
"/api/v1/vendor/orders",
headers=auth_headers,
)
# Should succeed
assert response.status_code == 200
def test_orders_forbidden_when_disabled(
self, client, auth_headers, db, test_platform
):
"""Test orders endpoints return 403 when module disabled."""
# Disable orders module
test_platform.settings = {"enabled_modules": ["core", "platform-admin"]}
db.commit()
response = client.get(
"/api/v1/vendor/orders",
headers=auth_headers,
)
# Should return 403
assert response.status_code == 403
# ========================================================================
# Marketplace Module Access Tests
# ========================================================================
def test_marketplace_accessible_when_enabled(
self, client, auth_headers
):
"""Test marketplace endpoints accessible when module enabled."""
response = client.get(
"/api/v1/vendor/marketplace/settings",
headers=auth_headers,
)
# Should not return 403 for module disabled
# (might be 404 if no settings exist, or 200)
assert response.status_code != 403 or "module" not in response.json().get("message", "").lower()
def test_marketplace_forbidden_when_disabled(
self, client, auth_headers, db, test_platform
):
"""Test marketplace endpoints return 403 when module disabled."""
# Disable marketplace module but keep inventory (its dependency)
test_platform.settings = {"enabled_modules": ["core", "platform-admin", "inventory"]}
db.commit()
response = client.get(
"/api/v1/vendor/marketplace/settings",
headers=auth_headers,
)
# Should return 403
assert response.status_code == 403
# ========================================================================
# Core Module Tests
# ========================================================================
def test_core_always_accessible(
self, client, auth_headers, db, test_platform
):
"""Test core endpoints always accessible even with empty modules."""
# Set empty module list (but core is always added)
test_platform.settings = {"enabled_modules": []}
db.commit()
# Dashboard is a core endpoint
response = client.get(
"/api/v1/vendor/dashboard",
headers=auth_headers,
)
# Should NOT return 403 for module disabled
assert response.status_code != 403 or "module" not in response.json().get("message", "").lower()
# ========================================================================
# Admin Module Access Tests
# ========================================================================
def test_admin_inventory_accessible_when_enabled(
self, client, admin_headers, test_inventory
):
"""Test admin inventory endpoints accessible when module enabled."""
response = client.get(
"/api/v1/admin/inventory",
headers=admin_headers,
)
# Should succeed
assert response.status_code == 200
def test_admin_inventory_forbidden_when_disabled(
self, client, admin_headers, db, test_platform
):
"""Test admin inventory endpoints return 403 when module disabled."""
# Disable inventory module
test_platform.settings = {"enabled_modules": ["core", "platform-admin"]}
db.commit()
response = client.get(
"/api/v1/admin/inventory",
headers=admin_headers,
)
# Should return 403
assert response.status_code == 403
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.modules
class TestModuleDependencyAccess:
"""Tests for module dependency enforcement in access control."""
def test_marketplace_requires_inventory(
self, client, auth_headers, db, test_platform
):
"""Test marketplace requires inventory to be enabled."""
# Enable marketplace but disable inventory
test_platform.settings = {"enabled_modules": ["marketplace"]}
db.commit()
# Due to dependency resolution, inventory should be auto-enabled
response = client.get(
"/api/v1/vendor/inventory",
headers=auth_headers,
)
# Should be accessible because marketplace depends on inventory
# The module service should auto-enable inventory
assert response.status_code != 403 or "module" not in response.json().get("message", "").lower()
def test_disabling_dependency_disables_dependent(
self, client, auth_headers, db, test_platform
):
"""Test that disabling a dependency also affects dependent modules."""
# First enable both
test_platform.settings = {"enabled_modules": ["inventory", "marketplace"]}
db.commit()
# Now disable inventory - marketplace should also be affected
test_platform.settings = {"enabled_modules": []} # Only core remains
db.commit()
# Marketplace should be disabled
response = client.get(
"/api/v1/vendor/marketplace/settings",
headers=auth_headers,
)
assert response.status_code == 403