feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.notification_routing import (
|
||||
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
||||
ANALYZER_NOTIFICATION_APP_ID,
|
||||
GYXX_NOTIFICATION_APP_PROFILE,
|
||||
GYXX_NOTIFICATION_HERMES_PROFILE,
|
||||
GYXX_NOTIFICATION_RECIPIENTS_JSON,
|
||||
GYXX_NOTIFICATION_ROUTE_MODE,
|
||||
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID,
|
||||
NOTIFICATION_CAPABILITIES,
|
||||
NotificationRoutingConflictError,
|
||||
NotificationRoutingError,
|
||||
NotificationRoutingPreconditionError,
|
||||
NotificationRoutingStore,
|
||||
notification_environment_for_workflow,
|
||||
resolve_notification_route,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def routing_settings(tmp_path: Path) -> Settings:
|
||||
project_root = tmp_path / "project"
|
||||
config_root = project_root / "config"
|
||||
config_root.mkdir(parents=True)
|
||||
shutil.copyfile(
|
||||
PROJECT_ROOT / "config" / "notification-routing.json",
|
||||
config_root / "notification-routing.json",
|
||||
)
|
||||
return Settings(project_root=project_root, data_root=tmp_path / "data")
|
||||
|
||||
|
||||
def _environment(settings: Settings) -> dict[str, str]:
|
||||
return {
|
||||
"GYXX_PROJECT_ROOT": str(settings.project_root),
|
||||
"GYXX_DATA_ROOT": str(settings.data_root),
|
||||
}
|
||||
|
||||
|
||||
def _route(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
app_profile: str = "hermes-analyzer",
|
||||
person_ids: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"app_profile": app_profile,
|
||||
"person_ids": ["he_yingwei", "wang_yunlong"]
|
||||
if person_ids is None
|
||||
else person_ids,
|
||||
}
|
||||
|
||||
|
||||
def test_baseline_contains_all_provided_people_and_routable_capabilities(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
payload, revision = NotificationRoutingStore(routing_settings).snapshot()
|
||||
|
||||
assert len(payload["people"]) == 16
|
||||
assert payload["app_profile"] == "hermes-analyzer"
|
||||
assert (
|
||||
payload["app_profiles"]["hermes-analyzer"]["app_id"]
|
||||
== ANALYZER_NOTIFICATION_APP_ID
|
||||
)
|
||||
assert all(person["mobile"] is None for person in payload["people"].values())
|
||||
assert set(payload["capabilities"]) == set(NOTIFICATION_CAPABILITIES)
|
||||
assert payload["routes"] == {}
|
||||
assert len(revision) == 64
|
||||
|
||||
wang = payload["people"]["wang_yunlong"]
|
||||
assert wang["bindings"]["collector"]["open_id"] == (
|
||||
"ou_8ee224968aa26a74c7d30ba27fed5eeb"
|
||||
)
|
||||
assert wang["bindings"]["hermes-analyzer"]["open_id"] == (
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
)
|
||||
provided = {
|
||||
person["name"]: binding["open_id"]
|
||||
for person_id, person in payload["people"].items()
|
||||
if person_id != "wang_yunlong"
|
||||
for profile, binding in person["bindings"].items()
|
||||
if profile == "hermes-analyzer"
|
||||
}
|
||||
assert provided == {
|
||||
"何颖威": "ou_89bcff110ccbb09a23548dc0fb3d880c",
|
||||
"冯任运": "ou_c1faf3d3498201d27cd73c388acde067",
|
||||
"张育基": "ou_339c1d396b397c97b08e1bf377963d40",
|
||||
"李静娴": "ou_2eda5eec112109ae6d19a1f6813eadcb",
|
||||
"黄坤平": "ou_b76e4cbdb24fe28cebd45ad091b60224",
|
||||
"马兆基": "ou_54141d75b24d22ddc8c1c4d66e00b6e9",
|
||||
"尹江涛": "ou_96fea927e6eef671f019428cc5075e69",
|
||||
"余鹏辉": "ou_531782527ea7164a4abfb23dc1c37b2b",
|
||||
"何嘉琪": "ou_bfcfa9585a0cc9c0a989816c12de661f",
|
||||
"黄韶基": "ou_fa8d81a16527ad06352dbecc575285b8",
|
||||
"谢钧宇": "ou_0d407e24fcf61bf6bb3a176bc84006e9",
|
||||
"赵静": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"李凤仪": "ou_e557867b29d756e57d3a1b656e9d3ce1",
|
||||
"陈燕": "ou_63945c9e1b6d4c9ced7cce1c495d8bea",
|
||||
"邓红梅": "ou_3799e39cfc5f78da1b7c54c2f677f5e3",
|
||||
}
|
||||
assert all(
|
||||
binding["verified"] is False and binding["source"] == "provided"
|
||||
for person_id, person in payload["people"].items()
|
||||
if person_id != "wang_yunlong"
|
||||
for profile, binding in person["bindings"].items()
|
||||
if profile == "hermes-analyzer"
|
||||
)
|
||||
|
||||
|
||||
def test_business_adapter_boundary_reexports_notification_resolution() -> None:
|
||||
from gyxx_flow.adapters import (
|
||||
ResolvedNotificationRoute as ExportedResolvedNotificationRoute,
|
||||
)
|
||||
from gyxx_flow.adapters import resolve_notification_route as exported_resolver
|
||||
from gyxx_flow.notification_routing import ResolvedNotificationRoute
|
||||
|
||||
assert ExportedResolvedNotificationRoute is ResolvedNotificationRoute
|
||||
assert exported_resolver is resolve_notification_route
|
||||
|
||||
|
||||
def test_enabled_route_supports_multiple_people_and_deduplicates_ids(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["content.marketing_report.daily"] = _route(
|
||||
person_ids=["he_yingwei", "wang_yunlong", "he_yingwei"]
|
||||
)
|
||||
|
||||
updated, updated_revision = store.update(
|
||||
payload,
|
||||
expected_revision=f'"{revision}"',
|
||||
)
|
||||
resolved = resolve_notification_route(
|
||||
"content.marketing_report.daily",
|
||||
("ou_legacy",),
|
||||
_environment(routing_settings),
|
||||
)
|
||||
|
||||
assert store.path == (
|
||||
routing_settings.data_root / "state" / "notifications" / "routing.json"
|
||||
)
|
||||
assert store.path.is_file()
|
||||
assert updated_revision != revision
|
||||
assert updated["routes"]["content.marketing_report.daily"]["person_ids"] == [
|
||||
"he_yingwei",
|
||||
"wang_yunlong",
|
||||
]
|
||||
assert resolved.configured is True
|
||||
assert resolved.enabled is True
|
||||
assert resolved.app_profile == "hermes-analyzer"
|
||||
assert resolved.open_ids == (
|
||||
"ou_89bcff110ccbb09a23548dc0fb3d880c",
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_route_returns_no_recipients_and_absent_route_is_legacy(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
legacy = resolve_notification_route(
|
||||
"product.alert.daily",
|
||||
("ou_old", "ou_old", "ou_other"),
|
||||
_environment(routing_settings),
|
||||
)
|
||||
assert legacy.configured is False
|
||||
assert legacy.enabled is True
|
||||
assert legacy.open_ids == ("ou_old", "ou_other")
|
||||
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["product.alert.daily"] = _route(
|
||||
enabled=False,
|
||||
person_ids=[],
|
||||
)
|
||||
store.update(payload, expected_revision=revision)
|
||||
|
||||
disabled = resolve_notification_route(
|
||||
"product.alert.daily",
|
||||
("ou_old",),
|
||||
_environment(routing_settings),
|
||||
)
|
||||
assert disabled.configured is True
|
||||
assert disabled.enabled is False
|
||||
assert disabled.open_ids == ()
|
||||
|
||||
|
||||
def test_acceptance_recipient_does_not_reenable_an_explicitly_disabled_route(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["product.alert.daily"] = _route(
|
||||
enabled=False,
|
||||
person_ids=[],
|
||||
)
|
||||
store.update(payload, expected_revision=revision)
|
||||
environment = {
|
||||
**_environment(routing_settings),
|
||||
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
||||
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": (
|
||||
"ou_8ee224968aa26a74c7d30ba27fed5eeb"
|
||||
),
|
||||
}
|
||||
|
||||
resolved = resolve_notification_route(
|
||||
"product.alert.daily",
|
||||
("ou_legacy",),
|
||||
environment,
|
||||
)
|
||||
|
||||
assert resolved.enabled is False
|
||||
assert resolved.open_ids == ()
|
||||
|
||||
|
||||
def test_app_profile_identity_is_required_and_open_ids_remain_app_scoped(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
invalid = deepcopy(payload)
|
||||
invalid["routes"]["product.market_rank"] = _route(
|
||||
app_profile="collector",
|
||||
person_ids=["he_yingwei"],
|
||||
)
|
||||
with pytest.raises(NotificationRoutingError, match="analyzer Hermes"):
|
||||
store.update(invalid, expected_revision=revision)
|
||||
assert not store.path.exists()
|
||||
|
||||
invalid_default = deepcopy(payload)
|
||||
invalid_default["app_profile"] = "collector"
|
||||
with pytest.raises(NotificationRoutingError, match="analyzer Hermes"):
|
||||
store.update(invalid_default, expected_revision=revision)
|
||||
assert not store.path.exists()
|
||||
|
||||
|
||||
def test_analyzer_capability_uses_analyzer_scoped_acceptance_recipient(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
resolved = resolve_notification_route(
|
||||
"content.marketing_report.daily",
|
||||
("ou_89bcff110ccbb09a23548dc0fb3d880c",),
|
||||
{
|
||||
**_environment(routing_settings),
|
||||
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
||||
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": (
|
||||
"ou_8ee224968aa26a74c7d30ba27fed5eeb"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved.open_ids == (ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"snapshot_workflow",
|
||||
[None, "product.alert.daily"],
|
||||
)
|
||||
def test_environment_snapshot_requires_the_exact_workflow_id(
|
||||
snapshot_workflow: str | None,
|
||||
) -> None:
|
||||
environment = {
|
||||
GYXX_NOTIFICATION_ROUTE_MODE: "enabled",
|
||||
GYXX_NOTIFICATION_APP_PROFILE: "hermes-analyzer",
|
||||
GYXX_NOTIFICATION_HERMES_PROFILE: "data-analyzer",
|
||||
GYXX_NOTIFICATION_RECIPIENTS_JSON: (
|
||||
'["ou_89bcff110ccbb09a23548dc0fb3d880c"]'
|
||||
),
|
||||
}
|
||||
if snapshot_workflow is not None:
|
||||
environment[GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID] = snapshot_workflow
|
||||
|
||||
with pytest.raises(NotificationRoutingError, match="another workflow"):
|
||||
resolve_notification_route(
|
||||
"content.marketing_report.daily",
|
||||
(),
|
||||
environment,
|
||||
)
|
||||
|
||||
|
||||
def test_unsupported_workflow_cannot_gain_a_route(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["content.metrics.daily"] = _route()
|
||||
|
||||
with pytest.raises(NotificationRoutingError, match="does not support"):
|
||||
store.update(payload, expected_revision=revision)
|
||||
assert not store.path.exists()
|
||||
|
||||
|
||||
def test_unsafe_people_fields_and_cross_person_identity_collisions_are_rejected(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
unsafe = deepcopy(payload)
|
||||
unsafe["people"]["he_yingwei"]["name"] = "何颖威\u200b"
|
||||
with pytest.raises(NotificationRoutingError, match="person name is invalid"):
|
||||
store.update(unsafe, expected_revision=revision)
|
||||
|
||||
collision = deepcopy(payload)
|
||||
collision["people"]["feng_renyun"]["bindings"]["hermes-analyzer"][
|
||||
"open_id"
|
||||
] = collision["people"]["he_yingwei"]["bindings"]["hermes-analyzer"][
|
||||
"open_id"
|
||||
]
|
||||
with pytest.raises(NotificationRoutingError, match="multiple people"):
|
||||
store.update(collision, expected_revision=revision)
|
||||
assert not store.path.exists()
|
||||
|
||||
|
||||
def test_corrupt_runtime_state_fails_closed_instead_of_using_defaults(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
store.path.parent.mkdir(parents=True)
|
||||
store.path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
with pytest.raises(NotificationRoutingError, match="JSON is invalid"):
|
||||
resolve_notification_route(
|
||||
"content.marketing_report.daily",
|
||||
("ou_legacy",),
|
||||
_environment(routing_settings),
|
||||
)
|
||||
|
||||
|
||||
def test_update_requires_current_revision_and_preserves_state_on_conflict(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["product.alert.daily"] = _route(
|
||||
person_ids=["wang_yunlong"]
|
||||
)
|
||||
|
||||
with pytest.raises(NotificationRoutingPreconditionError):
|
||||
store.update(payload, expected_revision=None)
|
||||
with pytest.raises(NotificationRoutingConflictError):
|
||||
store.update(payload, expected_revision="0" * 64)
|
||||
assert not store.path.exists()
|
||||
|
||||
_updated, current_revision = store.update(payload, expected_revision=revision)
|
||||
before = store.path.read_bytes()
|
||||
with pytest.raises(NotificationRoutingConflictError):
|
||||
store.update(payload, expected_revision=revision)
|
||||
assert store.path.read_bytes() == before
|
||||
assert current_revision != revision
|
||||
|
||||
|
||||
def test_environment_snapshot_is_stable_after_routing_file_changes(
|
||||
routing_settings: Settings,
|
||||
) -> None:
|
||||
store = NotificationRoutingStore(routing_settings)
|
||||
payload, revision = store.snapshot()
|
||||
payload["routes"]["supply.replenishment.weekly"] = _route(
|
||||
person_ids=["he_yingwei", "wang_yunlong"]
|
||||
)
|
||||
payload, revision = store.update(payload, expected_revision=revision)
|
||||
|
||||
snapshot_environment = notification_environment_for_workflow(
|
||||
"supply.replenishment.weekly",
|
||||
project_root=routing_settings.project_root,
|
||||
data_root=routing_settings.data_root,
|
||||
environment={},
|
||||
)
|
||||
payload["routes"]["supply.replenishment.weekly"] = _route(
|
||||
person_ids=["feng_renyun"]
|
||||
)
|
||||
store.update(payload, expected_revision=revision)
|
||||
|
||||
resolved = resolve_notification_route(
|
||||
"supply.replenishment.weekly",
|
||||
(),
|
||||
snapshot_environment,
|
||||
)
|
||||
assert snapshot_environment[GYXX_NOTIFICATION_ROUTE_MODE] == "enabled"
|
||||
assert (
|
||||
snapshot_environment[GYXX_NOTIFICATION_HERMES_PROFILE]
|
||||
== "data-analyzer"
|
||||
)
|
||||
assert json.loads(snapshot_environment[GYXX_NOTIFICATION_RECIPIENTS_JSON]) == [
|
||||
"ou_89bcff110ccbb09a23548dc0fb3d880c",
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
]
|
||||
assert resolved.open_ids == (
|
||||
"ou_89bcff110ccbb09a23548dc0fb3d880c",
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
)
|
||||
Reference in New Issue
Block a user