2026-05-05 05:50:01 +02:00
|
|
|
"""LearningPropagationService coverage — stub OptimalService.
|
|
|
|
|
|
|
|
|
|
LearningPropagationService is logic on top of OptimalService (the RAG layer).
|
|
|
|
|
We unit-test the wiring with a stub that records calls and returns canned
|
|
|
|
|
SearchResult lists; OptimalService itself is exercised in its own integration
|
|
|
|
|
tests.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from uuid import UUID, uuid4
|
2026-05-05 05:50:01 +02:00
|
|
|
|
|
|
|
|
import pytest
|
2026-07-07 10:09:23 +02:00
|
|
|
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
|
|
|
|
|
from roboco.models import NotificationPriority, NotificationType
|
|
|
|
|
from roboco.models.base import AgentRole
|
2026-05-05 05:50:01 +02:00
|
|
|
from roboco.models.optimal import IndexType, SearchResult
|
|
|
|
|
from roboco.services.learning import (
|
|
|
|
|
Learning,
|
|
|
|
|
LearningNotification,
|
|
|
|
|
LearningPropagationService,
|
|
|
|
|
LearningScope,
|
|
|
|
|
LearningType,
|
|
|
|
|
RecordLearningParams,
|
2026-05-06 21:02:31 +02:00
|
|
|
_LearningServiceHolder,
|
|
|
|
|
get_learning_service,
|
2026-05-05 05:50:01 +02:00
|
|
|
)
|
2026-07-07 10:09:23 +02:00
|
|
|
from sqlalchemy.sql.dml import Insert
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import AsyncIterator
|
2026-05-05 05:50:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _StubOptimal:
|
|
|
|
|
"""Records calls so tests can assert wiring."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, results: list[SearchResult] | None = None) -> None:
|
|
|
|
|
self.recorded: list[Any] = []
|
|
|
|
|
self.searches: list[dict[str, Any]] = []
|
|
|
|
|
self.search_learnings_calls: list[dict[str, Any]] = []
|
|
|
|
|
self.results = results or []
|
|
|
|
|
|
|
|
|
|
async def record_learning(self, params: Any) -> None:
|
|
|
|
|
self.recorded.append(params)
|
|
|
|
|
|
|
|
|
|
async def search(
|
|
|
|
|
self, *, query: str, index_types: list[IndexType], top_k: int
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
self.searches.append(
|
|
|
|
|
{"query": query, "index_types": index_types, "top_k": top_k}
|
|
|
|
|
)
|
|
|
|
|
return self.results
|
|
|
|
|
|
2026-05-06 00:32:52 +02:00
|
|
|
async def search_learnings(self, *, query: str, top_k: int) -> list[SearchResult]:
|
2026-05-05 05:50:01 +02:00
|
|
|
self.search_learnings_calls.append({"query": query, "top_k": top_k})
|
|
|
|
|
return self.results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def svc() -> LearningPropagationService:
|
|
|
|
|
return LearningPropagationService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_record_learning_requires_initialization(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
|
|
|
await svc.record_learning(
|
|
|
|
|
RecordLearningParams(
|
|
|
|
|
agent_id=uuid4(),
|
|
|
|
|
agent_role="developer",
|
|
|
|
|
content="x",
|
|
|
|
|
learning_type=LearningType.SOLUTION,
|
|
|
|
|
scope=LearningScope.PERSONAL,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_record_learning_personal_scope_skips_notifications(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
stub = _StubOptimal()
|
|
|
|
|
await svc.initialize(stub)
|
|
|
|
|
learning = await svc.record_learning(
|
|
|
|
|
RecordLearningParams(
|
|
|
|
|
agent_id=uuid4(),
|
|
|
|
|
agent_role="developer",
|
|
|
|
|
content="some private insight",
|
|
|
|
|
learning_type=LearningType.INSIGHT,
|
|
|
|
|
scope=LearningScope.PERSONAL,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert isinstance(learning, Learning)
|
|
|
|
|
assert learning.scope == LearningScope.PERSONAL
|
|
|
|
|
assert len(stub.recorded) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_record_learning_normalizes_string_enums(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
stub = _StubOptimal()
|
|
|
|
|
await svc.initialize(stub)
|
|
|
|
|
learning = await svc.record_learning(
|
|
|
|
|
RecordLearningParams(
|
|
|
|
|
agent_id=uuid4(),
|
|
|
|
|
agent_role="qa",
|
|
|
|
|
content="content",
|
|
|
|
|
learning_type="solution",
|
|
|
|
|
scope="personal",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert learning.learning_type == LearningType.SOLUTION
|
|
|
|
|
assert learning.scope == LearningScope.PERSONAL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_record_learning_team_scope_calls_create_notifications(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
2026-05-06 21:02:31 +02:00
|
|
|
"""Team-scope learnings call _create_notifications; best-effort logs on error."""
|
2026-05-05 05:50:01 +02:00
|
|
|
stub = _StubOptimal()
|
|
|
|
|
await svc.initialize(stub)
|
|
|
|
|
# The notifications branch will silently fail because there's no DB
|
|
|
|
|
# context inside the unit-test environment — that's fine; we just want
|
|
|
|
|
# to cover the code path.
|
|
|
|
|
learning = await svc.record_learning(
|
|
|
|
|
RecordLearningParams(
|
|
|
|
|
agent_id=uuid4(),
|
|
|
|
|
agent_role="developer",
|
|
|
|
|
content="team-scoped lesson",
|
|
|
|
|
learning_type=LearningType.PATTERN,
|
|
|
|
|
scope=LearningScope.TEAM,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert learning.scope == LearningScope.TEAM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_learnings_for_agent_requires_initialization(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
|
|
|
await svc.get_learnings_for_agent(uuid4(), "developer")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_learnings_for_agent_returns_filtered_results(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
aid = uuid4()
|
|
|
|
|
other_id = uuid4()
|
2026-05-06 00:32:52 +02:00
|
|
|
|
2026-05-05 05:50:01 +02:00
|
|
|
def _r(metadata: dict, score: float = 0.7) -> SearchResult:
|
|
|
|
|
return SearchResult(
|
|
|
|
|
content="x",
|
|
|
|
|
source="test",
|
|
|
|
|
score=score,
|
|
|
|
|
index_type=IndexType.LEARNINGS,
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
own_personal = _r(
|
|
|
|
|
{"scope": "personal", "agent_id": str(aid), "agent_role": "developer"},
|
|
|
|
|
score=0.9,
|
|
|
|
|
)
|
|
|
|
|
other_personal = _r(
|
|
|
|
|
{
|
|
|
|
|
"scope": "personal",
|
|
|
|
|
"agent_id": str(other_id),
|
|
|
|
|
"agent_role": "developer",
|
|
|
|
|
},
|
|
|
|
|
score=0.9,
|
|
|
|
|
)
|
|
|
|
|
team_visible = _r({"scope": "team", "agent_role": "developer"})
|
|
|
|
|
team_other_role = _r({"scope": "team", "agent_role": "qa"})
|
|
|
|
|
org_visible = _r({"scope": "org", "agent_role": "qa"}, score=0.5)
|
|
|
|
|
stub = _StubOptimal(
|
2026-05-06 00:32:52 +02:00
|
|
|
results=[
|
|
|
|
|
own_personal,
|
|
|
|
|
other_personal,
|
|
|
|
|
team_visible,
|
|
|
|
|
team_other_role,
|
|
|
|
|
org_visible,
|
|
|
|
|
]
|
2026-05-05 05:50:01 +02:00
|
|
|
)
|
|
|
|
|
await svc.initialize(stub)
|
|
|
|
|
out = await svc.get_learnings_for_agent(aid, "developer")
|
2026-05-06 21:02:31 +02:00
|
|
|
# Visible: own personal, team for matching role, org-anyone.
|
|
|
|
|
# Filtered out: other-personal & team-other-role.
|
2026-05-05 05:50:01 +02:00
|
|
|
contents = [r.metadata for r in out]
|
|
|
|
|
assert own_personal.metadata in contents
|
|
|
|
|
assert team_visible.metadata in contents
|
|
|
|
|
assert org_visible.metadata in contents
|
|
|
|
|
assert other_personal.metadata not in contents
|
|
|
|
|
assert team_other_role.metadata not in contents
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_similar_learnings_requires_initialization(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
with pytest.raises(RuntimeError, match="not initialized"):
|
|
|
|
|
await svc.search_similar_learnings("anything")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_similar_learnings_passes_through(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
stub = _StubOptimal(
|
|
|
|
|
results=[
|
|
|
|
|
SearchResult(
|
|
|
|
|
content="x",
|
|
|
|
|
source="test",
|
|
|
|
|
score=1.0,
|
|
|
|
|
index_type=IndexType.LEARNINGS,
|
|
|
|
|
metadata={},
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
await svc.initialize(stub)
|
|
|
|
|
out = await svc.search_similar_learnings("how to debug", top_k=3)
|
|
|
|
|
assert len(out) == 1
|
2026-05-06 21:02:31 +02:00
|
|
|
_TOP_K = 3
|
|
|
|
|
assert stub.searches[0]["top_k"] == _TOP_K
|
2026-05-05 05:50:01 +02:00
|
|
|
assert IndexType.LEARNINGS in stub.searches[0]["index_types"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_mark_learning_helpful_logs(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Just exercises the log call — no error path."""
|
|
|
|
|
await svc.mark_learning_helpful("lrn-abc", uuid4(), helpful=True)
|
|
|
|
|
await svc.mark_learning_helpful("lrn-abc", uuid4(), helpful=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_mark_learning_used_logs(svc: LearningPropagationService) -> None:
|
|
|
|
|
await svc.mark_learning_used("lrn-abc", uuid4(), context="tried this")
|
|
|
|
|
await svc.mark_learning_used("lrn-abc", uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_pending_notifications_filters_by_agent(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
aid = uuid4()
|
|
|
|
|
other_id = uuid4()
|
|
|
|
|
svc._notification_queue.append(
|
|
|
|
|
LearningNotification(
|
|
|
|
|
notification_id="n1",
|
|
|
|
|
learning_id="lrn-1",
|
|
|
|
|
target_agent_id=aid,
|
|
|
|
|
learning_summary="s",
|
|
|
|
|
reason="r",
|
|
|
|
|
created_at="2026-01-01",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
svc._notification_queue.append(
|
|
|
|
|
LearningNotification(
|
|
|
|
|
notification_id="n2",
|
|
|
|
|
learning_id="lrn-2",
|
|
|
|
|
target_agent_id=other_id,
|
|
|
|
|
learning_summary="s",
|
|
|
|
|
reason="r",
|
|
|
|
|
created_at="2026-01-01",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
pending = await svc.get_pending_notifications(aid)
|
|
|
|
|
assert len(pending) == 1
|
|
|
|
|
assert pending[0].notification_id == "n1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_pending_excludes_already_acknowledged(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
aid = uuid4()
|
|
|
|
|
svc._notification_queue.append(
|
|
|
|
|
LearningNotification(
|
|
|
|
|
notification_id="n1",
|
|
|
|
|
learning_id="lrn-1",
|
|
|
|
|
target_agent_id=aid,
|
|
|
|
|
learning_summary="s",
|
|
|
|
|
reason="r",
|
|
|
|
|
created_at="2026-01-01",
|
|
|
|
|
acknowledged=True,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
pending = await svc.get_pending_notifications(aid)
|
|
|
|
|
assert pending == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_acknowledge_notification(svc: LearningPropagationService) -> None:
|
|
|
|
|
aid = uuid4()
|
|
|
|
|
svc._notification_queue.append(
|
|
|
|
|
LearningNotification(
|
|
|
|
|
notification_id="n1",
|
|
|
|
|
learning_id="lrn-1",
|
|
|
|
|
target_agent_id=aid,
|
|
|
|
|
learning_summary="s",
|
|
|
|
|
reason="r",
|
|
|
|
|
created_at="2026-01-01",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert await svc.acknowledge_notification("n1", aid) is True
|
|
|
|
|
pending = await svc.get_pending_notifications(aid)
|
|
|
|
|
assert pending == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_acknowledge_notification_returns_false_when_missing(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert await svc.acknowledge_notification("ghost", uuid4()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_learning_stats_returns_dict_with_expected_keys(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
stats = await svc.get_learning_stats()
|
|
|
|
|
assert "total_learnings" in stats
|
|
|
|
|
assert "by_type" in stats
|
|
|
|
|
assert "by_scope" in stats
|
2026-05-06 21:02:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_learning_service_factory() -> None:
|
|
|
|
|
"""get_learning_service returns a singleton instance."""
|
|
|
|
|
_LearningServiceHolder.instance = None
|
|
|
|
|
a = await get_learning_service()
|
|
|
|
|
b = await get_learning_service()
|
|
|
|
|
assert a is b
|
|
|
|
|
_LearningServiceHolder.instance = None
|
2026-07-07 10:09:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeAgentRow:
|
|
|
|
|
"""Stand-in for an AgentTable row returned by the recipients SELECT."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, id: UUID, role: AgentRole) -> None:
|
|
|
|
|
self.id = id
|
|
|
|
|
self.role = role
|
|
|
|
|
self.slug = f"{role.value}-agent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _BulkInsertFakeDb:
|
|
|
|
|
"""Fake AsyncSession that records INSERT executes for the N+1 test.
|
|
|
|
|
|
|
|
|
|
The agent-recipient SELECT returns a canned list; every other execute
|
|
|
|
|
call is recorded so the test can count INSERTs into notifications.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, agents: list[_FakeAgentRow]) -> None:
|
|
|
|
|
self._agents = agents
|
|
|
|
|
self.execute_calls: list[Any] = []
|
|
|
|
|
self.insert_rows: list[dict[str, Any]] = []
|
|
|
|
|
self.committed = False
|
|
|
|
|
|
|
|
|
|
async def execute(self, stmt: Any, params: Any = None) -> Any:
|
|
|
|
|
self.execute_calls.append((stmt, params))
|
|
|
|
|
# Detect the notifications bulk INSERT.
|
|
|
|
|
if isinstance(stmt, Insert):
|
|
|
|
|
# params is a list of dicts for parametrized bulk insert.
|
|
|
|
|
self.insert_rows.extend(params or [])
|
|
|
|
|
return MagicMock()
|
|
|
|
|
# Agent recipients SELECT.
|
|
|
|
|
result = MagicMock()
|
|
|
|
|
result.scalars.return_value.all.return_value = list(self._agents)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
async def flush(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def commit(self) -> None:
|
|
|
|
|
self.committed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def _fake_db_ctx(db: _BulkInsertFakeDb) -> AsyncIterator[_BulkInsertFakeDb]:
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_agents(n: int) -> list[_FakeAgentRow]:
|
|
|
|
|
return [_FakeAgentRow(id=uuid4(), role=AgentRole.DEVELOPER) for _ in range(n)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_N_RECIPIENTS = 25
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_notifications_uses_one_bulk_insert_not_n(
|
|
|
|
|
svc: LearningPropagationService,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A learning broadcast to N agents issues ONE INSERT, not N.
|
|
|
|
|
|
|
|
|
|
Regression guard for the N+1 at `_create_notifications`: the old path
|
|
|
|
|
called `NotificationService._create_notification` per recipient, each
|
|
|
|
|
opening its own session/transaction. The bulk path issues a single
|
|
|
|
|
parametrized INSERT for all NotificationTable rows.
|
|
|
|
|
"""
|
|
|
|
|
agents = _make_agents(_N_RECIPIENTS)
|
|
|
|
|
db = _BulkInsertFakeDb(agents)
|
|
|
|
|
delivery_mock = MagicMock()
|
|
|
|
|
delivery_mock.deliver = AsyncMock(return_value=True)
|
|
|
|
|
|
|
|
|
|
learning = Learning(
|
|
|
|
|
learning_id="lrn-abc",
|
|
|
|
|
agent_id=uuid4(),
|
|
|
|
|
agent_role="developer",
|
|
|
|
|
content="team lesson" * 30,
|
|
|
|
|
learning_type=LearningType.PATTERN,
|
|
|
|
|
scope=LearningScope.TEAM,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.db.base.get_db_context",
|
|
|
|
|
lambda: _fake_db_ctx(db),
|
|
|
|
|
),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.notification_delivery.get_notification_delivery_service",
|
|
|
|
|
lambda _db: delivery_mock,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await svc._create_notifications(learning)
|
|
|
|
|
|
|
|
|
|
# Exactly one INSERT execute call into notifications.
|
|
|
|
|
insert_calls = [
|
|
|
|
|
(stmt, params) for stmt, params in db.execute_calls if isinstance(stmt, Insert)
|
|
|
|
|
]
|
|
|
|
|
assert len(insert_calls) == 1, (
|
|
|
|
|
f"expected 1 bulk INSERT, got {len(insert_calls)}; "
|
|
|
|
|
f"total execute calls: {len(db.execute_calls)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# All rows landed with correct recipients + payload.
|
|
|
|
|
assert len(db.insert_rows) == _N_RECIPIENTS
|
|
|
|
|
expected_ids = {a.id for a in agents}
|
|
|
|
|
actual_ids = {row["to_agents"][0] for row in db.insert_rows}
|
|
|
|
|
assert actual_ids == expected_ids
|
|
|
|
|
|
|
|
|
|
for row in db.insert_rows:
|
|
|
|
|
assert row["type"] is NotificationType.KNOWLEDGE_SHARE
|
|
|
|
|
assert row["priority"] is NotificationPriority.NORMAL
|
|
|
|
|
assert row["from_agent"] == learning.agent_id
|
|
|
|
|
assert row["subject"] == f"New Learning: {learning.learning_type.value}"
|
|
|
|
|
assert row["body"] == db.insert_rows[0]["body"]
|
|
|
|
|
assert (
|
|
|
|
|
row["requires_ack"]
|
|
|
|
|
is ACK_REQUIRED_BY_TYPE[NotificationType.KNOWLEDGE_SHARE]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Delivery invoked once per notification, commit ran once.
|
|
|
|
|
assert delivery_mock.deliver.await_count == _N_RECIPIENTS
|
|
|
|
|
assert db.committed is True
|