Interactive test_notify Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add an interactive wizard to test_notify that discovers channels, guides config, and supports adjust-and-retry — with --non-interactive flag to preserve current behavior.
Architecture: Overlay wizard on existing command. handle() routes to _handle_interactive() (default) or _handle_non_interactive() (flag). Wizard reuses existing _build_*_config helpers and NotifySelector.resolve(). Interactive prompts follow the same _prompt_choice / _prompt_input pattern used in setup_instance.
Tech Stack: Django management commands, input() for prompts, unittest.mock.patch("builtins.input") for testing.
Task 1: Rename existing handle() to _handle_non_interactive and add routing
Files:
- Modify:
apps/notify/management/commands/test_notify.py:123-202 - Test:
apps/notify/_tests/test_test_notify.py(create)
Step 1: Write failing tests for non-interactive mode
Create apps/notify/_tests/test_test_notify.py:
"""Tests for the test_notify management command."""
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase
from apps.notify.models import NotificationChannel
class NonInteractiveTests(TestCase):
"""Tests for --non-interactive flag preserving existing behavior."""
def test_non_interactive_with_db_channel_sends(self):
"""--non-interactive with a named DB channel sends via that channel."""
NotificationChannel.objects.create(
name="ops-slack",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
description="[setup_wizard] slack channel",
)
out = StringIO()
with patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "test-123", "metadata": {}},
):
call_command(
"test_notify",
"ops-slack",
"--non-interactive",
stdout=out,
)
self.assertIn("successfully", out.getvalue())
def test_non_interactive_unknown_driver_raises(self):
"""--non-interactive with unknown driver raises CommandError."""
out = StringIO()
err = StringIO()
with self.assertRaises(Exception):
call_command(
"test_notify",
"nonexistent",
"--non-interactive",
stdout=out,
stderr=err,
)
def test_default_mode_is_interactive(self):
"""Running without --non-interactive enters interactive mode."""
NotificationChannel.objects.create(
name="ops-slack",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
# Mock input() to select channel 1, accept defaults, then "done"
with patch(
"builtins.input",
side_effect=["1", "", "", "", "3"],
), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
):
call_command("test_notify", stdout=out)
self.assertIn("Test Notification Wizard", out.getvalue())
Step 2: Run tests to verify they fail
Run: uv run pytest apps/notify/_tests/test_test_notify.py -v Expected: FAIL — test_non_interactive_with_db_channel_sends fails because --non-interactive flag doesn’t exist yet. test_default_mode_is_interactive fails because wizard doesn’t exist yet.
Step 3: Add –non-interactive flag and rename handle()
In apps/notify/management/commands/test_notify.py:
- Add
--non-interactiveargument inadd_arguments:
parser.add_argument(
"--non-interactive",
action="store_true",
help="Skip interactive wizard; use CLI flags only (for scripts/CI).",
)
- Rename
handle()→_handle_non_interactive(). Create newhandle()that routes:
def handle(self, *args, **options):
if options.get("non_interactive"):
self._handle_non_interactive(*args, **options)
else:
self._handle_interactive(**options)
def _handle_interactive(self, **options):
"""Interactive wizard — placeholder for Task 2."""
self.stdout.write(self.style.HTTP_INFO("\n=== Test Notification Wizard ===\n"))
self.stderr.write(self.style.WARNING("Interactive mode not yet implemented."))
def _handle_non_interactive(self, *args, **options):
# ... existing handle() body, unchanged ...
Step 4: Run tests to verify the first two pass
Run: uv run pytest apps/notify/_tests/test_test_notify.py::NonInteractiveTests -v Expected: test_non_interactive_with_db_channel_sends and test_non_interactive_unknown_driver_raises PASS. test_default_mode_is_interactive still fails (placeholder).
Step 5: Commit
git add apps/notify/management/commands/test_notify.py apps/notify/_tests/test_test_notify.py
git commit -m "refactor: rename handle() to _handle_non_interactive, add --non-interactive flag"
Task 2: Implement _select_channel — channel discovery and selection
Files:
- Modify:
apps/notify/management/commands/test_notify.py - Test:
apps/notify/_tests/test_test_notify.py
Step 1: Write failing tests for channel selection
Add to apps/notify/_tests/test_test_notify.py:
class SelectChannelTests(TestCase):
"""Tests for interactive channel selection."""
def _call_interactive(self, inputs, channels=None):
"""Helper: create channels, mock input, call command, return stdout."""
for ch in (channels or []):
NotificationChannel.objects.create(**ch)
out = StringIO()
with patch("builtins.input", side_effect=inputs), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.send",
return_value={"success": True, "message_id": "m2", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.validate_config",
return_value=True,
):
call_command("test_notify", stdout=out)
return out.getvalue()
def test_lists_active_channels(self):
"""Wizard lists active DB channels with index numbers."""
output = self._call_interactive(
# Select channel 1, accept defaults, done
["1", "", "", "", "3"],
channels=[
{
"name": "ops-slack",
"driver": "slack",
"config": {"webhook_url": "https://hooks.slack.com/services/T/B/X"},
},
],
)
self.assertIn("ops-slack", output)
self.assertIn("slack", output)
def test_no_channels_shows_configure_only(self):
"""When no active channels exist, only 'Configure new' is shown."""
output = self._call_interactive(
# Only option is "configure new" → pick generic → endpoint → accept defaults → done
["1", "generic", "https://example.com/hook", "", "", "", "3"],
)
self.assertIn("Configure a new driver", output)
def test_invalid_selection_retries(self):
"""Invalid selection prompts again."""
output = self._call_interactive(
# Invalid "99", then valid "1", accept defaults, done
["99", "1", "", "", "", "3"],
channels=[
{
"name": "ops-slack",
"driver": "slack",
"config": {"webhook_url": "https://hooks.slack.com/services/T/B/X"},
},
],
)
self.assertIn("ops-slack", output)
Step 2: Run tests to verify they fail
Run: uv run pytest apps/notify/_tests/test_test_notify.py::SelectChannelTests -v Expected: FAIL — _handle_interactive is a placeholder.
Step 3: Implement _select_channel and wire into _handle_interactive
Add to test_notify.py Command class:
def _prompt_choice(self, prompt, options):
"""Prompt user to select one option from a numbered list.
Args:
prompt: Question text to display.
options: List of (value, label) tuples.
Returns:
The value of the selected option.
"""
self.stdout.write(f"\n{prompt}")
for i, (_, label) in enumerate(options, 1):
self.stdout.write(f" {i}) {label}")
while True:
try:
choice = int(input("\n> "))
if 1 <= choice <= len(options):
return options[choice - 1][0]
except (ValueError, IndexError):
pass
self.stdout.write(
self.style.WARNING(f" Please enter 1-{len(options)}.")
)
def _prompt_input(self, prompt, default=None, required=False):
"""Prompt user for free-text input.
Args:
prompt: Question text.
default: Default value if user presses Enter.
required: If True, retry on empty input.
Returns:
User input string, or default.
"""
suffix = f" [{default}]" if default else ""
while True:
value = input(f"{prompt}{suffix}: ").strip()
if value:
return value
if default is not None:
return default
if required:
self.stdout.write(self.style.WARNING(" Value cannot be empty."))
continue
return ""
def _select_channel(self):
"""List active DB channels and let user pick one, or configure new.
Returns:
(driver_name, config, selected_label) tuple.
"""
from apps.notify.models import NotificationChannel
channels = list(
NotificationChannel.objects.filter(is_active=True).order_by("name")
)
options = []
for ch in channels:
options.append(
(ch, f"{ch.name} ({ch.driver}) — {ch.description or 'no description'}")
)
options.append(("__new__", "Configure a new driver manually"))
choice = self._prompt_choice("Active notification channels:", options)
if choice == "__new__":
return self._configure_new_driver()
# choice is a NotificationChannel instance
return choice.driver, choice.config or {}, choice.name
Update _handle_interactive:
def _handle_interactive(self, **options):
"""Interactive wizard for testing notifications."""
self.stdout.write(
self.style.HTTP_INFO("\n=== Test Notification Wizard ===\n")
)
driver_name, config, selected_label = self._select_channel()
msg_opts = self._prompt_message_options()
result = self._send_and_show_result(driver_name, config, selected_label, msg_opts)
# Post-send loop
while True:
action = self._post_send_loop()
if action == "done":
break
elif action == "retry":
msg_opts = self._prompt_message_options(defaults=msg_opts)
result = self._send_and_show_result(
driver_name, config, selected_label, msg_opts
)
elif action == "switch":
driver_name, config, selected_label = self._select_channel()
msg_opts = self._prompt_message_options(defaults=msg_opts)
result = self._send_and_show_result(
driver_name, config, selected_label, msg_opts
)
Add stubs for _configure_new_driver, _prompt_message_options, _send_and_show_result, _post_send_loop — implement in subsequent tasks.
For this task, stub them minimally so channel selection tests pass:
def _configure_new_driver(self):
"""Prompt for driver type and config — implemented in Task 3."""
driver = self._prompt_choice(
"Select driver:", [(d, d) for d in DRIVER_REGISTRY]
)
config = self._build_config_interactive(driver)
return driver, config, driver
def _build_config_interactive(self, driver_name):
"""Prompt for driver-specific config fields."""
if driver_name == "slack":
return {"webhook_url": self._prompt_input("Webhook URL", required=True)}
elif driver_name == "email":
return {
"smtp_host": self._prompt_input("SMTP host", required=True),
"from_address": self._prompt_input("From address", required=True),
"smtp_port": int(self._prompt_input("SMTP port", default="587")),
}
elif driver_name == "pagerduty":
return {
"integration_key": self._prompt_input("Integration key", required=True)
}
elif driver_name == "generic":
return {"endpoint": self._prompt_input("Endpoint URL", required=True)}
return {}
def _prompt_message_options(self, defaults=None):
"""Collect title, message, severity from user."""
defaults = defaults or {}
title = self._prompt_input("Title", default=defaults.get("title", "Test Alert"))
message = self._prompt_input(
"Message",
default=defaults.get(
"message", "This is a test notification from the notify app."
),
)
severity = self._prompt_input(
"Severity (critical/warning/info/success)",
default=defaults.get("severity", "info"),
)
return {"title": title, "message": message, "severity": severity}
def _send_and_show_result(self, driver_name, config, selected_label, msg_opts):
"""Send notification and display result."""
driver_class = DRIVER_REGISTRY.get(driver_name)
if not driver_class:
self.stderr.write(
self.style.ERROR(f"Unknown driver: {driver_name}")
)
return {"success": False}
driver = driver_class()
message = NotificationMessage(
title=msg_opts["title"],
message=msg_opts["message"],
severity=msg_opts["severity"],
)
if not driver.validate_config(config):
self.stderr.write(
self.style.ERROR(
f"Invalid config for {driver_name}. Missing required fields."
)
)
return {"success": False}
self.stdout.write(
self.style.WARNING(
f"\nSending test notification to {selected_label} ({driver_name})..."
)
)
result = driver.send(message, config)
success = bool(result.get("success") or (result.get("status") == "success"))
if success:
self.stdout.write(self.style.SUCCESS("\n✓ Notification sent successfully!"))
msg_id = result.get("message_id", "")
if msg_id:
self.stdout.write(f" Message ID: {msg_id}")
if result.get("metadata"):
self.stdout.write(
f" Metadata: {json.dumps(result['metadata'], indent=2)}"
)
else:
self.stdout.write(self.style.ERROR("\n✗ Failed to send notification"))
self.stdout.write(
f" Error: {result.get('error') or result.get('message') or result}"
)
return result
def _post_send_loop(self):
"""Ask what to do after sending."""
return self._prompt_choice(
"What next?",
[
("retry", "Retry with changes"),
("switch", "Send to a different channel"),
("done", "Done"),
],
)
Step 4: Run tests
Run: uv run pytest apps/notify/_tests/test_test_notify.py -v Expected: All tests pass.
Step 5: Commit
git add apps/notify/management/commands/test_notify.py apps/notify/_tests/test_test_notify.py
git commit -m "feat: add interactive channel selection wizard to test_notify"
Task 3: Add tests for configure-new-driver interactive prompts
Files:
- Test:
apps/notify/_tests/test_test_notify.py - Modify:
apps/notify/management/commands/test_notify.py(if needed)
Step 1: Write failing tests
Add to test file:
class ConfigureNewDriverTests(TestCase):
"""Tests for interactive new-driver configuration."""
def _call_interactive(self, inputs):
"""Helper: mock input, call command, return stdout."""
out = StringIO()
with patch("builtins.input", side_effect=inputs), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
), patch(
"apps.notify.drivers.email.EmailNotifyDriver.send",
return_value={"success": True, "message_id": "m2", "metadata": {}},
), patch(
"apps.notify.drivers.pagerduty.PagerDutyNotifyDriver.send",
return_value={"success": True, "message_id": "m3", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.send",
return_value={"success": True, "message_id": "m4", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.validate_config",
return_value=True,
), patch(
"apps.notify.drivers.pagerduty.PagerDutyNotifyDriver.validate_config",
return_value=True,
):
call_command("test_notify", stdout=out)
return out.getvalue()
def test_configure_slack(self):
"""Configuring new slack driver prompts for webhook URL."""
# No DB channels → "Configure new" is option 1 → pick slack (1) → webhook → defaults → done
output = self._call_interactive(
[
"1", # Configure new
"1", # slack (first in DRIVER_REGISTRY)
"https://hooks.slack.com/services/T/B/X", # webhook_url
"", "", "", # title, message, severity defaults
"3", # done
]
)
self.assertIn("successfully", output)
def test_configure_generic(self):
"""Configuring new generic driver prompts for endpoint."""
output = self._call_interactive(
[
"1", # Configure new
"4", # generic (4th in registry)
"https://example.com/hook", # endpoint
"", "", "", # defaults
"3", # done
]
)
self.assertIn("successfully", output)
def test_configure_pagerduty(self):
"""Configuring new pagerduty driver prompts for integration key."""
output = self._call_interactive(
[
"1", # Configure new
"3", # pagerduty
"abcdefghijklmnopqrstuvwxyz", # integration_key (20+ chars)
"", "", "", # defaults
"3", # done
]
)
self.assertIn("successfully", output)
Step 2: Run tests to verify
Run: uv run pytest apps/notify/_tests/test_test_notify.py::ConfigureNewDriverTests -v Expected: PASS if Task 2 stubs are correct. If FAIL, adjust _build_config_interactive and driver registry ordering.
Note: The DRIVER_REGISTRY is a dict. In Python 3.7+ dicts are insertion-ordered, so the order is: email(1), slack(2), pagerduty(3), generic(4). Adjust the test input indices accordingly:
Check the actual order in test_notify.py lines 23-28:
DRIVER_REGISTRY = {
"email": EmailNotifyDriver, # 1
"slack": SlackNotifyDriver, # 2
"pagerduty": PagerDutyNotifyDriver, # 3
"generic": GenericNotifyDriver, # 4
}
So slack=2, pagerduty=3, generic=4. Fix the test indices in side_effect lists.
Step 3: Fix tests if needed, then commit
git add apps/notify/_tests/test_test_notify.py
git commit -m "test: add configure-new-driver interactive tests"
Task 4: Add tests for message prompts and send result display
Files:
- Test:
apps/notify/_tests/test_test_notify.py - Modify:
apps/notify/management/commands/test_notify.py(if needed)
Step 1: Write tests for message options and result display
Add to test file:
class MessagePromptTests(TestCase):
"""Tests for message option prompting."""
def test_defaults_accepted_on_enter(self):
"""Pressing Enter accepts default title, message, severity."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch("builtins.input", side_effect=["1", "", "", "", "3"]), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
) as mock_send:
call_command("test_notify", stdout=out)
# Verify defaults were passed to send
msg = mock_send.call_args[0][0]
self.assertEqual(msg.title, "Test Alert")
self.assertEqual(msg.severity, "info")
def test_custom_values_used(self):
"""Custom title, message, severity are passed to the driver."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch(
"builtins.input",
side_effect=["1", "Deploy Alert", "Deploying v2.0", "warning", "3"],
), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
) as mock_send:
call_command("test_notify", stdout=out)
msg = mock_send.call_args[0][0]
self.assertEqual(msg.title, "Deploy Alert")
self.assertEqual(msg.message, "Deploying v2.0")
self.assertEqual(msg.severity, "warning")
class SendResultDisplayTests(TestCase):
"""Tests for send result display."""
def test_success_shows_message_id(self):
"""Successful send shows message ID."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch("builtins.input", side_effect=["1", "", "", "", "3"]), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={
"success": True,
"message_id": "abc-123",
"metadata": {"channel": "#alerts"},
},
):
call_command("test_notify", stdout=out)
output = out.getvalue()
self.assertIn("successfully", output)
self.assertIn("abc-123", output)
def test_failure_shows_error(self):
"""Failed send shows error message."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch("builtins.input", side_effect=["1", "", "", "", "3"]), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": False, "error": "Connection refused"},
):
call_command("test_notify", stdout=out)
output = out.getvalue()
self.assertIn("Failed", output)
self.assertIn("Connection refused", output)
def test_invalid_config_shows_error(self):
"""Invalid driver config shows validation error."""
NotificationChannel.objects.create(
name="bad-ch",
driver="slack",
config={}, # missing webhook_url
)
out = StringIO()
err = StringIO()
with patch("builtins.input", side_effect=["1", "", "", "", "3"]):
call_command("test_notify", stdout=out, stderr=err)
self.assertIn("Invalid config", err.getvalue())
Step 2: Run tests
Run: uv run pytest apps/notify/_tests/test_test_notify.py::MessagePromptTests apps/notify/_tests/test_test_notify.py::SendResultDisplayTests -v Expected: PASS
Step 3: Commit
git add apps/notify/_tests/test_test_notify.py
git commit -m "test: add message prompt and send result display tests"
Task 5: Add tests for post-send loop (retry, switch, done)
Files:
- Test:
apps/notify/_tests/test_test_notify.py - Modify:
apps/notify/management/commands/test_notify.py(if needed)
Step 1: Write tests for the adjust-and-retry loop
Add to test file:
class PostSendLoopTests(TestCase):
"""Tests for the retry/switch/done loop after sending."""
def test_retry_resends_with_new_options(self):
"""Retry prompts for new options and sends again."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch(
"builtins.input",
side_effect=[
"1", # select channel
"", "", "", # accept defaults (title, msg, severity)
"1", # retry
"Retry Alert", "", "", # new title, same msg/severity
"3", # done
],
), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
) as mock_send:
call_command("test_notify", stdout=out)
# Should have been called twice
self.assertEqual(mock_send.call_count, 2)
# Second call should have new title
second_msg = mock_send.call_args_list[1][0][0]
self.assertEqual(second_msg.title, "Retry Alert")
def test_switch_channel_resends(self):
"""Switch channel prompts for new channel and sends."""
NotificationChannel.objects.create(
name="ch-a",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
NotificationChannel.objects.create(
name="ch-b",
driver="generic",
config={"endpoint": "https://example.com/hook"},
)
out = StringIO()
with patch(
"builtins.input",
side_effect=[
"1", # select ch-a
"", "", "", # defaults
"2", # switch
"2", # select ch-b
"", "", "", # defaults
"3", # done
],
), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.send",
return_value={"success": True, "message_id": "m2", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.validate_config",
return_value=True,
):
call_command("test_notify", stdout=out)
output = out.getvalue()
self.assertIn("ch-a", output)
self.assertIn("ch-b", output)
def test_done_exits_immediately(self):
"""Selecting 'Done' exits the wizard."""
NotificationChannel.objects.create(
name="test-ch",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch(
"builtins.input", side_effect=["1", "", "", "", "3"]
), patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
) as mock_send:
call_command("test_notify", stdout=out)
self.assertEqual(mock_send.call_count, 1)
Step 2: Run tests
Run: uv run pytest apps/notify/_tests/test_test_notify.py::PostSendLoopTests -v Expected: PASS
Step 3: Commit
git add apps/notify/_tests/test_test_notify.py
git commit -m "test: add post-send loop tests (retry, switch, done)"
Task 6: Verify 100% branch coverage and fix gaps
Files:
- Test:
apps/notify/_tests/test_test_notify.py - Modify:
apps/notify/management/commands/test_notify.py(if needed)
Step 1: Run coverage
uv run coverage run -m pytest apps/notify/_tests/test_test_notify.py -q
uv run coverage report --include="apps/notify/management/commands/test_notify.py" --show-missing
Step 2: Identify uncovered branches
Look at the Missing column. Common gaps:
_build_config_interactivebranches for email (smtp_port int conversion)_prompt_choiceinvalid input retry path_prompt_inputrequired=True empty retry path_handle_non_interactivewith no driver arg (fallback to first DB channel)- Result display branches (metadata present vs absent, message_id present vs absent)
Step 3: Write targeted tests for each gap
Add tests that exercise the missing branches. For example:
class EdgeCaseTests(TestCase):
"""Tests for edge cases and branch coverage."""
def test_prompt_input_required_retries_on_empty(self):
"""Required prompt retries when user enters empty string."""
# Covered by configure_new_driver tests if a required field gets empty input first
def test_non_interactive_no_driver_uses_first_channel(self):
"""--non-interactive without driver arg uses first active channel."""
NotificationChannel.objects.create(
name="alpha",
driver="slack",
config={"webhook_url": "https://hooks.slack.com/services/T/B/X"},
)
out = StringIO()
with patch(
"apps.notify.drivers.slack.SlackNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
):
call_command("test_notify", "--non-interactive", stdout=out)
self.assertIn("successfully", out.getvalue())
def test_non_interactive_json_config_override(self):
"""--non-interactive with --json-config overrides DB config."""
NotificationChannel.objects.create(
name="ops-generic",
driver="generic",
config={"endpoint": "https://old.example.com"},
)
out = StringIO()
with patch(
"apps.notify.drivers.generic.GenericNotifyDriver.send",
return_value={"success": True, "message_id": "m1", "metadata": {}},
), patch(
"apps.notify.drivers.generic.GenericNotifyDriver.validate_config",
return_value=True,
):
call_command(
"test_notify",
"generic",
"--non-interactive",
"--json-config",
'{"endpoint": "https://new.example.com"}',
stdout=out,
)
self.assertIn("successfully", out.getvalue())
Step 4: Iterate until 100%
Run coverage, add tests, repeat until 100% branch coverage.
Step 5: Commit
git add apps/notify/_tests/test_test_notify.py apps/notify/management/commands/test_notify.py
git commit -m "test: achieve 100% branch coverage for test_notify"
Task 7: Update cli.sh test_notify_menu for interactive mode
Files:
- Modify:
bin/cli.sh:699-729
Step 1: Simplify test_notify_menu
Since test_notify is now interactive by default, the cli.sh menu no longer needs to prompt for driver/channel/message. Replace the current test_notify_menu function:
Current (lines 699-729): Manually prompts for driver_name, channel, message.
New:
test_notify_menu() {
show_banner
echo -e "${BOLD}═══ Test Notification ═══${NC}"
echo ""
echo "Launch the interactive notification testing wizard."
echo ""
local options=(
"Interactive wizard - guided channel selection and testing"
"Non-interactive (specify driver) - for scripting"
"Back"
)
select opt in "${options[@]}"; do
case $REPLY in
1)
confirm_and_run "uv run python manage.py test_notify"
;;
2)
read -p "Enter driver name or channel name: " driver_name
if [ -z "$driver_name" ]; then
echo -e "${RED}Driver name required${NC}"
return
fi
confirm_and_run "uv run python manage.py test_notify $driver_name --non-interactive"
;;
3)
return
;;
*)
echo -e "${RED}Invalid option${NC}"
;;
esac
break
done
}
Step 2: Commit
git add bin/cli.sh
git commit -m "feat: update cli.sh test_notify_menu for interactive wizard"
Task 8: Update documentation files
Files:
- Modify:
apps/notify/README.md:47-183 - Modify:
docs/Setup-Guide.md(add section after line 217) - Modify:
apps/notify/agents.md(add section)
Step 1: Update apps/notify/README.md
Replace the test_notify section (line 47 onwards) to add interactive mode first, then non-interactive. Insert before the existing #### test_notify line:
After the #### \test_notify`` heading and first description line, add:
##### Interactive mode (default)
Run without arguments to launch the interactive wizard:
```bash
uv run python manage.py test_notify
The wizard guides you through:
- Channel selection — pick an existing DB channel or configure a new driver
- Message options — set title, message, and severity (with sensible defaults)
- Send and review — see success/failure, message ID, and metadata
- Adjust and retry — retry with different options or switch channels
Example session:
=== Test Notification Wizard ===
Active notification channels:
1) ops-slack (slack) — [setup_wizard] slack channel
2) Configure a new driver manually
Select channel [1]: 1
Title [Test Alert]:
Message [This is a test notification...]:
Severity (critical/warning/info/success) [info]: warning
Sending test notification to ops-slack (slack)...
✓ Notification sent successfully!
Message ID: abc-123
What next?
1) Retry with changes
2) Send to a different channel
3) Done
Select [3]: 3
Non-interactive mode
For scripts and CI, use --non-interactive to skip the wizard:
uv run python manage.py test_notify slack --non-interactive
uv run python manage.py test_notify ops-slack --non-interactive
All existing flags work in non-interactive mode (–title, –message, –severity, –webhook-url, etc.).
Add `--non-interactive` to the flag reference table:
```markdown
| `--non-interactive` | flag | — | Skip wizard; use CLI flags only |
Step 2: Update docs/Setup-Guide.md
After the “Step 9: Set up recurring monitoring with cron” section (line 217), add:
### Step 10: Test your notification channels
Verify your notification channels are working before relying on them:
```bash
uv run python manage.py test_notify
The interactive wizard lists your configured channels and lets you send test notifications. Pick a channel, customize the message if desired, and verify delivery. You can retry with different options or switch channels without re-running the command.
For scripting or CI:
uv run python manage.py test_notify ops-slack --non-interactive
**Step 3: Update apps/notify/agents.md**
Add after the "How to extend with a new driver" section:
```markdown
## Interactive test_notify wizard
The `test_notify` command runs in interactive mode by default:
- Lists active `NotificationChannel` records for selection
- Prompts for driver config when "configure new" is chosen
- Collects title, message, severity with defaults
- Shows send result with message_id and metadata
- Offers retry/switch/done loop
For automation, use `--non-interactive` flag — all existing CLI flags still work.
The interactive flow uses `_prompt_choice` and `_prompt_input` helpers (same pattern
as `setup_instance`). When testing, mock `builtins.input` and the driver's `send()` method.
Step 4: Commit
git add apps/notify/README.md docs/Setup-Guide.md apps/notify/agents.md
git commit -m "docs: update README, Setup-Guide, agents.md for interactive test_notify"
Task 9: Final verification
Step 1: Run full test suite
uv run pytest -v
Expected: All tests pass (1115+ tests).
Step 2: Run coverage on new/modified files
uv run coverage run -m pytest apps/notify/_tests/test_test_notify.py -q
uv run coverage report --include="apps/notify/management/commands/test_notify.py" --show-missing
Expected: 100% branch coverage.
Step 3: Run formatting and linting
uv run black --check .
uv run ruff check .
Expected: All clean.
Step 4: Fix any issues found, then commit
git add -A
git commit -m "chore: final verification and cleanup"