Files
pocketpaw/tests/cloud/test_fleet_installer.py
Prakash Dalai df5c77dee2 feat(fleet): installable bundle runtime + Sales Fleet template (#940)
* feat(fleet): installable bundle runtime + Sales Fleet template

A FleetTemplate is a YAML manifest naming a soul template + pocket +
connector list + scope tags. install_fleet() orchestrates the install
using existing primitives — SoulFactory, ConnectorRegistry, pocket
service — without introducing new runtime concepts. Sales Fleet ships
as the first bundled example.

What landed:

- ee/fleet/models.py
  - FleetConnector — name + config + optional flag.
  - FleetTemplate — name, display_name, version, soul_template ref,
    pocket name + widgets, connector list, scope list, open metadata.
  - FleetInstallStep + FleetInstallReport — per-step status
    (succeeded/skipped/failed) so partial installs are observable
    without re-running the whole pipeline.

- ee/fleet/installer.py
  - load_fleet(path_or_name) reads YAML/JSON or resolves a bundled
    name (sales-fleet → src/pocketpaw/fleet_templates/sales-fleet.yaml).
  - install_fleet(fleet, *, soul_factory, connector_registry,
    pocket_creator) — pure orchestrator. Each external dep is
    injectable so tests substitute fakes; production callers pass
    the real services.
  - Each step is wrapped: per-step exceptions are caught + logged
    + marked as failed in the report so install never crashes the
    runtime.
  - Optional connectors get "skipped" when missing; required get
    "failed" so admins see what to fix.

- src/pocketpaw/fleet_templates/sales-fleet.yaml
  - Arrow soul + Pipeline pocket + HubSpot + Gong connectors,
    scoped org:sales:*. Connectors marked optional so the demo
    install works without external API keys.

Tests: 15 new in tests/cloud/test_fleet_installer.py covering:
  - YAML + JSON manifest loading + bundled-by-name resolution +
    missing-file error
  - install_fleet creates soul + pocket + registers connectors
    with mocked deps
  - Skips pocket cleanly when creator unavailable
  - Optional missing connector → skipped, required missing → failed
  - Per-step exception is captured in the report
  - Returns early on soul creation failure (no orphan pocket)
  - Sales Fleet bundled, has Arrow soul + sales scope
  - Sales Fleet connectors all optional (demo-friendly)
  - Report.succeeded() + failed_steps() helpers

First PR of Move 7 PR-B. PR-C ships the Install Fleet UI.

* style(fleet): ruff auto-fix

* fix(fleet): point PyYAML import error at pocketpaw[soul]

PyYAML is pulled in transitively via pocketpaw[soul] -> soul-protocol[engine].
The error message was pointing at the transitive package, which sent operators
chasing the wrong install command. Point it at the pocketpaw extra that
actually owns the dependency.

---------

Co-authored-by: Prakash-1 <prakash-1@Mac.lan>
2026-04-16 09:30:34 +05:30

288 lines
9.4 KiB
Python

# tests/cloud/test_fleet_installer.py — Move 7 PR-B.
# Created: 2026-04-13 — Manifest loader, install orchestration with mocked
# soul/connector/pocket dependencies, partial-failure reporting, bundled
# Sales Fleet contract, and the install report shape.
from __future__ import annotations
import json
import textwrap
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from ee.fleet import (
FleetConnector,
FleetInstallReport,
FleetTemplate,
install_fleet,
list_bundled_fleets,
load_fleet,
)
# ---------------------------------------------------------------------------
# Manifest loader
# ---------------------------------------------------------------------------
class TestLoader:
def test_loads_yaml_manifest(self, tmp_path: Path) -> None:
path = tmp_path / "custom.yaml"
path.write_text(
textwrap.dedent(
"""
name: custom-fleet
soul_template: arrow
pocket_name: Custom Pocket
scopes:
- org:sales:*
""",
).strip(),
encoding="utf-8",
)
fleet = load_fleet(path)
assert fleet.name == "custom-fleet"
assert fleet.soul_template == "arrow"
assert fleet.scopes == ["org:sales:*"]
def test_loads_json_manifest(self, tmp_path: Path) -> None:
path = tmp_path / "custom.json"
path.write_text(
json.dumps(
{
"name": "json-fleet",
"soul_template": "flash",
"pocket_name": "JSON Pocket",
}
),
encoding="utf-8",
)
fleet = load_fleet(path)
assert fleet.name == "json-fleet"
def test_loads_bundled_by_name(self) -> None:
names = list_bundled_fleets()
if not names:
pytest.skip("No bundled fleets available")
fleet = load_fleet(names[0])
assert isinstance(fleet, FleetTemplate)
def test_missing_file_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_fleet(tmp_path / "nope.yaml")
# ---------------------------------------------------------------------------
# install_fleet — orchestration
# ---------------------------------------------------------------------------
def _basic_fleet(**overrides) -> FleetTemplate:
defaults = {
"name": "sales-fleet",
"soul_template": "arrow",
"pocket_name": "Pipeline",
"pocket_description": "Live pipeline",
"scopes": ["org:sales:*"],
}
defaults.update(overrides)
return FleetTemplate(**defaults)
def _fake_factory(soul_template_name: str = "arrow"):
"""Return an object that quacks like SoulFactory — load_bundled + from_template."""
factory = MagicMock()
template = MagicMock()
template.name = soul_template_name.capitalize()
factory.load_bundled = MagicMock(return_value=template)
soul = MagicMock()
soul.did = "did:soul:fake-1"
soul.name = template.name
factory.from_template = AsyncMock(return_value=soul)
return factory, soul
@pytest.fixture
def fake_pocket_creator():
pocket = MagicMock()
pocket.id = "pocket_fake_1"
creator = AsyncMock(return_value=pocket)
return creator, pocket
@pytest.fixture
def fake_registry():
registry = MagicMock()
registry.has = MagicMock(return_value=True)
registry.connect = AsyncMock(return_value=True)
return registry
class TestInstallOrchestration:
@pytest.mark.asyncio
async def test_install_creates_soul_pocket_and_connectors(
self, fake_pocket_creator, fake_registry
) -> None:
factory, soul = _fake_factory()
creator, pocket = fake_pocket_creator
fleet = _basic_fleet(
connectors=[FleetConnector(name="hubspot", config={"poll_minutes": 15})],
)
report = await install_fleet(
fleet,
soul_factory=factory,
connector_registry=fake_registry,
pocket_creator=creator,
)
assert report.succeeded()
assert report.soul_id == "did:soul:fake-1"
assert report.pocket_id == "pocket_fake_1"
statuses = [step.status for step in report.steps]
assert "succeeded" in statuses
assert all(s != "failed" for s in statuses)
@pytest.mark.asyncio
async def test_install_skips_pocket_when_creator_missing(self) -> None:
factory, _ = _fake_factory()
report = await install_fleet(
_basic_fleet(),
soul_factory=factory,
connector_registry=None,
pocket_creator=None,
)
skipped = [s for s in report.steps if s.status == "skipped"]
assert any("create_pocket" in s.name for s in skipped)
assert report.pocket_id is None
@pytest.mark.asyncio
async def test_install_marks_optional_missing_connector_as_skipped(
self, fake_pocket_creator
) -> None:
factory, _ = _fake_factory()
creator, _ = fake_pocket_creator
registry = MagicMock()
registry.has = MagicMock(return_value=False)
fleet = _basic_fleet(
connectors=[FleetConnector(name="missing-connector", optional=True)],
)
report = await install_fleet(
fleet,
soul_factory=factory,
connector_registry=registry,
pocket_creator=creator,
)
connector_step = next(s for s in report.steps if "missing-connector" in s.name)
assert connector_step.status == "skipped"
@pytest.mark.asyncio
async def test_install_marks_required_missing_connector_as_failed(
self, fake_pocket_creator
) -> None:
factory, _ = _fake_factory()
creator, _ = fake_pocket_creator
registry = MagicMock()
registry.has = MagicMock(return_value=False)
fleet = _basic_fleet(
connectors=[FleetConnector(name="critical-connector", optional=False)],
)
report = await install_fleet(
fleet,
soul_factory=factory,
connector_registry=registry,
pocket_creator=creator,
)
assert not report.succeeded()
failed = report.failed_steps()
assert len(failed) == 1
assert "critical-connector" in failed[0].name
@pytest.mark.asyncio
async def test_install_swallows_per_step_exceptions(self, fake_pocket_creator) -> None:
factory, _ = _fake_factory()
creator, _ = fake_pocket_creator
registry = MagicMock()
registry.has = MagicMock(return_value=True)
registry.connect = AsyncMock(side_effect=RuntimeError("network down"))
fleet = _basic_fleet(connectors=[FleetConnector(name="hubspot")])
report = await install_fleet(
fleet,
soul_factory=factory,
connector_registry=registry,
pocket_creator=creator,
)
failed = report.failed_steps()
assert len(failed) == 1
assert "network down" in failed[0].detail
@pytest.mark.asyncio
async def test_install_returns_early_on_soul_failure(self) -> None:
factory = MagicMock()
factory.load_bundled = MagicMock(side_effect=FileNotFoundError("template missing"))
fleet = _basic_fleet()
report = await install_fleet(fleet, soul_factory=factory)
assert not report.succeeded()
assert report.soul_id is None
# Pocket + connector steps shouldn't even appear.
assert all("create_pocket" not in s.name for s in report.steps)
# ---------------------------------------------------------------------------
# Bundled Sales Fleet — contract check
# ---------------------------------------------------------------------------
class TestSalesFleetBundle:
def test_sales_fleet_is_bundled(self) -> None:
names = list_bundled_fleets()
assert "sales-fleet" in names
def test_sales_fleet_has_arrow_soul_and_sales_scope(self) -> None:
fleet = load_fleet("sales-fleet")
assert fleet.soul_template == "arrow"
assert "org:sales:*" in fleet.scopes
def test_sales_fleet_lists_widgets_and_connectors(self) -> None:
fleet = load_fleet("sales-fleet")
assert len(fleet.pocket_widgets) >= 2
assert any(c.name == "hubspot" for c in fleet.connectors)
assert all(c.optional for c in fleet.connectors), (
"All Sales Fleet connectors should be optional so the demo install "
"works without external API keys."
)
# ---------------------------------------------------------------------------
# Report shape
# ---------------------------------------------------------------------------
class TestInstallReport:
def test_succeeded_when_no_failed_steps(self) -> None:
report = FleetInstallReport(fleet="x")
assert report.succeeded()
def test_failed_steps_filters(self) -> None:
from ee.fleet.models import FleetInstallStep
report = FleetInstallReport(
fleet="x",
steps=[
FleetInstallStep(name="a", status="succeeded"),
FleetInstallStep(name="b", status="failed"),
FleetInstallStep(name="c", status="skipped"),
],
)
failed = report.failed_steps()
assert len(failed) == 1
assert failed[0].name == "b"
assert not report.succeeded()