76df8b7ab6
Add owner-safe lesson catalog, fixture-backed capability statuses, CLI, and unit tests without live OAuth or connect scripts.
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Tests for lumina_skills.setup.capability_report domain model and builder."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import pathlib
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
|
|
|
from lumina_skills.setup.capability_report import (
|
|
CapabilityEntry,
|
|
CapabilityReport,
|
|
ConnectionStatus,
|
|
build_capability_report,
|
|
format_capability_report_text,
|
|
status_label,
|
|
)
|
|
|
|
|
|
# ── ConnectionStatus ───────────────────────────────────────────────────────
|
|
|
|
def test_connection_status_values():
|
|
"""All status enum values are correct."""
|
|
assert ConnectionStatus.CONNECTED.value == "connected"
|
|
assert ConnectionStatus.SKIPPED.value == "skipped"
|
|
assert ConnectionStatus.LATER.value == "later"
|
|
assert ConnectionStatus.ERROR.value == "error"
|
|
assert ConnectionStatus.OFFLINE.value == "offline"
|
|
|
|
|
|
def test_status_labels():
|
|
"""Each status has an emoji label."""
|
|
assert "✅" in status_label(ConnectionStatus.CONNECTED)
|
|
assert "⏭️" in status_label(ConnectionStatus.SKIPPED)
|
|
assert "⏳" in status_label(ConnectionStatus.LATER)
|
|
assert "❌" in status_label(ConnectionStatus.ERROR)
|
|
assert "📋" in status_label(ConnectionStatus.OFFLINE)
|
|
|
|
|
|
# ── CapabilityEntry ────────────────────────────────────────────────────────
|
|
|
|
def test_capability_entry_label():
|
|
"""Entry label uses status_label."""
|
|
entry = CapabilityEntry(
|
|
area="channels",
|
|
provider="whatsapp",
|
|
status=ConnectionStatus.CONNECTED,
|
|
)
|
|
assert "✅" in entry.label
|
|
|
|
|
|
def test_capability_entry_frozen():
|
|
"""CapabilityEntry is immutable."""
|
|
entry = CapabilityEntry(
|
|
area="channels",
|
|
provider="whatsapp",
|
|
status=ConnectionStatus.CONNECTED,
|
|
)
|
|
try:
|
|
entry.status = ConnectionStatus.ERROR
|
|
assert False, "Should not be able to modify frozen dataclass"
|
|
except Exception:
|
|
pass # Expected
|
|
|
|
|
|
# ── CapabilityReport ───────────────────────────────────────────────────────
|
|
|
|
def _make_entries(*statuses: ConnectionStatus) -> list[CapabilityEntry]:
|
|
"""Helper to create CapabilityEntry objects with given statuses."""
|
|
providers = ["whatsapp", "email", "telegram", "vagaro", "qbo"]
|
|
areas = ["channels", "channels", "channels", "scheduling", "books"]
|
|
entries = []
|
|
for i, status in enumerate(statuses):
|
|
entries.append(CapabilityEntry(
|
|
area=areas[i],
|
|
provider=providers[i],
|
|
status=status,
|
|
))
|
|
return entries
|
|
|
|
|
|
def test_report_connected_count():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.ERROR,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.connected_count() == 2
|
|
|
|
|
|
def test_report_offline_count():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.ERROR,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.offline_count() == 2
|
|
|
|
|
|
def test_report_skipped_count():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.LATER,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.ERROR,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.skipped_count() == 2 # SKIPPED + LATER
|
|
|
|
|
|
def test_report_error_count():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.ERROR,
|
|
ConnectionStatus.ERROR,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.OFFLINE,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.error_count() == 2
|
|
|
|
|
|
def test_report_all_connected():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.all_connected() is True
|
|
|
|
|
|
def test_report_not_all_connected():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.CONNECTED,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.all_connected() is False
|
|
|
|
|
|
def test_report_has_errors():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.ERROR,
|
|
ConnectionStatus.SKIPPED,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.has_errors() is True
|
|
|
|
|
|
def test_report_no_errors():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.OFFLINE,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
assert report.has_errors() is False
|
|
|
|
|
|
def test_report_empty():
|
|
report = build_capability_report([], "Empty Salon")
|
|
assert report.connected_count() == 0
|
|
assert report.offline_count() == 0
|
|
assert report.skipped_count() == 0
|
|
assert report.error_count() == 0
|
|
assert report.all_connected() is False # empty report is not "all connected"
|
|
assert report.has_errors() is False
|
|
|
|
|
|
# ── to_dict ────────────────────────────────────────────────────────────────
|
|
|
|
def test_report_to_dict():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.SKIPPED,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
|
d = report.to_dict()
|
|
|
|
assert d["salon_name"] == "Test Salon"
|
|
assert d["is_fixture"] is True
|
|
assert len(d["capabilities"]) == 3
|
|
assert d["capabilities"][0]["status"] == "connected"
|
|
assert d["capabilities"][1]["status"] == "offline"
|
|
assert d["capabilities"][2]["status"] == "skipped"
|
|
assert d["summary"]["connected"] == 1
|
|
assert d["summary"]["offline"] == 1
|
|
assert d["summary"]["skipped_or_later"] == 1
|
|
assert d["summary"]["errors"] == 0
|
|
assert d["summary"]["all_connected"] is False
|
|
|
|
|
|
def test_report_to_dict_serializable():
|
|
"""to_dict output must be JSON-serializable."""
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.OFFLINE,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
d = report.to_dict()
|
|
# Should not raise.
|
|
json.dumps(d)
|
|
|
|
|
|
# ── format_capability_report_text ──────────────────────────────────────────
|
|
|
|
def test_format_text_includes_salon_name():
|
|
entries = _make_entries(ConnectionStatus.CONNECTED)
|
|
report = build_capability_report(entries, "Lumina Hair Studio & Spa")
|
|
text = format_capability_report_text(report)
|
|
assert "Lumina Hair Studio & Spa" in text
|
|
|
|
|
|
def test_format_text_fixture_label():
|
|
entries = _make_entries(ConnectionStatus.OFFLINE)
|
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
|
text = format_capability_report_text(report)
|
|
assert "FIXTURE DATA" in text
|
|
|
|
|
|
def test_format_text_no_fixture_label_when_live():
|
|
entries = _make_entries(ConnectionStatus.CONNECTED)
|
|
report = build_capability_report(entries, "Test Salon", is_fixture=False)
|
|
text = format_capability_report_text(report)
|
|
assert "FIXTURE DATA" not in text
|
|
|
|
|
|
def test_format_text_includes_summary():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.SKIPPED,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
text = format_capability_report_text(report)
|
|
assert "Summary" in text
|
|
assert "Connected:" in text
|
|
|
|
|
|
def test_format_text_error_warning():
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.ERROR,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon")
|
|
text = format_capability_report_text(report)
|
|
assert "needs attention" in text
|
|
|
|
|
|
def test_format_text_fixture_disclaimer():
|
|
entries = _make_entries(ConnectionStatus.OFFLINE)
|
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
|
text = format_capability_report_text(report)
|
|
assert "fixture" in text.lower() or "demo" in text.lower()
|
|
|
|
|
|
def test_format_text_groups_by_area():
|
|
entries = [
|
|
CapabilityEntry("channels", "whatsapp", ConnectionStatus.CONNECTED),
|
|
CapabilityEntry("channels", "email", ConnectionStatus.SKIPPED),
|
|
CapabilityEntry("scheduling", "vagaro", ConnectionStatus.OFFLINE),
|
|
CapabilityEntry("books", "quickbooks_online", ConnectionStatus.OFFLINE),
|
|
]
|
|
report = build_capability_report(entries, "Test Salon")
|
|
text = format_capability_report_text(report)
|
|
assert "Channels" in text
|
|
assert "Scheduling" in text
|
|
assert "Books" in text
|
|
|
|
|
|
def test_format_text_owner_safe():
|
|
"""Formatted text must never contain forbidden keywords."""
|
|
forbidden = ["terminal", "docker", "nano", "shell", "bash", "sudo"]
|
|
entries = _make_entries(
|
|
ConnectionStatus.CONNECTED,
|
|
ConnectionStatus.OFFLINE,
|
|
ConnectionStatus.SKIPPED,
|
|
ConnectionStatus.ERROR,
|
|
ConnectionStatus.LATER,
|
|
)
|
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
|
text = format_capability_report_text(report).lower()
|
|
for kw in forbidden:
|
|
assert kw not in text, f"Formatted text contains forbidden keyword: {kw}"
|