feat: add Nextcloud initial setup endpoint via Playwright
Build and Push Docker Image / test (push) Successful in 55s Details
Build and Push Docker Image / build (push) Successful in 1m16s Details

Add POST /api/v1/tenants/{tenant_id}/nextcloud/setup endpoint that:
- Creates a PLAYWRIGHT task for Nextcloud initial admin setup
- Validates tenant has domain configured
- Auto-resolves online agent for tenant
- Performs health check against Nextcloud before creating task
- Returns 409 if Nextcloud is unavailable

Changes:
- Add httpx for health checks
- Add build_nextcloud_initial_setup_step() and create_nextcloud_initial_setup_task()
- Add NextcloudInitialSetupRequest schema with username/password validation
- Add check_nextcloud_availability() helper for health checks
- Add comprehensive unit tests (42 tests total)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-08 20:20:31 +01:00
parent 4dac6d7e4e
commit 4bafefdfaf
5 changed files with 699 additions and 5 deletions

View File

@ -1,13 +1,15 @@
"""Nextcloud deployment playbook. """Nextcloud deployment playbook.
Defines the steps required to set Nextcloud domain on a tenant server Defines the steps required to:
that already has stacks and env templates under /opt/letsbe. 1. Set Nextcloud domain on a tenant server (v2: via NEXTCLOUD_SET_DOMAIN task)
2. Perform initial setup via Playwright automation (create admin account)
v2: Configures Nextcloud via NEXTCLOUD_SET_DOMAIN task, then reloads the stack. Tenant servers must have stacks and env templates under /opt/letsbe.
""" """
import uuid import uuid
from typing import Any from typing import Any
from urllib.parse import urlparse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -28,6 +30,95 @@ class CompositeStep(BaseModel):
NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud" NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud"
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_nextcloud_initial_setup_step(
*,
base_url: str,
admin_username: str,
admin_password: str,
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Nextcloud initial setup.
This creates the admin account on a fresh Nextcloud installation.
Args:
base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com")
admin_username: Username for the admin account
admin_password: Password for the admin account
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
# Extract domain from URL for allowlist
parsed = urlparse(base_url)
allowed_domain = parsed.netloc # e.g., "cloud.example.com"
return {
"scenario": "nextcloud.initial_setup",
"inputs": {
"base_url": base_url,
"admin_username": admin_username,
"admin_password": admin_password,
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_nextcloud_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_username: str,
admin_password: str,
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Nextcloud initial setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: UUID of the agent to assign the task to
base_url: The base URL for Nextcloud
admin_username: Username for the admin account
admin_password: Password for the admin account
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_nextcloud_initial_setup_step(
base_url=base_url,
admin_username=admin_username,
admin_password=admin_password,
)
task = Task(
tenant_id=tenant_id,
agent_id=agent_id,
type="PLAYWRIGHT",
payload=payload,
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
# =============================================================================
# Set Domain via NEXTCLOUD_SET_DOMAIN
# =============================================================================
def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]: def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]:
""" """
Build the sequence of steps required to set Nextcloud domain (v2). Build the sequence of steps required to set Nextcloud domain (v2).

View File

@ -1,9 +1,11 @@
"""Playbook endpoints for triggering infrastructure automation.""" """Playbook endpoints for triggering infrastructure automation."""
import re
import uuid import uuid
import httpx
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select from sqlalchemy import select
from app.db import AsyncSessionDep from app.db import AsyncSessionDep
@ -11,11 +13,17 @@ from app.models.agent import Agent
from app.models.task import Task from app.models.task import Task
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.playbooks.chatwoot import create_chatwoot_setup_task from app.playbooks.chatwoot import create_chatwoot_setup_task
from app.playbooks.nextcloud import create_nextcloud_set_domain_task from app.playbooks.nextcloud import (
create_nextcloud_initial_setup_task,
create_nextcloud_set_domain_task,
)
from app.schemas.task import TaskResponse from app.schemas.task import TaskResponse
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"])
# Health check timeout for Nextcloud availability
NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0
class ChatwootSetupRequest(BaseModel): class ChatwootSetupRequest(BaseModel):
"""Request body for Chatwoot setup playbook.""" """Request body for Chatwoot setup playbook."""
@ -42,6 +50,32 @@ class NextcloudSetDomainRequest(BaseModel):
) )
class NextcloudInitialSetupRequest(BaseModel):
"""Request body for Nextcloud initial setup via Playwright."""
admin_username: str = Field(
...,
min_length=3,
max_length=64,
description="Username for the Nextcloud admin account",
)
admin_password: str = Field(
...,
min_length=8,
description="Password for the Nextcloud admin account (minimum 8 characters)",
)
@field_validator("admin_username")
@classmethod
def validate_username(cls, v: str) -> str:
"""Validate that username is alphanumeric (with underscores allowed)."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", v):
raise ValueError(
"Username must start with a letter and contain only letters, numbers, and underscores"
)
return v
# --- Helper functions --- # --- Helper functions ---
@ -70,6 +104,46 @@ async def get_online_agent_for_tenant(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def check_nextcloud_availability(base_url: str) -> bool:
"""
Check if Nextcloud is available at the given URL.
Performs a health check against {base_url}/status.php which returns
JSON with installation status on a healthy Nextcloud instance.
Args:
base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com")
Returns:
True if Nextcloud responds with a valid status, False otherwise
"""
status_url = f"{base_url.rstrip('/')}/status.php"
try:
async with httpx.AsyncClient(
timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT,
follow_redirects=True,
) as client:
response = await client.get(status_url)
# Nextcloud status.php returns 200 with JSON when healthy
if response.status_code == 200:
# Try to parse as JSON - valid Nextcloud returns {"installed":true,...}
try:
data = response.json()
# If we get valid JSON with "installed" key, Nextcloud is available
return "installed" in data
except (ValueError, KeyError):
# Not valid JSON, but 200 OK might still indicate availability
return True
return False
except (httpx.RequestError, httpx.TimeoutException):
# Connection failed, timeout, or other network error
return False
# --- Route handlers --- # --- Route handlers ---
@ -181,3 +255,91 @@ async def nextcloud_set_domain(
) )
return task return task
@router.post(
"/nextcloud/setup",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
)
async def nextcloud_initial_setup(
tenant_id: uuid.UUID,
request: NextcloudInitialSetupRequest,
db: AsyncSessionDep,
) -> Task:
"""
Perform initial Nextcloud setup via Playwright browser automation.
Creates a PLAYWRIGHT task that navigates to the Nextcloud installation page
and creates the admin account. This should only be called once on a fresh
Nextcloud installation.
**Prerequisites:**
- Tenant must have a domain configured
- Nextcloud must be deployed and accessible at `https://cloud.{domain}`
- An online agent must be registered for the tenant
**Health Check:**
Before creating the task, the endpoint checks if Nextcloud is available
at `https://cloud.{domain}/status.php`. If not reachable, returns 409.
## Request Body
- **admin_username**: Username for the admin account (3-64 chars, alphanumeric)
- **admin_password**: Password for the admin account (minimum 8 characters)
## Response
Returns the created Task with type="PLAYWRIGHT" and status="pending".
## Errors
- **404**: Tenant not found
- **400**: Tenant has no domain configured
- **404**: No online agent found for tenant
- **409**: Nextcloud not available (health check failed)
- **422**: Invalid username or password format
"""
# Validate tenant exists
tenant = await get_tenant_by_id(db, tenant_id)
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found",
)
# Validate tenant has domain configured
if not tenant.domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tenant {tenant_id} has no domain configured",
)
# Auto-resolve agent (first online agent for tenant)
agent = await get_online_agent_for_tenant(db, tenant_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No online agent found for tenant {tenant_id}",
)
# Construct Nextcloud base URL from tenant domain
base_url = f"https://cloud.{tenant.domain}"
# Health check: verify Nextcloud is accessible
is_available = await check_nextcloud_availability(base_url)
if not is_available:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Nextcloud not available at {base_url}. "
"Ensure Nextcloud is deployed and accessible before running setup.",
)
# Create the PLAYWRIGHT task
task = await create_nextcloud_initial_setup_task(
db=db,
tenant_id=tenant_id,
agent_id=agent.id,
base_url=base_url,
admin_username=request.admin_username,
admin_password=request.admin_password,
)
return task

View File

@ -14,6 +14,9 @@ pydantic-settings>=2.1.0
# Utilities # Utilities
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# HTTP Client
httpx>=0.26.0
# Testing # Testing
pytest>=8.0.0 pytest>=8.0.0
pytest-asyncio>=0.23.0 pytest-asyncio>=0.23.0

View File

@ -1,9 +1,12 @@
"""Tests for Nextcloud playbook routes.""" """Tests for Nextcloud playbook routes."""
import uuid import uuid
from unittest.mock import AsyncMock, patch
import pytest import pytest
import pytest_asyncio
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -12,7 +15,9 @@ from app.models.task import Task
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR
from app.routes.playbooks import ( from app.routes.playbooks import (
NextcloudInitialSetupRequest,
NextcloudSetDomainRequest, NextcloudSetDomainRequest,
nextcloud_initial_setup,
nextcloud_set_domain, nextcloud_set_domain,
) )
@ -202,3 +207,285 @@ class TestNextcloudSetDomainEndpoint:
# Should have resolved to the online agent # Should have resolved to the online agent
assert task.agent_id == online_agent.id assert task.agent_id == online_agent.id
# =============================================================================
# Fixtures for Initial Setup Tests
# =============================================================================
@pytest_asyncio.fixture(scope="function")
async def test_tenant_with_domain(db: AsyncSession) -> Tenant:
"""Create a test tenant with domain configured."""
tenant = Tenant(
id=uuid.uuid4(),
name="Test Tenant With Domain",
domain="example.com",
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant
@pytest_asyncio.fixture(scope="function")
async def test_agent_for_tenant_with_domain(
db: AsyncSession, test_tenant_with_domain: Tenant
) -> Agent:
"""Create a test agent linked to test_tenant_with_domain with online status."""
agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant_with_domain.id,
name="test-agent-for-tenant-domain",
version="1.0.0",
status="online",
token="test-token-domain",
)
db.add(agent)
await db.commit()
await db.refresh(agent)
return agent
# =============================================================================
# Tests for Nextcloud Initial Setup Endpoint
# =============================================================================
@pytest.mark.asyncio
class TestNextcloudInitialSetupEndpoint:
"""Tests for the nextcloud_initial_setup endpoint."""
@patch("app.routes.playbooks.check_nextcloud_availability")
async def test_happy_path_creates_task(
self,
mock_health_check: AsyncMock,
db: AsyncSession,
test_tenant_with_domain: Tenant,
test_agent_for_tenant_with_domain: Agent,
):
"""POST /tenants/{id}/nextcloud/setup returns 201 with PLAYWRIGHT task."""
mock_health_check.return_value = True
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="securepassword123",
)
task = await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
assert task.id is not None
assert task.tenant_id == test_tenant_with_domain.id
assert task.agent_id == test_agent_for_tenant_with_domain.id
assert task.type == "PLAYWRIGHT"
assert task.status == "pending"
@patch("app.routes.playbooks.check_nextcloud_availability")
async def test_task_has_correct_payload(
self,
mock_health_check: AsyncMock,
db: AsyncSession,
test_tenant_with_domain: Tenant,
test_agent_for_tenant_with_domain: Agent,
):
"""Verify response task payload contains scenario and inputs."""
mock_health_check.return_value = True
request = NextcloudInitialSetupRequest(
admin_username="myadmin",
admin_password="mypassword123",
)
task = await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
assert task.payload["scenario"] == "nextcloud.initial_setup"
inputs = task.payload["inputs"]
assert inputs["base_url"] == "https://cloud.example.com"
assert inputs["admin_username"] == "myadmin"
assert inputs["admin_password"] == "mypassword123"
assert inputs["allowed_domains"] == ["cloud.example.com"]
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
"""Non-existent tenant_id returns 404."""
fake_tenant_id = uuid.uuid4()
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_initial_setup(
tenant_id=fake_tenant_id, request=request, db=db
)
assert exc_info.value.status_code == 404
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
async def test_tenant_without_domain_returns_400(
self, db: AsyncSession, test_tenant: Tenant
):
"""Tenant without domain configured returns 400."""
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_initial_setup(
tenant_id=test_tenant.id, request=request, db=db
)
assert exc_info.value.status_code == 400
assert "no domain configured" in str(exc_info.value.detail)
async def test_no_online_agent_returns_404(
self, db: AsyncSession, test_tenant_with_domain: Tenant
):
"""Tenant with no online agent returns 404."""
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
assert exc_info.value.status_code == 404
assert "No online agent found" in str(exc_info.value.detail)
@patch("app.routes.playbooks.check_nextcloud_availability")
async def test_nextcloud_unavailable_returns_409(
self,
mock_health_check: AsyncMock,
db: AsyncSession,
test_tenant_with_domain: Tenant,
test_agent_for_tenant_with_domain: Agent,
):
"""Health check failure returns 409."""
mock_health_check.return_value = False
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
assert exc_info.value.status_code == 409
assert "Nextcloud not available" in str(exc_info.value.detail)
@patch("app.routes.playbooks.check_nextcloud_availability")
async def test_health_check_called_with_correct_url(
self,
mock_health_check: AsyncMock,
db: AsyncSession,
test_tenant_with_domain: Tenant,
test_agent_for_tenant_with_domain: Agent,
):
"""Verify health check is called with correct URL."""
mock_health_check.return_value = True
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
mock_health_check.assert_called_once_with("https://cloud.example.com")
def test_username_too_short_raises_validation_error(self):
"""Username less than 3 characters raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
NextcloudInitialSetupRequest(
admin_username="ab", # Too short
admin_password="password123",
)
assert "admin_username" in str(exc_info.value)
def test_username_invalid_chars_raises_validation_error(self):
"""Username with invalid characters raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
NextcloudInitialSetupRequest(
admin_username="admin@user", # Invalid char @
admin_password="password123",
)
assert "admin_username" in str(exc_info.value)
def test_username_starting_with_number_raises_validation_error(self):
"""Username starting with number raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
NextcloudInitialSetupRequest(
admin_username="123admin", # Starts with number
admin_password="password123",
)
assert "admin_username" in str(exc_info.value)
def test_password_too_short_raises_validation_error(self):
"""Password less than 8 characters raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="short", # Too short
)
assert "admin_password" in str(exc_info.value)
def test_valid_username_with_underscore(self):
"""Username with underscore is valid."""
request = NextcloudInitialSetupRequest(
admin_username="admin_user",
admin_password="password123",
)
assert request.admin_username == "admin_user"
def test_valid_username_with_numbers(self):
"""Username with numbers (not at start) is valid."""
request = NextcloudInitialSetupRequest(
admin_username="admin123",
admin_password="password123",
)
assert request.admin_username == "admin123"
@patch("app.routes.playbooks.check_nextcloud_availability")
async def test_task_persisted_to_database(
self,
mock_health_check: AsyncMock,
db: AsyncSession,
test_tenant_with_domain: Tenant,
test_agent_for_tenant_with_domain: Agent,
):
"""Verify the task is actually persisted and can be retrieved."""
mock_health_check.return_value = True
request = NextcloudInitialSetupRequest(
admin_username="admin",
admin_password="password123",
)
task = await nextcloud_initial_setup(
tenant_id=test_tenant_with_domain.id, request=request, db=db
)
# Query the task back from the database
result = await db.execute(select(Task).where(Task.id == task.id))
retrieved_task = result.scalar_one_or_none()
assert retrieved_task is not None
assert retrieved_task.type == "PLAYWRIGHT"
assert retrieved_task.tenant_id == test_tenant_with_domain.id
assert retrieved_task.agent_id == test_agent_for_tenant_with_domain.id

View File

@ -12,11 +12,162 @@ from app.models.tenant import Tenant
from app.playbooks.nextcloud import ( from app.playbooks.nextcloud import (
NEXTCLOUD_STACK_DIR, NEXTCLOUD_STACK_DIR,
CompositeStep, CompositeStep,
build_nextcloud_initial_setup_step,
build_nextcloud_set_domain_steps, build_nextcloud_set_domain_steps,
create_nextcloud_initial_setup_task,
create_nextcloud_set_domain_task, create_nextcloud_set_domain_task,
) )
# =============================================================================
# Tests for Playwright Initial Setup
# =============================================================================
class TestBuildNextcloudInitialSetupStep:
"""Tests for the build_nextcloud_initial_setup_step function."""
def test_returns_playwright_payload(self):
"""Verify that build_nextcloud_initial_setup_step returns correct structure."""
payload = build_nextcloud_initial_setup_step(
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="securepassword123",
)
assert payload["scenario"] == "nextcloud.initial_setup"
assert "inputs" in payload
assert "timeout" in payload
def test_inputs_contain_required_fields(self):
"""Verify that inputs contain all required fields."""
payload = build_nextcloud_initial_setup_step(
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="securepassword123",
)
inputs = payload["inputs"]
assert inputs["base_url"] == "https://cloud.example.com"
assert inputs["admin_username"] == "admin"
assert inputs["admin_password"] == "securepassword123"
assert "allowed_domains" in inputs
def test_allowed_domains_extracted_from_url(self):
"""Verify that allowed_domains is extracted from base_url."""
payload = build_nextcloud_initial_setup_step(
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="password",
)
assert payload["inputs"]["allowed_domains"] == ["cloud.example.com"]
def test_allowed_domains_with_port(self):
"""Verify that allowed_domains handles URLs with ports."""
payload = build_nextcloud_initial_setup_step(
base_url="https://cloud.example.com:8443",
admin_username="admin",
admin_password="password",
)
assert payload["inputs"]["allowed_domains"] == ["cloud.example.com:8443"]
def test_timeout_is_set(self):
"""Verify that timeout is set in the payload."""
payload = build_nextcloud_initial_setup_step(
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="password",
)
assert payload["timeout"] == 120
@pytest.mark.asyncio
class TestCreateNextcloudInitialSetupTask:
"""Tests for the create_nextcloud_initial_setup_task function."""
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that create_nextcloud_initial_setup_task persists a PLAYWRIGHT task."""
agent_id = uuid.uuid4()
task = await create_nextcloud_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=agent_id,
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="securepassword123",
)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == agent_id
assert task.type == "PLAYWRIGHT"
assert task.status == TaskStatus.PENDING.value
async def test_task_payload_contains_scenario(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify that the task payload contains the scenario field."""
task = await create_nextcloud_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="password",
)
assert task.payload["scenario"] == "nextcloud.initial_setup"
async def test_task_payload_contains_inputs(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify that the task payload contains the inputs field."""
task = await create_nextcloud_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://cloud.example.com",
admin_username="testadmin",
admin_password="testpassword123",
)
inputs = task.payload["inputs"]
assert inputs["base_url"] == "https://cloud.example.com"
assert inputs["admin_username"] == "testadmin"
assert inputs["admin_password"] == "testpassword123"
assert inputs["allowed_domains"] == ["cloud.example.com"]
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify the task is actually persisted and can be retrieved."""
task = await create_nextcloud_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://cloud.example.com",
admin_username="admin",
admin_password="password",
)
# Query the task back from the database
result = await db.execute(select(Task).where(Task.id == task.id))
retrieved_task = result.scalar_one_or_none()
assert retrieved_task is not None
assert retrieved_task.type == "PLAYWRIGHT"
assert retrieved_task.tenant_id == test_tenant.id
# =============================================================================
# Tests for Set Domain Playbook
# =============================================================================
class TestBuildNextcloudSetDomainSteps: class TestBuildNextcloudSetDomainSteps:
"""Tests for the build_nextcloud_set_domain_steps function.""" """Tests for the build_nextcloud_set_domain_steps function."""