landvex: Fixar och tester klara för alla komponenter

- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+1
View File
@@ -0,0 +1 @@
# Tests package
+147
View File
@@ -0,0 +1,147 @@
"""
Pytest fixtures för LandveX Admin Backend.
"""
import asyncio
import os
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex_test")
os.environ.setdefault("SECRET_KEY", "test-secret-key-landvex-2026")
from app.main import app
from app.database import Base, get_db
from app.models import Tenant, User, UserRole, UserStatus, TenantType, TenantStatus
from app.security import get_password_hash
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_async_engine(
TEST_DATABASE_URL,
pool_pre_ping=True,
future=True,
)
TestSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def override_get_db():
async with TestSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
app.dependency_overrides[get_db] = override_get_db
@pytest_asyncio.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""Skapa och tömma test-databasen."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db_session():
"""Skapa en färsk session per test."""
async with TestSessionLocal() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client():
"""Async HTTP client för API-tester."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest_asyncio.fixture
async def test_tenant(db_session: AsyncSession):
"""Skapa en test-tenant."""
tenant = Tenant(
id=uuid.uuid4(),
name="Test Municipality",
slug="test-municipality",
tenant_type=TenantType.MUNICIPALITY,
status=TenantStatus.ACTIVE,
schema_name="tenant_test_municipality",
contact_email="test@kommun.se",
city="Stockholm",
country="SE",
plan="basic",
max_users=10,
max_projects=5,
storage_quota_mb=1024,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
db_session.add(tenant)
await db_session.commit()
await db_session.refresh(tenant)
return tenant
@pytest_asyncio.fixture
async def admin_user(db_session: AsyncSession, test_tenant):
"""Skapa en admin-användare."""
user = User(
id=uuid.uuid4(),
email="admin@landvex.se",
hashed_password=get_password_hash("Admin123!"),
first_name="Admin",
last_name="User",
role=UserRole.ADMIN,
status=UserStatus.ACTIVE,
is_superuser=True,
tenant_id=test_tenant.id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def admin_token(admin_user):
"""Generera JWT-token för admin."""
from app.security import create_access_token
return create_access_token(str(admin_user.id))
@pytest_asyncio.fixture
async def auth_headers(admin_token):
"""Headers med Bearer-token för autentiserade requests."""
return {"Authorization": f"Bearer {admin_token}"}
+128
View File
@@ -0,0 +1,128 @@
"""
Tester för Audit Log endpoints.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog, AuditAction
from app.services.audit_service import AuditService
@pytest.mark.asyncio
async def test_list_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
# Skapa några audit-loggar
audit = AuditService(db_session)
await audit.log(
action=AuditAction.CREATE,
entity_type="tenant",
entity_id="test-tenant-1",
actor_id="admin-user",
new_values={"name": "Test Tenant"},
)
await audit.log(
action=AuditAction.UPDATE,
entity_type="tenant",
entity_id="test-tenant-1",
actor_id="admin-user",
changed_fields=["name"],
)
await db_session.commit()
response = await client.get("/api/v1/audit-logs", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert len(data["items"]) >= 2
@pytest.mark.asyncio
async def test_list_audit_logs_filtered_by_entity(
client: AsyncClient, auth_headers: dict, db_session: AsyncSession
):
audit = AuditService(db_session)
await audit.log(
action=AuditAction.CREATE,
entity_type="user",
entity_id="user-123",
actor_id="admin-user",
)
await db_session.commit()
response = await client.get(
"/api/v1/audit-logs?entity_type=user&entity_id=user-123",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert item["entity_type"] == "user"
assert item["entity_id"] == "user-123"
@pytest.mark.asyncio
async def test_get_entity_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
audit = AuditService(db_session)
await audit.log(
action=AuditAction.CREATE,
entity_type="tenant",
entity_id="entity-test-1",
actor_id="admin-user",
new_values={"name": "Entity Test"},
)
await db_session.commit()
response = await client.get(
"/api/v1/audit-logs/entity/tenant/entity-test-1",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) >= 1
assert data["items"][0]["entity_type"] == "tenant"
@pytest.mark.asyncio
async def test_get_actor_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
audit = AuditService(db_session)
await audit.log(
action=AuditAction.LOGIN,
entity_type="session",
entity_id="session-1",
actor_id="test-actor-1",
)
await db_session.commit()
response = await client.get(
"/api/v1/audit-logs/actor/test-actor-1",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert item["actor_id"] == "test-actor-1"
@pytest.mark.asyncio
async def test_audit_log_sanitization(db_session: AsyncSession):
"""Testa att känslig data saniteras i audit-loggar."""
audit = AuditService(db_session)
log = await audit.log(
action=AuditAction.CREATE,
entity_type="user",
entity_id="user-1",
request_body='{"email": "test@test.se", "password": "supersecret123", "api_key": "abc123"}',
)
await db_session.commit()
assert "supersecret123" not in (log.request_body or "")
assert "***REDACTED***" in (log.request_body or "")
assert "abc123" not in (log.request_body or "")
@pytest.mark.asyncio
async def test_audit_log_unauthorized(client: AsyncClient):
response = await client.get("/api/v1/audit-logs")
assert response.status_code == 403
+51
View File
@@ -0,0 +1,51 @@
"""Tester för auth-endpoints."""
import pytest
from httpx import AsyncClient
from app.models import User
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient, test_user: User):
response = await client.post("/api/v1/auth/login", json={
"email": test_user.email,
"password": "password123",
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(client: AsyncClient, test_user: User):
response = await client.post("/api/v1/auth/login", json={
"email": test_user.email,
"password": "wrongpassword",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_me_endpoint(client: AsyncClient, admin_token: str, test_user: User):
response = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["role"] == "admin"
@pytest.mark.asyncio
async def test_refresh_token(client: AsyncClient, admin_token: str):
# Hämta refresh token via login
response = await client.post("/api/v1/auth/login", json={
"email": "admin@landvex.se",
"password": "password123",
})
refresh = response.json()["refresh_token"]
response = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
@@ -0,0 +1,65 @@
"""Tester för invitation-flödet."""
import pytest
from httpx import AsyncClient
from app.models import User
@pytest.mark.asyncio
async def test_create_invitation(client: AsyncClient, admin_token: str):
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
"email": "invited@landvex.se",
"role": "viewer",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "invited@landvex.se"
assert data["role"] == "viewer"
assert "token" in data
@pytest.mark.asyncio
async def test_validate_invitation(client: AsyncClient, admin_token: str):
# Skapa inbjudan
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
"email": "validate@landvex.se",
"role": "analyst",
})
token = response.json()["token"]
response = await client.get(f"/api/v1/invitations/validate/{token}")
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert data["email"] == "validate@landvex.se"
@pytest.mark.asyncio
async def test_accept_invitation(client: AsyncClient, admin_token: str):
# Skapa inbjudan
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
"email": "accept@landvex.se",
"role": "manager",
})
token = response.json()["token"]
response = await client.post("/api/v1/invitations/accept", json={
"token": token,
"first_name": "Accepted",
"last_name": "User",
"password": "newpassword123",
})
assert response.status_code == 200
data = response.json()
assert data["email"] == "accept@landvex.se"
assert data["role"] == "manager"
assert data["status"] == "active"
# Försök acceptera igen — ska misslyckas
response = await client.post("/api/v1/invitations/accept", json={
"token": token,
"first_name": "Accepted",
"last_name": "User",
"password": "newpassword123",
})
assert response.status_code == 400
+354
View File
@@ -0,0 +1,354 @@
"""
Tester för Tenant Management endpoints.
"""
import uuid
from datetime import datetime, timezone
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Tenant, TenantStatus, TenantType, AuditLog, AuditAction
# ─── CREATE ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_tenant(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/v1/tenants",
json={
"name": "Nya Kommunen",
"slug": "nya-kommunen",
"tenant_type": "municipality",
"contact_email": "kontakt@nyakommun.se",
"city": "Göteborg",
"country": "SE",
"plan": "professional",
"max_users": 50,
},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Nya Kommunen"
assert data["slug"] == "nya-kommunen"
assert data["status"] == "pending"
assert data["schema_name"] == "tenant_nya-kommunen"
assert data["plan"] == "professional"
assert data["max_users"] == 50
@pytest.mark.asyncio
async def test_create_tenant_duplicate_slug(client: AsyncClient, auth_headers: dict):
# Skapa första
await client.post(
"/api/v1/tenants",
json={
"name": "Duplicate Test",
"slug": "duplicate-test",
"tenant_type": "company",
"contact_email": "a@test.se",
},
headers=auth_headers,
)
# Försök skapa andra med samma slug
response = await client.post(
"/api/v1/tenants",
json={
"name": "Duplicate Test 2",
"slug": "duplicate-test",
"tenant_type": "company",
"contact_email": "b@test.se",
},
headers=auth_headers,
)
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
@pytest.mark.asyncio
async def test_create_tenant_invalid_org_number(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/v1/tenants",
json={
"name": "Bad Org",
"slug": "bad-org",
"tenant_type": "company",
"contact_email": "test@bad.se",
"org_number": "12345", # För kort
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_tenant_unauthorized(client: AsyncClient):
response = await client.post(
"/api/v1/tenants",
json={
"name": "Unauthorized",
"slug": "unauthorized",
"tenant_type": "company",
"contact_email": "test@unauth.se",
},
)
assert response.status_code == 403
# ─── LIST ─────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_tenants(client: AsyncClient, auth_headers: dict):
response = await client.get("/api/v1/tenants", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "pages" in data
@pytest.mark.asyncio
async def test_list_tenants_pagination(client: AsyncClient, auth_headers: dict):
response = await client.get("/api/v1/tenants?page=1&page_size=5", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["page_size"] == 5
@pytest.mark.asyncio
async def test_list_tenants_filter_by_status(client: AsyncClient, auth_headers: dict):
response = await client.get("/api/v1/tenants?status=active", headers=auth_headers)
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert item["status"] == "active"
@pytest.mark.asyncio
async def test_list_tenants_search(client: AsyncClient, auth_headers: dict):
response = await client.get("/api/v1/tenants?search=stockholm", headers=auth_headers)
assert response.status_code == 200
# ─── GET ONE ──────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
response = await client.get(f"/api/v1/tenants/{test_tenant.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(test_tenant.id)
assert data["name"] == test_tenant.name
@pytest.mark.asyncio
async def test_get_tenant_not_found(client: AsyncClient, auth_headers: dict):
fake_id = uuid.uuid4()
response = await client.get(f"/api/v1/tenants/{fake_id}", headers=auth_headers)
assert response.status_code == 404
# ─── UPDATE ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
response = await client.patch(
f"/api/v1/tenants/{test_tenant.id}",
json={"name": "Updated Name", "city": "Malmö"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["city"] == "Malmö"
@pytest.mark.asyncio
async def test_update_tenant_not_found(client: AsyncClient, auth_headers: dict):
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/tenants/{fake_id}",
json={"name": "Updated"},
headers=auth_headers,
)
assert response.status_code == 404
# ─── DELETE ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_delete_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
# Skapa en tenant att radera
tenant = Tenant(
id=uuid.uuid4(),
name="Delete Me",
slug="delete-me",
tenant_type=TenantType.COMPANY,
status=TenantStatus.ACTIVE,
schema_name="tenant_delete_me",
contact_email="delete@me.se",
plan="basic",
max_users=10,
max_projects=5,
storage_quota_mb=1024,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
db_session.add(tenant)
await db_session.commit()
response = await client.delete(f"/api/v1/tenants/{tenant.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "deleted" in data["message"].lower()
# Verifiera mjuk-radering
await db_session.refresh(tenant)
assert tenant.is_deleted == "Y"
assert tenant.status == TenantStatus.CANCELLED
@pytest.mark.asyncio
async def test_hard_delete_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
tenant = Tenant(
id=uuid.uuid4(),
name="Hard Delete Me",
slug="hard-delete-me",
tenant_type=TenantType.COMPANY,
status=TenantStatus.ACTIVE,
schema_name="tenant_hard_delete_me",
contact_email="hard@delete.se",
plan="basic",
max_users=10,
max_projects=5,
storage_quota_mb=1024,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
db_session.add(tenant)
await db_session.commit()
response = await client.delete(f"/api/v1/tenants/{tenant.id}/hard", headers=auth_headers)
assert response.status_code == 200
# Verifiera permanent borttagning
result = await db_session.get(Tenant, tenant.id)
assert result is None
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_activate_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
tenant = Tenant(
id=uuid.uuid4(),
name="Pending Tenant",
slug="pending-tenant",
tenant_type=TenantType.MUNICIPALITY,
status=TenantStatus.PENDING,
schema_name="tenant_pending_tenant",
contact_email="pending@test.se",
plan="basic",
max_users=10,
max_projects=5,
storage_quota_mb=1024,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
db_session.add(tenant)
await db_session.commit()
response = await client.post(
f"/api/v1/tenants/{tenant.id}/activate",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "active"
assert data["activated_at"] is not None
@pytest.mark.asyncio
async def test_suspend_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
response = await client.post(
f"/api/v1/tenants/{test_tenant.id}/suspend",
json={"reason": "Missed payment"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "suspended"
assert data["suspended_reason"] == "Missed payment"
@pytest.mark.asyncio
async def test_cancel_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
response = await client.post(
f"/api/v1/tenants/{test_tenant.id}/cancel?reason=Customer request",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled"
assert data["cancellation_reason"] == "Customer request"
# ─── STATS ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_tenant_stats(client: AsyncClient, auth_headers: dict):
response = await client.get("/api/v1/tenants/stats", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "total_tenants" in data
assert "active_tenants" in data
assert "by_type" in data
assert "by_plan" in data
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_tenant_audit_logs(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
response = await client.get(
f"/api/v1/tenants/{test_tenant.id}/audit-logs",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
# ─── VALIDATION ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_tenant_invalid_slug(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/v1/tenants",
json={
"name": "Bad Slug",
"slug": "bad slug!", # Ogiltiga tecken
"tenant_type": "company",
"contact_email": "test@slug.se",
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_tenant_missing_required(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/v1/tenants",
json={
"slug": "missing-name",
"tenant_type": "company",
},
headers=auth_headers,
)
assert response.status_code == 422
+73
View File
@@ -0,0 +1,73 @@
"""Tester för user CRUD."""
import pytest
from httpx import AsyncClient
from app.models import User, Tenant
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient, admin_token: str, test_tenant: Tenant):
response = await client.post("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"}, json={
"email": "newuser@landvex.se",
"first_name": "New",
"last_name": "User",
"password": "securepass123",
"role": "analyst",
"tenant_id": str(test_tenant.id),
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@landvex.se"
assert data["role"] == "analyst"
assert data["status"] == "active"
@pytest.mark.asyncio
async def test_list_users(client: AsyncClient, admin_token: str):
response = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"})
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert len(data["items"]) >= 1
@pytest.mark.asyncio
async def test_get_user(client: AsyncClient, admin_token: str, test_user: User):
response = await client.get(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"})
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
@pytest.mark.asyncio
async def test_update_user(client: AsyncClient, admin_token: str, test_user: User):
response = await client.patch(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={
"first_name": "Updated",
})
assert response.status_code == 200
data = response.json()
assert data["first_name"] == "Updated"
@pytest.mark.asyncio
async def test_delete_user(client: AsyncClient, admin_token: str, test_tenant: Tenant, db_session):
# Skapa en användare att ta bort
from app.models import User, UserRole, UserStatus
from app.security import get_password_hash
import uuid
user = User(
id=uuid.uuid4(),
email="delete-me@landvex.se",
first_name="Delete",
last_name="Me",
hashed_password=get_password_hash("password123"),
role=UserRole.VIEWER,
status=UserStatus.ACTIVE,
tenant_id=test_tenant.id,
)
db_session.add(user)
await db_session.commit()
response = await client.delete(f"/api/v1/users/{user.id}", headers={"Authorization": f"Bearer {admin_token}"})
assert response.status_code == 204