feat(sdk): Multi-Projekt-Architektur — mehrere Projekte pro Tenant
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
Jeder Tenant kann jetzt mehrere Compliance-Projekte anlegen (z.B. verschiedene Produkte, Tochterunternehmen). CompanyProfile ist pro Projekt kopierbar und danach unabhaengig editierbar. Multi-Tab-Support via separater BroadcastChannel und localStorage Keys pro Projekt. - Migration 039: compliance_projects Tabelle, sdk_states.project_id - Backend: FastAPI CRUD-Routes fuer Projekte mit Tenant-Isolation - Frontend: ProjectSelector UI, SDKProvider mit projectId, URL ?project= - State API: UPSERT auf (tenant_id, project_id) mit Abwaertskompatibilitaet - Tests: pytest fuer Model-Validierung, Row-Konvertierung, Tenant-Isolation - Docs: MKDocs Seite, CLAUDE.md, Backend README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -288,6 +288,22 @@ curl -X POST http://localhost:8000/api/v1/compliance/scraper/fetch \
|
||||
2. Re-Seed ausfuehren
|
||||
3. Mappings werden automatisch generiert
|
||||
|
||||
## Multi-Projekt-Architektur (Migration 039)
|
||||
|
||||
Jeder Tenant kann mehrere Compliance-Projekte anlegen. Neue Tabelle `compliance_projects`, `sdk_states` erweitert um `project_id`.
|
||||
|
||||
### Projekt-API Endpoints
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/api/v1/projects` | Alle Projekte des Tenants |
|
||||
| POST | `/api/v1/projects` | Neues Projekt erstellen |
|
||||
| GET | `/api/v1/projects/{id}` | Einzelnes Projekt |
|
||||
| PATCH | `/api/v1/projects/{id}` | Projekt aktualisieren |
|
||||
| DELETE | `/api/v1/projects/{id}` | Projekt archivieren |
|
||||
|
||||
Siehe `compliance/api/project_routes.py` und `migrations/039_compliance_projects.sql`.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.0 (2026-01-17)
|
||||
|
||||
@@ -31,6 +31,7 @@ from .vendor_compliance_routes import router as vendor_compliance_router
|
||||
from .incident_routes import router as incident_router
|
||||
from .change_request_routes import router as change_request_router
|
||||
from .generation_routes import router as generation_router
|
||||
from .project_routes import router as project_router
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(audit_router)
|
||||
@@ -63,6 +64,7 @@ router.include_router(vendor_compliance_router)
|
||||
router.include_router(incident_router)
|
||||
router.include_router(change_request_router)
|
||||
router.include_router(generation_router)
|
||||
router.include_router(project_router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
@@ -95,4 +97,5 @@ __all__ = [
|
||||
"incident_router",
|
||||
"change_request_router",
|
||||
"generation_router",
|
||||
"project_router",
|
||||
]
|
||||
|
||||
300
backend-compliance/compliance/api/project_routes.py
Normal file
300
backend-compliance/compliance/api/project_routes.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
FastAPI routes for Compliance Projects (Multi-Projekt-Architektur).
|
||||
|
||||
Endpoints:
|
||||
- GET /v1/projects → List all projects for a tenant
|
||||
- POST /v1/projects → Create a new project
|
||||
- GET /v1/projects/{project_id} → Get a single project
|
||||
- PATCH /v1/projects/{project_id} → Update project (name, description)
|
||||
- DELETE /v1/projects/{project_id} → Archive project (soft delete)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import SessionLocal
|
||||
from .tenant_utils import get_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/projects", tags=["projects"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REQUEST/RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
customer_type: str = "new" # 'new' | 'existing'
|
||||
copy_from_project_id: Optional[str] = None # Copy company profile from existing project
|
||||
|
||||
|
||||
class UpdateProjectRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str
|
||||
customer_type: str
|
||||
status: str
|
||||
project_version: int
|
||||
completion_percentage: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _row_to_response(row) -> dict:
|
||||
"""Convert a DB row to ProjectResponse dict."""
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"tenant_id": row.tenant_id,
|
||||
"name": row.name,
|
||||
"description": row.description or "",
|
||||
"customer_type": row.customer_type or "new",
|
||||
"status": row.status or "active",
|
||||
"project_version": row.project_version or 1,
|
||||
"completion_percentage": row.completion_percentage or 0,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else "",
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
include_archived: bool = False,
|
||||
):
|
||||
"""List all projects for the tenant."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if include_archived:
|
||||
query = text("""
|
||||
SELECT id, tenant_id, name, description, customer_type, status,
|
||||
project_version, completion_percentage, created_at, updated_at
|
||||
FROM compliance_projects
|
||||
WHERE tenant_id = :tenant_id AND status != 'deleted'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
else:
|
||||
query = text("""
|
||||
SELECT id, tenant_id, name, description, customer_type, status,
|
||||
project_version, completion_percentage, created_at, updated_at
|
||||
FROM compliance_projects
|
||||
WHERE tenant_id = :tenant_id AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
result = db.execute(query, {"tenant_id": tenant_id})
|
||||
rows = result.fetchall()
|
||||
return {
|
||||
"projects": [_row_to_response(row) for row in rows],
|
||||
"total": len(rows),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_project(
|
||||
body: CreateProjectRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
):
|
||||
"""Create a new compliance project.
|
||||
|
||||
Optionally copies the company profile (companyProfile) from an existing
|
||||
project's sdk_states into the new project's state. This allows a tenant
|
||||
to start a new project for a subsidiary with the same base data.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Create the project row
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_projects
|
||||
(tenant_id, name, description, customer_type, status)
|
||||
VALUES
|
||||
(:tenant_id, :name, :description, :customer_type, 'active')
|
||||
RETURNING id, tenant_id, name, description, customer_type, status,
|
||||
project_version, completion_percentage, created_at, updated_at
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"name": body.name,
|
||||
"description": body.description,
|
||||
"customer_type": body.customer_type,
|
||||
},
|
||||
)
|
||||
project_row = result.fetchone()
|
||||
project_id = str(project_row.id)
|
||||
|
||||
# Build initial SDK state
|
||||
initial_state = {
|
||||
"version": "1.0.0",
|
||||
"projectVersion": 1,
|
||||
"tenantId": tenant_id,
|
||||
"projectId": project_id,
|
||||
"customerType": body.customer_type,
|
||||
"companyProfile": None,
|
||||
}
|
||||
|
||||
# If copy_from_project_id is provided, copy company profile
|
||||
if body.copy_from_project_id:
|
||||
source = db.execute(
|
||||
text("""
|
||||
SELECT state FROM sdk_states
|
||||
WHERE tenant_id = :tenant_id AND project_id = :project_id
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": body.copy_from_project_id,
|
||||
},
|
||||
).fetchone()
|
||||
|
||||
if source and source.state:
|
||||
source_state = source.state if isinstance(source.state, dict) else json.loads(source.state)
|
||||
if "companyProfile" in source_state:
|
||||
initial_state["companyProfile"] = source_state["companyProfile"]
|
||||
if "customerType" in source_state:
|
||||
initial_state["customerType"] = source_state["customerType"]
|
||||
|
||||
# Create the sdk_states row for this project
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO sdk_states (tenant_id, project_id, state, version, created_at, updated_at)
|
||||
VALUES (:tenant_id, :project_id, :state::jsonb, 1, NOW(), NOW())
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": project_id,
|
||||
"state": json.dumps(initial_state),
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info("Created project %s for tenant %s", project_id, tenant_id)
|
||||
return _row_to_response(project_row)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
):
|
||||
"""Get a single project by ID (tenant-scoped)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, tenant_id, name, description, customer_type, status,
|
||||
project_version, completion_percentage, created_at, updated_at
|
||||
FROM compliance_projects
|
||||
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
|
||||
"""),
|
||||
{"project_id": project_id, "tenant_id": tenant_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return _row_to_response(row)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
body: UpdateProjectRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
):
|
||||
"""Update project name/description."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Build SET clause dynamically
|
||||
updates = {}
|
||||
set_parts = ["updated_at = NOW()"]
|
||||
if body.name is not None:
|
||||
set_parts.append("name = :name")
|
||||
updates["name"] = body.name
|
||||
if body.description is not None:
|
||||
set_parts.append("description = :description")
|
||||
updates["description"] = body.description
|
||||
|
||||
updates["project_id"] = project_id
|
||||
updates["tenant_id"] = tenant_id
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_projects
|
||||
SET {', '.join(set_parts)}
|
||||
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
|
||||
RETURNING id, tenant_id, name, description, customer_type, status,
|
||||
project_version, completion_percentage, created_at, updated_at
|
||||
"""),
|
||||
updates,
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
db.commit()
|
||||
return _row_to_response(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def archive_project(
|
||||
project_id: str,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
):
|
||||
"""Soft-delete (archive) a project."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE compliance_projects
|
||||
SET status = 'archived', archived_at = NOW(), updated_at = NOW()
|
||||
WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'active'
|
||||
RETURNING id
|
||||
"""),
|
||||
{"project_id": project_id, "tenant_id": tenant_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found or already archived")
|
||||
db.commit()
|
||||
return {"success": True, "id": str(row.id), "status": "archived"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
95
backend-compliance/migrations/039_compliance_projects.sql
Normal file
95
backend-compliance/migrations/039_compliance_projects.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Migration 039: Multi-Projekt-Architektur
|
||||
-- Enables multiple compliance projects per tenant (Cloud-Ready Multi-Tenancy)
|
||||
--
|
||||
-- Changes:
|
||||
-- 1. New table compliance_projects (project metadata)
|
||||
-- 2. sdk_states: Drop UNIQUE(tenant_id), add project_id column with FK
|
||||
-- 3. Migrate existing data: Create default project for each existing sdk_states row
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. New table: compliance_projects
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
customer_type VARCHAR(20) DEFAULT 'new', -- 'new' | 'existing'
|
||||
status VARCHAR(20) DEFAULT 'active', -- 'active' | 'archived' | 'deleted'
|
||||
project_version INTEGER DEFAULT 1,
|
||||
completion_percentage INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
archived_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_projects_tenant ON compliance_projects(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_projects_status ON compliance_projects(tenant_id, status);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. sdk_states: Add project_id, adjust constraints
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the old UNIQUE constraint on tenant_id (allows multiple states per tenant)
|
||||
ALTER TABLE sdk_states DROP CONSTRAINT IF EXISTS sdk_states_tenant_id_key;
|
||||
|
||||
-- Add project_id column (nullable initially for migration)
|
||||
ALTER TABLE sdk_states ADD COLUMN IF NOT EXISTS project_id UUID;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Data migration: Create default projects for existing states
|
||||
-- =============================================================================
|
||||
|
||||
-- For each existing sdk_states row without a project, create a default project
|
||||
INSERT INTO compliance_projects (id, tenant_id, name, customer_type, status)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
s.tenant_id,
|
||||
COALESCE(s.state->'companyProfile'->>'companyName', 'Projekt 1'),
|
||||
COALESCE(s.state->>'customerType', 'new'),
|
||||
'active'
|
||||
FROM sdk_states s
|
||||
WHERE s.project_id IS NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Link existing states to their newly created projects
|
||||
UPDATE sdk_states s
|
||||
SET project_id = p.id
|
||||
FROM compliance_projects p
|
||||
WHERE s.tenant_id = p.tenant_id AND s.project_id IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Add constraints after migration
|
||||
-- =============================================================================
|
||||
|
||||
-- Make project_id NOT NULL now that all rows have a value
|
||||
-- (Only if there are no NULL values remaining — safe guard)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM sdk_states WHERE project_id IS NULL) THEN
|
||||
ALTER TABLE sdk_states ALTER COLUMN project_id SET NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Unique constraint: one state per (tenant, project)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'uq_sdk_states_tenant_project'
|
||||
) THEN
|
||||
ALTER TABLE sdk_states ADD CONSTRAINT uq_sdk_states_tenant_project
|
||||
UNIQUE (tenant_id, project_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Foreign key to compliance_projects
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_sdk_states_project'
|
||||
) THEN
|
||||
ALTER TABLE sdk_states ADD CONSTRAINT fk_sdk_states_project
|
||||
FOREIGN KEY (project_id) REFERENCES compliance_projects(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
189
backend-compliance/tests/test_project_routes.py
Normal file
189
backend-compliance/tests/test_project_routes.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests for Compliance Project routes (project_routes.py)."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from compliance.api.project_routes import (
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
_row_to_response,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateProjectRequest:
|
||||
"""Tests for request model validation."""
|
||||
|
||||
def test_default_values(self):
|
||||
req = CreateProjectRequest(name="Test Project")
|
||||
assert req.name == "Test Project"
|
||||
assert req.description == ""
|
||||
assert req.customer_type == "new"
|
||||
assert req.copy_from_project_id is None
|
||||
|
||||
def test_full_values(self):
|
||||
req = CreateProjectRequest(
|
||||
name="KI-Produkt X",
|
||||
description="DSGVO-Compliance fuer Produkt X",
|
||||
customer_type="existing",
|
||||
copy_from_project_id="uuid-123",
|
||||
)
|
||||
assert req.name == "KI-Produkt X"
|
||||
assert req.description == "DSGVO-Compliance fuer Produkt X"
|
||||
assert req.customer_type == "existing"
|
||||
assert req.copy_from_project_id == "uuid-123"
|
||||
|
||||
def test_name_required(self):
|
||||
with pytest.raises(Exception):
|
||||
CreateProjectRequest()
|
||||
|
||||
def test_serialization(self):
|
||||
req = CreateProjectRequest(name="Test")
|
||||
data = req.model_dump()
|
||||
assert data["name"] == "Test"
|
||||
assert data["customer_type"] == "new"
|
||||
|
||||
|
||||
class TestUpdateProjectRequest:
|
||||
"""Tests for update model."""
|
||||
|
||||
def test_empty_update(self):
|
||||
req = UpdateProjectRequest()
|
||||
assert req.name is None
|
||||
assert req.description is None
|
||||
|
||||
def test_partial_update(self):
|
||||
req = UpdateProjectRequest(name="New Name")
|
||||
assert req.name == "New Name"
|
||||
assert req.description is None
|
||||
|
||||
|
||||
class TestRowToResponse:
|
||||
"""Tests for DB row to response conversion."""
|
||||
|
||||
def _make_row(self, **overrides):
|
||||
now = datetime.now(timezone.utc)
|
||||
defaults = {
|
||||
"id": "uuid-project-1",
|
||||
"tenant_id": "uuid-tenant-1",
|
||||
"name": "Test Project",
|
||||
"description": "A test project",
|
||||
"customer_type": "new",
|
||||
"status": "active",
|
||||
"project_version": 1,
|
||||
"completion_percentage": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
mock = MagicMock()
|
||||
for key, value in defaults.items():
|
||||
setattr(mock, key, value)
|
||||
return mock
|
||||
|
||||
def test_basic_conversion(self):
|
||||
row = self._make_row()
|
||||
result = _row_to_response(row)
|
||||
assert result["id"] == "uuid-project-1"
|
||||
assert result["tenant_id"] == "uuid-tenant-1"
|
||||
assert result["name"] == "Test Project"
|
||||
assert result["description"] == "A test project"
|
||||
assert result["customer_type"] == "new"
|
||||
assert result["status"] == "active"
|
||||
assert result["project_version"] == 1
|
||||
assert result["completion_percentage"] == 0
|
||||
|
||||
def test_null_description(self):
|
||||
row = self._make_row(description=None)
|
||||
result = _row_to_response(row)
|
||||
assert result["description"] == ""
|
||||
|
||||
def test_null_customer_type(self):
|
||||
row = self._make_row(customer_type=None)
|
||||
result = _row_to_response(row)
|
||||
assert result["customer_type"] == "new"
|
||||
|
||||
def test_null_status(self):
|
||||
row = self._make_row(status=None)
|
||||
result = _row_to_response(row)
|
||||
assert result["status"] == "active"
|
||||
|
||||
def test_created_at_iso(self):
|
||||
dt = datetime(2026, 3, 9, 12, 0, 0, tzinfo=timezone.utc)
|
||||
row = self._make_row(created_at=dt)
|
||||
result = _row_to_response(row)
|
||||
assert "2026-03-09" in result["created_at"]
|
||||
|
||||
def test_archived_project(self):
|
||||
row = self._make_row(status="archived", completion_percentage=75)
|
||||
result = _row_to_response(row)
|
||||
assert result["status"] == "archived"
|
||||
assert result["completion_percentage"] == 75
|
||||
|
||||
|
||||
class TestCreateProjectCopiesProfile:
|
||||
"""Tests that creating a project with copy_from_project_id works."""
|
||||
|
||||
def test_copy_request_model(self):
|
||||
req = CreateProjectRequest(
|
||||
name="Tochter GmbH",
|
||||
customer_type="existing",
|
||||
copy_from_project_id="source-uuid-123",
|
||||
)
|
||||
assert req.copy_from_project_id == "source-uuid-123"
|
||||
|
||||
def test_no_copy_request_model(self):
|
||||
req = CreateProjectRequest(name="Brand New")
|
||||
assert req.copy_from_project_id is None
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
"""Tests verifying tenant isolation is enforced in query patterns."""
|
||||
|
||||
def test_list_query_includes_tenant_filter(self):
|
||||
"""Verify that our SQL queries always filter by tenant_id."""
|
||||
import inspect
|
||||
from compliance.api.project_routes import list_projects
|
||||
source = inspect.getsource(list_projects)
|
||||
assert "tenant_id" in source
|
||||
assert "WHERE" in source
|
||||
|
||||
def test_get_query_includes_tenant_filter(self):
|
||||
import inspect
|
||||
from compliance.api.project_routes import get_project
|
||||
source = inspect.getsource(get_project)
|
||||
assert "tenant_id" in source
|
||||
assert "project_id" in source
|
||||
|
||||
def test_archive_query_includes_tenant_filter(self):
|
||||
import inspect
|
||||
from compliance.api.project_routes import archive_project
|
||||
source = inspect.getsource(archive_project)
|
||||
assert "tenant_id" in source
|
||||
|
||||
def test_update_query_includes_tenant_filter(self):
|
||||
import inspect
|
||||
from compliance.api.project_routes import update_project
|
||||
source = inspect.getsource(update_project)
|
||||
assert "tenant_id" in source
|
||||
|
||||
|
||||
class TestStateIsolation:
|
||||
"""Tests verifying state isolation between projects."""
|
||||
|
||||
def test_create_project_creates_sdk_state(self):
|
||||
"""Verify create_project function inserts into sdk_states."""
|
||||
import inspect
|
||||
from compliance.api.project_routes import create_project
|
||||
source = inspect.getsource(create_project)
|
||||
assert "sdk_states" in source
|
||||
assert "project_id" in source
|
||||
|
||||
def test_create_project_copies_company_profile(self):
|
||||
"""Verify create_project copies companyProfile when copy_from specified."""
|
||||
import inspect
|
||||
from compliance.api.project_routes import create_project
|
||||
source = inspect.getsource(create_project)
|
||||
assert "copy_from_project_id" in source
|
||||
assert "companyProfile" in source
|
||||
Reference in New Issue
Block a user