"""Unit tests for agent_team.nodes.confluence_writer (CONF_DRAFT/GATE/WRITE). Covers the three LangGraph nodes plus the conditional-edge router: * :func:`conf_draft_node` — the reasoning->JSON draft call. Pins the post-#60 contract: a small turn headroom (``max_turns == 4``, like the planner) and TOOLS-OFF (no allowed_tools / budget) — the high-cap + read-only-tools approach was reverted live in PR #61 (feedback_claude_sdk_single_shot). * :func:`conf_gate_node` — the resumable human gate. Driven through a real in-memory LangGraph (interrupt + ``Command(resume=...)``) to assert the interrupt payload ``kind`` and the approve / request_changes / abandon / unrecognized / ceiling routing. * :func:`conf_write_node` — dry-run-by-default vs. apply-flag live write through an INJECTED fake ConfluenceClient; the mermaid path routing. * :func:`route_after_conf_gate` — approve / revise / terminal mapping. """ from __future__ import annotations import json from typing import Any import pytest try: # InMemorySaver is the modern name; fall back on older langgraph. from langgraph.checkpoint.memory import InMemorySaver as _Saver except ImportError: # pragma: no cover - environment-dependent from langgraph.checkpoint.memory import MemorySaver as _Saver from langgraph.graph import END, START, StateGraph from langgraph.types import Command from agent_team import billing from agent_team.billing import BillingMode, ClaudeResult from agent_team.nodes import confluence_writer as cw from agent_team.nodes.confluence_writer import ( CONFLUENCE_APPROVAL_KIND, MAX_CONFLUENCE_GATE_VISITS, ConfluenceWriteError, conf_draft_node, conf_gate_node, conf_write_node, route_after_conf_gate, ) from agent_team.task_model import Phase, PipelineState, TaskStatus # --------------------------------------------------------------------------- # # Fixtures / helpers # --------------------------------------------------------------------------- # _VALID_DRAFT = { "title": "AWS Architecture Map — agent-team", "body_storage": "

The coordinator daemon runs on the R720.

", "page_id": "1540098", } @pytest.fixture(autouse=True) def _restore_invoker(): """Restore the module invoker after each test (mirrors test_planner).""" original = billing._invoker yield billing._invoker = original @pytest.fixture(autouse=True) def _clear_apply_env(monkeypatch): """Ensure the apply/allowlist env never leaks in from the host environment.""" monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_APPLY", raising=False) monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", raising=False) def _bind_invoker(reply: str) -> list[dict[str, Any]]: """Bind a fake Claude invoker returning ``reply``; capture its calls.""" calls: list[dict[str, Any]] = [] def _fake(prompt: str, *, mode: BillingMode, **kw: Any) -> ClaudeResult: calls.append({"prompt": prompt, "mode": mode, "kw": kw}) return ClaudeResult(text=reply, mode=mode, usage={"input_tokens": 1}) billing.set_invoker(_fake) return calls def _state(**overrides: Any) -> PipelineState: base: PipelineState = PipelineState( thread_id="t-1", status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_DRAFT_PHASE, ) base.update(overrides) # type: ignore[typeddict-item] return base class _FakeUpdateOutcome: """Stand-in for the client's PlannedPageUpdate (carries ``.applied``).""" def __init__(self, *, page_id, applied: bool) -> None: self.page_id = page_id self.applied = applied class FakeConfluenceClient: """In-memory ConfluenceClient seam: records writes, no network. Mirrors the REAL injected-client shape :func:`conf_write_node` expects: ``update_page(page_id, title, body_storage, version_number, *, apply=False)`` (the live path passes ``apply=True`` and a ``version_number`` it reads off ``get_page``), and an optional ``page_has_macros`` capability drives the mermaid-routing branch. ``get_page`` returns the current version so the node can derive ``version_number``. """ def __init__(self, *, has_macros: bool = False, current_version: int = 7) -> None: self.update_calls: list[dict[str, Any]] = [] self.macro_checks: list[Any] = [] self.get_page_calls: list[Any] = [] self._has_macros = has_macros self._current_version = current_version def get_page(self, page_id) -> dict[str, Any]: self.get_page_calls.append(page_id) return { "id": page_id, "version": {"number": self._current_version}, "body": {"storage": {"value": "

old

"}}, } def update_page( self, *, page_id, title, body_storage, version_number, apply=False ) -> _FakeUpdateOutcome: self.update_calls.append( { "page_id": page_id, "title": title, "body_storage": body_storage, "version_number": version_number, "apply": apply, } ) return _FakeUpdateOutcome(page_id=page_id or "new-123", applied=apply) def page_has_macros(self, page_id) -> bool: self.macro_checks.append(page_id) return self._has_macros class FakeAdfConfluenceClient(FakeConfluenceClient): """Fake client that ALSO exposes the ADF seam the mermaid live path needs. ``get_page_adf`` returns a parsed ADF doc; ``update_page_adf`` records the persisted doc and returns an outcome carrying ``.applied`` so the mermaid branch reports its applied state honestly. """ def __init__(self, *, adf: dict[str, Any] | None = None, **kw: Any) -> None: super().__init__(**kw) self.adf_get_calls: list[Any] = [] self.adf_put_calls: list[dict[str, Any]] = [] self._adf = adf or {"type": "doc", "version": 1, "content": []} def get_page_adf(self, page_id) -> dict[str, Any]: self.adf_get_calls.append(page_id) return self._adf def update_page_adf(self, page_id, new_adf) -> _FakeUpdateOutcome: self.adf_put_calls.append({"page_id": page_id, "new_adf": new_adf}) return _FakeUpdateOutcome(page_id=page_id, applied=True) # A minimal single-node graph wrapping conf_gate_node so interrupt()/resume work # against a real checkpointer (mirrors the e2e harness, lighter). def _gate_graph(): builder: StateGraph = StateGraph(PipelineState) builder.add_node("gate", conf_gate_node) builder.add_edge(START, "gate") builder.add_edge("gate", END) return builder.compile(checkpointer=_Saver()) def _run_to_interrupt(graph, state: PipelineState, thread_id: str = "t-1") -> dict: """Invoke the gate graph; return the single interrupt payload.""" cfg = {"configurable": {"thread_id": thread_id}} result = graph.invoke(state, cfg) interrupts = result["__interrupt__"] assert len(interrupts) == 1 return interrupts[0].value def _resume(graph, decision: Any, thread_id: str = "t-1") -> dict: """Resume the suspended gate graph with ``decision``; return final state.""" cfg = {"configurable": {"thread_id": thread_id}} return graph.invoke(Command(resume=decision), cfg) # --------------------------------------------------------------------------- # # conf_draft_node — agentic-invoker contract (PR #61) # --------------------------------------------------------------------------- # def test_conf_draft_node_builds_draft_and_advances_to_gate() -> None: _bind_invoker(json.dumps(_VALID_DRAFT)) out = conf_draft_node(_state(task="document the agent-team stack")) assert out["current_phase"] == cw.CONF_GATE_PHASE assert out["status"] == TaskStatus.ACTIVE.value assert out["confluence_draft"]["title"] == _VALID_DRAFT["title"] assert out["confluence_draft"]["page_id"] == "1540098" def test_conf_draft_node_passes_reasoning_turn_headroom() -> None: # The single-shot default (max_turns=1) crashes reasoning nodes; the draft # asks for the same small headroom the planner uses (4) — NOT a high agentic # cap (the merged #60 fix / PR #61 reverted the high-cap + tools approach). calls = _bind_invoker(json.dumps(_VALID_DRAFT)) conf_draft_node(_state(task="x")) assert calls[0]["kw"].get("max_turns", 1) == 4 def test_conf_draft_node_runs_tools_off() -> None: # Load-bearing pin (memory feedback_claude_sdk_single_shot): the draft is a # reasoning->JSON node and must run TOOLS-OFF. Giving it repo tools was proven # live to exhaust the turn cap / return narration (#60). Any repo context goes # INTO the prompt, never via Claude tools — so no allowed_tools, no budget. calls = _bind_invoker(json.dumps(_VALID_DRAFT)) conf_draft_node(_state(task="x")) assert not calls[0]["kw"].get("allowed_tools") assert "budget_usd" not in calls[0]["kw"] def test_conf_draft_node_forwards_config_to_billing_seam() -> None: calls = _bind_invoker(json.dumps(_VALID_DRAFT)) conf_draft_node(_state(task="x"), config={"billing_mode": "api"}) assert calls[0]["mode"] is BillingMode.API def test_conf_draft_node_sends_task_into_prompt() -> None: calls = _bind_invoker(json.dumps(_VALID_DRAFT)) conf_draft_node(_state(task="UNIQUE-DOC-MARKER")) assert "UNIQUE-DOC-MARKER" in calls[0]["prompt"] def test_conf_draft_node_garbled_reply_raises() -> None: from agent_team.nodes.confluence_writer_llm import ConfluenceDraftError _bind_invoker("not json at all") with pytest.raises(ConfluenceDraftError): conf_draft_node(_state(task="x")) # --------------------------------------------------------------------------- # # conf_gate_node — interrupt payload + resume routing (real graph) # --------------------------------------------------------------------------- # def test_conf_gate_interrupts_with_confluence_kind() -> None: graph = _gate_graph() payload = _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT))) assert payload["kind"] == CONFLUENCE_APPROVAL_KIND assert payload["kind"] == "confluence_approval" assert payload["thread_id"] == "t-1" assert payload["confluence_draft"]["title"] == _VALID_DRAFT["title"] assert "preview" in payload and _VALID_DRAFT["title"] in payload["preview"] # The gate turn lives in the high disjoint namespace (>= the plan-gate base). assert payload["turn"] >= cw._CONF_GATE_TURN_BASE # The coordinator notify path requires a question_set; it must carry the same # ids/turn and a single decision question whose prompt is the draft preview. qs = payload["question_set"] assert qs.thread_id == "t-1" assert qs.question_id == payload["question_id"] assert qs.turn == payload["turn"] assert len(qs.questions) == 1 assert _VALID_DRAFT["title"] in qs.questions[0] assert "approve" in qs.questions[0] and "abandon" in qs.questions[0] assert qs.context["kind"] == CONFLUENCE_APPROVAL_KIND def test_conf_gate_approve_routes_to_write() -> None: graph = _gate_graph() _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT))) out = _resume(graph, {"decision": "approve", "notes": ""}) assert out["current_phase"] == cw.CONF_WRITE_PHASE assert out["status"] == TaskStatus.ACTIVE.value assert out["confluence_gate_visits"] == 1 def test_conf_gate_request_changes_loops_to_draft_with_feedback() -> None: graph = _gate_graph() _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT))) out = _resume(graph, {"decision": "request_changes", "notes": "fix the title"}) assert out["current_phase"] == cw.CONF_DRAFT_PHASE assert out["status"] == TaskStatus.ACTIVE.value assert out["confluence_feedback"] == "fix the title" assert out["confluence_gate_visits"] == 1 def test_conf_gate_abandon_fails_with_reason() -> None: graph = _gate_graph() _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT))) out = _resume(graph, {"decision": "abandon", "notes": "drop it"}) assert out["status"] == TaskStatus.FAILED.value assert out["current_phase"] == Phase.PARKED.value assert "abandoned" in (out.get("failure_reason") or "") def test_conf_gate_unrecognized_decision_maps_to_request_changes() -> None: # Free-text prose (no recognized verb) must fail SAFE to request_changes — # never an accidental approve or silent abandon (mirrors the plan gate). graph = _gate_graph() _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT))) out = _resume(graph, "please tighten the architecture section") assert out["current_phase"] == cw.CONF_DRAFT_PHASE assert out["status"] == TaskStatus.ACTIVE.value assert "tighten the architecture section" in out["confluence_feedback"] def test_conf_gate_ceiling_parks_without_interrupt() -> None: # At the visit ceiling the gate does NOT interrupt: it returns terminal PARKED # so the human loop always terminates. out = conf_gate_node( _state( confluence_draft=dict(_VALID_DRAFT), confluence_gate_visits=MAX_CONFLUENCE_GATE_VISITS, ) ) assert out["status"] == TaskStatus.PARKED.value assert out["current_phase"] == Phase.PARKED.value assert "ceiling" in (out.get("failure_reason") or "") def test_conf_gate_visits_bump_monotonically_across_loops() -> None: graph = _gate_graph() _run_to_interrupt( graph, _state(confluence_draft=dict(_VALID_DRAFT), confluence_gate_visits=1), thread_id="t-loop", ) out = _resume(graph, {"decision": "approve"}, thread_id="t-loop") assert out["confluence_gate_visits"] == 2 # --------------------------------------------------------------------------- # # route_after_conf_gate # --------------------------------------------------------------------------- # def test_route_after_conf_gate_approve() -> None: state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_WRITE_PHASE) assert route_after_conf_gate(state) == "approve" def test_route_after_conf_gate_revise() -> None: state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_DRAFT_PHASE) assert route_after_conf_gate(state) == "revise" def test_route_after_conf_gate_terminal_on_failed() -> None: state = _state(status=TaskStatus.FAILED.value, current_phase=Phase.PARKED.value) assert route_after_conf_gate(state) == "terminal" def test_route_after_conf_gate_terminal_on_parked() -> None: state = _state(status=TaskStatus.PARKED.value, current_phase=Phase.PARKED.value) assert route_after_conf_gate(state) == "terminal" # --------------------------------------------------------------------------- # # conf_write_node — dry-run default vs. apply flag, injected client # --------------------------------------------------------------------------- # def test_conf_write_dry_run_writes_nothing_by_default() -> None: client = FakeConfluenceClient() out = conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client) assert client.update_calls == [] # NOTHING was written assert out["status"] == TaskStatus.DONE.value assert out["current_phase"] == cw.CONF_DONE_PHASE result = out["confluence_result"] assert result["applied"] is False assert result["dry_run"] is True assert result["action"] == "update" assert "planned_change" in result and "revert_diff" in result def test_conf_write_dry_run_create_action_when_no_page_id() -> None: draft = {k: v for k, v in _VALID_DRAFT.items() if k != "page_id"} out = conf_write_node(_state(confluence_draft=draft), client=FakeConfluenceClient()) assert out["confluence_result"]["action"] == "create" def test_conf_write_apply_via_config_calls_client() -> None: client = FakeConfluenceClient() out = conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=client, ) assert len(client.update_calls) == 1 call = client.update_calls[0] assert call["page_id"] == "1540098" # The live path MUST pass apply=True and a version_number (read off get_page); # without both the client's update_page raises / never writes (the defect). assert call["apply"] is True assert call["version_number"] == 7 # FakeConfluenceClient.get_page current assert client.get_page_calls == ["1540098"] result = out["confluence_result"] # ``applied`` is DERIVED from the returned outcome (.applied), not hardcoded. assert result["applied"] is True assert result["dry_run"] is False assert out["status"] == TaskStatus.DONE.value def test_conf_write_apply_via_env_calls_client(monkeypatch) -> None: monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_APPLY", "1") client = FakeConfluenceClient() conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client) assert len(client.update_calls) == 1 def test_conf_write_missing_draft_raises() -> None: with pytest.raises(ConfluenceWriteError): conf_write_node(_state(confluence_draft={"title": "only title"})) def test_conf_write_mermaid_path_only_when_edits_and_macros(monkeypatch) -> None: # The mermaid route fires ONLY when the draft has edits AND the page carries # macros. plan_mermaid_edits is PURE ADF: (adf, edits, *, apply=False). We # assert routing by patching it and checking it received the parsed ADF plus # MermaidEdit objects converted from the draft dicts. import agent_team.confluence.mermaid as mermaid_mod routed: dict[str, Any] = {} def _fake_plan(adf, edits=None, *, apply=False): routed["called"] = True routed["adf"] = adf routed["edits"] = edits routed["apply"] = apply return mermaid_mod.MermaidEditResult( macro_count=1, new_adf=adf, revert_diff=[], skip_mermaid=False ) monkeypatch.setattr(mermaid_mod, "plan_mermaid_edits", _fake_plan) draft = dict(_VALID_DRAFT) draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}] adf_doc = {"type": "doc", "version": 1, "content": ["macro"]} client = FakeAdfConfluenceClient(has_macros=True, adf=adf_doc) out = conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=client, ) assert routed.get("called") is True assert routed["apply"] is True assert routed["adf"] is adf_doc # pure ADF fetched via get_page_adf # draft edit dicts were converted to MermaidEdit(macro_key, new_source). assert routed["edits"][0].macro_key == "m1" assert routed["edits"][0].new_source == "graph TD; A-->B" assert client.adf_get_calls == ["1540098"] assert len(client.adf_put_calls) == 1 # persisted via update_page_adf assert client.update_calls == [] # NOT routed through storage update_page assert out["confluence_result"]["applied"] is True def test_conf_write_mermaid_apply_without_adf_seam_raises() -> None: # The base fake client has NO ADF persistence (get_page_adf/update_page_adf): # a Mermaid live apply MUST fail loudly rather than falsely record success # (ADF-only edits cannot round-trip through storage without dropping macros). draft = dict(_VALID_DRAFT) draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}] client = FakeConfluenceClient(has_macros=True) with pytest.raises(ConfluenceWriteError, match="ADF persistence"): conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=client, ) def test_conf_write_applied_reflects_outcome_not_hardcoded() -> None: # The result's ``applied`` is DERIVED from the returned outcome, so a client # that declines to apply is reported as applied=False (never a FALSE success). class _DeclineClient(FakeConfluenceClient): def update_page( self, *, page_id, title, body_storage, version_number, apply=False ): self.update_calls.append({"page_id": page_id, "apply": apply}) return _FakeUpdateOutcome(page_id=page_id, applied=False) out = conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=_DeclineClient(), ) assert out["confluence_result"]["applied"] is False assert out["confluence_result"]["dry_run"] is False def test_conf_write_no_mermaid_route_when_page_lacks_macros() -> None: # Edits present but the page has NO macros: fall back to a plain body update. draft = dict(_VALID_DRAFT) draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}] client = FakeConfluenceClient(has_macros=False) conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=client, ) assert len(client.update_calls) == 1 # fell back to update_page assert client.macro_checks == ["1540098"] # the probe ran def test_conf_write_no_mermaid_route_without_edits() -> None: # No mermaid edits at all: plain body update even on a macro page. client = FakeConfluenceClient(has_macros=True) conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=client, ) assert len(client.update_calls) == 1 def test_conf_write_client_failure_normalized_to_write_error() -> None: class _BoomClient(FakeConfluenceClient): def update_page(self, **kw): raise RuntimeError("network down") with pytest.raises(ConfluenceWriteError, match="confluence write failed"): conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=_BoomClient(), ) # --------------------------------------------------------------------------- # # Macro-preservation guard — a storage write must NEVER drop diagram macros # (the page-1540098 data-loss class; security-review BLOCKER fix) # --------------------------------------------------------------------------- # class _MacroBodyClient(FakeConfluenceClient): """Fake whose current page body carries Confluence macros (storage XHTML).""" def __init__(self, *, current_macros: int = 1, **kw: Any) -> None: super().__init__(**kw) self._body = "".join( f'' f'graph TD; A-->B{i}' "" for i in range(current_macros) ) def get_page(self, page_id) -> dict[str, Any]: self.get_page_calls.append(page_id) return { "id": page_id, "version": {"number": self._current_version}, "body": {"storage": {"value": self._body}}, } def test_conf_write_storage_refuses_to_drop_macros() -> None: # BLOCKER fix: the live page has a Mermaid macro; the model-authored body # (plain

) has none. A wholesale storage PUT would erase the diagram, so # the write must REFUSE rather than overwrite (no update_page issued). client = _MacroBodyClient(current_macros=1) with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"): conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=client, ) assert client.update_calls == [] # nothing was written def test_conf_write_storage_refuses_macro_swap_at_equal_count() -> None: # Identity-aware guard: the proposed body DROPS the Mermaid macro but ADDS an # unrelated macro, keeping the raw count equal (1 -> 1). A count-only guard # would wave this through; the signature guard must still refuse. client = _MacroBodyClient(current_macros=1) # current: one mermaid-cloud macro draft = dict(_VALID_DRAFT) draft["body_storage"] = ( '' "

note

" ) with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"): conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=client, ) assert client.update_calls == [] def test_conf_write_storage_allows_when_macros_preserved() -> None: # When the new body reproduces the macro count, the write proceeds. client = _MacroBodyClient(current_macros=1) draft = dict(_VALID_DRAFT) draft["body_storage"] = ( '' 'graph TD; A-->B0_edited' "" ) conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=client, ) assert len(client.update_calls) == 1 def test_page_has_macros_fails_closed_without_capability() -> None: # A client with NO page_has_macros method must be probed via the storage body # and detect macros there (fail-closed), routing a macro page with edits into # the ADF path (which raises here, since this client has no ADF seam) rather # than the destructive storage overwrite. class _NoCapMacroClient(_MacroBodyClient): page_has_macros = None # type: ignore[assignment] draft = dict(_VALID_DRAFT) draft["mermaid_edits"] = [{"macro_id": "m0", "mermaid": "graph TD; A-->B"}] with pytest.raises(ConfluenceWriteError, match="ADF persistence"): conf_write_node( _state(confluence_draft=draft), config={"confluence_apply": True}, client=_NoCapMacroClient(), ) # --------------------------------------------------------------------------- # # Page allowlist (AUTHZ-CONF-01 defense-in-depth) + fail-honest applied default # --------------------------------------------------------------------------- # def test_conf_write_refuses_page_outside_allowlist(monkeypatch) -> None: monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "999,1234") client = FakeConfluenceClient() with pytest.raises(ConfluenceWriteError, match="not in the .*allowlist"): conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), # page_id 1540098 config={"confluence_apply": True}, client=client, ) assert client.update_calls == [] def test_conf_write_allows_page_on_allowlist(monkeypatch) -> None: monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "1540098, 999") client = FakeConfluenceClient() conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=client, ) assert len(client.update_calls) == 1 def test_conf_write_applied_defaults_false_when_attr_missing() -> None: # Fail-honest: an outcome object lacking ``.applied`` must NOT be reported as # a successful write (was: defaulted True). class _AttrlessOutcome: page_id = "1540098" class _AttrlessClient(FakeConfluenceClient): def update_page(self, **kw): self.update_calls.append(kw) return _AttrlessOutcome() out = conf_write_node( _state(confluence_draft=dict(_VALID_DRAFT)), config={"confluence_apply": True}, client=_AttrlessClient(), ) assert out["confluence_result"]["applied"] is False