Add Helpdesk smoke test

This commit is contained in:
Jason Thistlethwaite
2026-04-24 23:15:07 +00:00
parent cdfa298420
commit c0904659e4
5 changed files with 507 additions and 3 deletions
+4 -2
View File
@@ -25,7 +25,7 @@ Use targeted syntax checks before committing:
ruby -c plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb ruby -c plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb
ruby -c plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb ruby -c plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb
ruby -c plugins/redmine_event_outbox/lib/redmine_event_outbox.rb ruby -c plugins/redmine_event_outbox/lib/redmine_event_outbox.rb
python3 -m py_compile redmine_outbox_worker.py reset_helpdesk_mail_settings.py validate_test_instance.py python3 -m py_compile redmine_outbox_worker.py reset_helpdesk_mail_settings.py validate_test_instance.py helpdesk_smoke_test.py
cd redMCP && php -l app/RedmineClient.php && composer validate cd redMCP && php -l app/RedmineClient.php && composer validate
``` ```
@@ -34,6 +34,7 @@ Dry-run operational helpers before applying changes:
```sh ```sh
./reset_helpdesk_mail_settings.py --dry-run ./reset_helpdesk_mail_settings.py --dry-run
./validate_test_instance.py ./validate_test_instance.py
./helpdesk_smoke_test.py
./redmine_outbox_worker.py --dry-run --batch-size 10 ./redmine_outbox_worker.py --dry-run --batch-size 10
``` ```
@@ -51,7 +52,8 @@ behavior. For mail or database helpers, validate with dry runs first, then test
against the LAN Redmine copy using controlled projects such as `fud-helpdesk` or against the LAN Redmine copy using controlled projects such as `fud-helpdesk` or
`fud-nohelpdesk`. Record notable manual validation in `docs/` when behavior or `fud-nohelpdesk`. Record notable manual validation in `docs/` when behavior or
deployment assumptions change. The post-import workflow lives in deployment assumptions change. The post-import workflow lives in
`docs/test_instance_post_import.md`. `docs/test_instance_post_import.md`; the Helpdesk/redMCP live smoke test is
documented in `docs/helpdesk_smoke_test.md`.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
+10
View File
@@ -243,6 +243,16 @@ Use the read-only validator to check the test instance without changing it:
./validate_test_instance.py ./validate_test_instance.py
``` ```
Run the Helpdesk/redMCP live smoke test after the post-import checks pass:
```sh
./helpdesk_smoke_test.py
```
That test is documented in:
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1)
Preview the affected projects and settings: Preview the affected projects and settings:
```sh ```sh
+47
View File
@@ -0,0 +1,47 @@
# Helpdesk Smoke Test
Use `helpdesk_smoke_test.py` to verify that the LAN Redmine copy, Mailpit, and
redMCP work together on real Helpdesk issues.
The test creates one inbound Helpdesk email for `fud-helpdesk`, triggers
Helpdesk mail import, fetches the created issue through
`RedMCP\RedmineClient::issueWithHelpdesk()`, performs a non-Helpdesk CRUD
control in `fud-nohelpdesk`, sends a Helpdesk reply through the same mailer path
used by the UI's "Send note" checkbox, verifies the outbound message arrives in
Mailpit, and closes the Helpdesk issue.
## Run
```sh
./helpdesk_smoke_test.py
```
Defaults:
- Redmine: `http://192.168.50.170`
- Mailpit: `192.168.1.105`, SMTP `1025`, HTTP `8025`
- Helpdesk project: `fud-helpdesk`
- CRUD control project: `fud-nohelpdesk`
- API key source: `REDMINE_API_KEY`, `REDMNINE_API_KEY`, or `redMCP/.env`
The script runs `./validate_test_instance.py` first. To skip that preflight
while debugging:
```sh
./helpdesk_smoke_test.py --skip-preflight
```
## Expected Result
The final output should include:
```text
[OK] redMCP issueWithHelpdesk returned ticket and message context
[OK] redMCP non-Helpdesk CRUD control passed
[OK] Mailpit received outbound Helpdesk/Redmine mail containing the reply token
[OK] Closed smoke-test Helpdesk issue
Smoke test passed.
```
Closed smoke-test tickets are intentionally retained in `fud-helpdesk` as audit
evidence. Their subjects start with `[redMCP-smoke ...]`.
+13 -1
View File
@@ -123,7 +123,19 @@ php -l redMCP/app/redmineClient.php
Use the existing redMCP examples in `redMCP/README.md` for read and CRUD smoke Use the existing redMCP examples in `redMCP/README.md` for read and CRUD smoke
tests against the LAN Redmine copy. tests against the LAN Redmine copy.
## 7. Re-Run The Read-Only Validator ## 7. Run The Helpdesk Smoke Test
After the post-import checks pass, run the live Helpdesk/redMCP smoke test:
```sh
./helpdesk_smoke_test.py
```
This imports a controlled Helpdesk email, verifies `issueWithHelpdesk()`, checks
non-Helpdesk CRUD, verifies outbound Mailpit delivery, and closes the created
test ticket. Details are in `docs/helpdesk_smoke_test.md`.
## 8. Re-Run The Read-Only Validator
Finish by running: Finish by running:
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""End-to-end Helpdesk smoke test for the LAN Redmine copy.
The test intentionally creates one Helpdesk issue in the controlled
``fud-helpdesk`` project and leaves it closed as audit evidence.
"""
from __future__ import annotations
import argparse
import json
import os
import smtplib
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from email.message import EmailMessage
from pathlib import Path
from typing import Any
DEFAULT_REDMINE_URL = "http://192.168.50.170"
DEFAULT_MAILPIT_HOST = "192.168.1.105"
DEFAULT_MAILPIT_HTTP_PORT = 8025
DEFAULT_MAILPIT_SMTP_PORT = 1025
DEFAULT_PROJECT = "fud-helpdesk"
DEFAULT_CONTROL_PROJECT = "fud-nohelpdesk"
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
DEFAULT_SSH_KEY = Path("/tmp/reddev")
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
class SmokeError(RuntimeError):
pass
@dataclass(frozen=True)
class SmokeConfig:
redmine_url: str
api_key: str
mailpit_host: str
mailpit_http_port: int
mailpit_smtp_port: int
project: str
control_project: str
ssh_host: str
ssh_key: Path
remote_redmine: str
skip_preflight: bool
keep_open: bool
def main() -> int:
parser = argparse.ArgumentParser(description="Run a live Helpdesk/redMCP smoke test against the LAN Redmine copy.")
parser.add_argument("--redmine-url", default=os.getenv("REDMINE_URL", DEFAULT_REDMINE_URL))
parser.add_argument("--api-key", default=os.getenv("REDMINE_API_KEY") or os.getenv("REDMNINE_API_KEY"))
parser.add_argument("--env-file", type=Path, default=Path("redMCP/.env"))
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
parser.add_argument("--mailpit-http-port", type=int, default=DEFAULT_MAILPIT_HTTP_PORT)
parser.add_argument("--mailpit-smtp-port", type=int, default=DEFAULT_MAILPIT_SMTP_PORT)
parser.add_argument("--project", default=DEFAULT_PROJECT)
parser.add_argument("--control-project", default=DEFAULT_CONTROL_PROJECT)
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
parser.add_argument("--skip-preflight", action="store_true", help="Skip validate_test_instance.py.")
parser.add_argument("--keep-open", action="store_true", help="Do not close the created Helpdesk issue.")
args = parser.parse_args()
env = load_env_file(args.env_file)
redmine_url = args.redmine_url or env.get("REDMINE_URL") or DEFAULT_REDMINE_URL
api_key = args.api_key or env.get("REDMINE_API_KEY") or env.get("REDMNINE_API_KEY")
if not api_key:
print("error: missing Redmine API key. Set REDMINE_API_KEY or redMCP/.env.", file=sys.stderr)
return 1
config = SmokeConfig(
redmine_url=redmine_url.rstrip("/"),
api_key=api_key,
mailpit_host=args.mailpit_host,
mailpit_http_port=args.mailpit_http_port,
mailpit_smtp_port=args.mailpit_smtp_port,
project=args.project,
control_project=args.control_project,
ssh_host=args.ssh_host,
ssh_key=args.ssh_key,
remote_redmine=args.remote_redmine,
skip_preflight=args.skip_preflight,
keep_open=args.keep_open,
)
try:
run_smoke(config)
return 0
except SmokeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def run_smoke(config: SmokeConfig) -> None:
token = time.strftime("%Y%m%d%H%M%S")
subject = f"[redMCP-smoke {token}] Helpdesk inbound validation"
from_address = f"redmcp-smoke-{token}@example.test"
reply_token = f"redMCP outbound smoke reply {token}"
if not config.skip_preflight:
run_checked(["./validate_test_instance.py"], "preflight validator")
before_ids = set(mailpit_message_ids(config))
send_inbound_email(config, from_address, subject, token)
print(f"[OK] Sent inbound Mailpit message: {subject}")
trigger_helpdesk_import(config)
issue_id = wait_for_issue(config, subject)
print(f"[OK] Helpdesk imported issue #{issue_id}")
context = redmcp_call(config, "issueWithHelpdesk", {"issue_id": issue_id, "limit": 20})
assert_helpdesk_context(context, issue_id, from_address, subject, require_messages=False)
print("[OK] redMCP issueWithHelpdesk returned Helpdesk ticket context")
control = redmcp_call(config, "createControlIssue", {"project": config.control_project, "token": token})
control_id = int(control["id"])
redmcp_call(config, "updateIssue", {"issue_id": control_id, "fields": {"notes": f"redMCP control update {token}"}})
redmcp_call(config, "deleteIssue", {"issue_id": control_id})
print(f"[OK] redMCP non-Helpdesk CRUD control passed with issue #{control_id}")
send_helpdesk_reply(config, issue_id, reply_token)
wait_for_mailpit_text(config, before_ids, reply_token)
print("[OK] Mailpit received outbound Helpdesk/Redmine mail containing the reply token")
context = redmcp_call(config, "issueWithHelpdesk", {"issue_id": issue_id, "limit": 20})
assert_helpdesk_context(context, issue_id, from_address, subject, require_messages=True)
print("[OK] redMCP issueWithHelpdesk returned post-reply journal message context")
if not config.keep_open:
redmcp_call(
config,
"updateIssue",
{"issue_id": issue_id, "fields": {"status_id": 5, "notes": f"Closed by redMCP smoke test {token}"}},
)
print(f"[OK] Closed smoke-test Helpdesk issue #{issue_id}")
else:
print(f"[WARN] Left smoke-test Helpdesk issue #{issue_id} open because --keep-open was passed")
print("\nSmoke test passed.")
print(f" issue_id: {issue_id}")
print(f" subject: {subject}")
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def send_inbound_email(config: SmokeConfig, from_address: str, subject: str, token: str) -> None:
message = EmailMessage()
message["From"] = f"redMCP Smoke <{from_address}>"
message["To"] = f"helpdesk-{config.project}@example.test"
message["Subject"] = subject
message["Message-ID"] = f"<redmcp-smoke-{token}@example.test>"
message.set_content(
"\n".join(
[
"This is an automated redMCP Helpdesk smoke-test message.",
f"Smoke token: {token}",
"The test validates Helpdesk import, redMCP enrichment, and outbound delivery.",
]
)
)
with smtplib.SMTP(config.mailpit_host, config.mailpit_smtp_port, timeout=10) as smtp:
smtp.send_message(message)
def trigger_helpdesk_import(config: SmokeConfig) -> None:
ruby = (
f"project = Project.find_by_identifier({ruby_string(config.project)}); "
"raise 'project not found' unless project; "
"HelpdeskMailer.check_project(project.id); "
"puts 'ok'"
)
result = ssh_redmine(config, f"bin/rails runner -e production {shell_quote(ruby)}")
if result.returncode != 0:
raise SmokeError("Helpdesk import failed: " + (result.stderr or result.stdout).strip())
def send_helpdesk_reply(config: SmokeConfig, issue_id: int, content: str) -> None:
ruby = (
"issue = Issue.find(%d); "
"user = User.find_by_login('rebot') || User.find_by_login('admin') || User.find(1); "
"User.current = user; "
"raise 'issue has no Helpdesk customer' if issue.customer.nil?; "
"journal = issue.init_journal(user); "
"journal.notes = %s; "
"issue.save!; "
"contact = issue.customer; "
"HelpdeskMailer.with_activated_perform_deliveries do; "
"msg = HelpdeskMailer.issue_response(contact, journal, {}).deliver; "
"JournalMessage.create!(:from_address => '', "
":to_address => contact.primary_email.downcase, "
":is_incoming => false, "
":message_date => Time.now, "
":message_id => msg.message_id.to_s.slice(0, 255), "
":source => HelpdeskTicket::HELPDESK_EMAIL_SOURCE, "
":contact => contact, "
":journal => journal); "
"end; "
"puts 'ok'"
) % (issue_id, ruby_string(content))
result = ssh_redmine(config, f"bin/rails runner -e production {shell_quote(ruby)}")
if result.returncode != 0:
raise SmokeError("Helpdesk reply failed: " + (result.stderr or result.stdout).strip())
def wait_for_issue(config: SmokeConfig, subject: str, timeout: int = 60) -> int:
deadline = time.time() + timeout
while time.time() < deadline:
issue_id = find_issue_id(config, subject)
if issue_id is not None:
return issue_id
time.sleep(2)
raise SmokeError(f"Timed out waiting for Helpdesk issue with subject {subject!r}")
def find_issue_id(config: SmokeConfig, subject: str) -> int | None:
sql = (
"SELECT i.id "
"FROM issues i JOIN projects p ON p.id = i.project_id "
f"WHERE p.identifier = {sql_string(config.project)} "
f"AND i.subject = {sql_string(subject)} "
"ORDER BY i.id DESC LIMIT 1;"
)
output = remote_mysql(config, sql).strip()
if not output:
return None
return int(output.splitlines()[0])
def remote_mysql(config: SmokeConfig, sql: str) -> str:
ruby = (
"require 'yaml'; "
"c = YAML.load_file('config/database.yml')['production']; "
"ENV['MYSQL_PWD'] = c['password'].to_s; "
"args = ['--batch', '--raw', '--quick', '--skip-column-names', "
"'--default-character-set=utf8', '-h', c['host'].to_s, "
"'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; "
"exec('mysql', *args)"
)
command = [
"ssh",
"-i",
str(config.ssh_key),
"-o",
"IdentitiesOnly=yes",
config.ssh_host,
f"cd {shell_quote(config.remote_redmine)} && ruby -e {shell_quote(ruby)}",
]
result = subprocess.run(command, input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode != 0:
raise SmokeError("Remote MySQL query failed: " + (result.stderr or result.stdout).strip())
return result.stdout
def redmcp_call(config: SmokeConfig, action: str, payload: dict[str, Any]) -> dict[str, Any]:
php = r'''
require getcwd() . '/redMCP/vendor/autoload.php';
use RedMCP\RedmineClient;
$input = json_decode(stream_get_contents(STDIN), true);
$client = RedmineClient::fromCredentials($input['redmine_url'], $input['api_key']);
$payload = $input['payload'];
switch ($input['action']) {
case 'issueWithHelpdesk':
$result = $client->issueWithHelpdesk((int) $payload['issue_id'], (int) $payload['limit']);
break;
case 'createControlIssue':
$result = $client->createIssue([
'project_id' => $payload['project'],
'subject' => '[redMCP-smoke ' . $payload['token'] . '] Non-Helpdesk CRUD control',
'description' => 'Created by the redMCP Helpdesk smoke test.',
'tracker_id' => 3,
]);
break;
case 'updateIssue':
$client->updateIssue((int) $payload['issue_id'], $payload['fields']);
$result = ['ok' => true];
break;
case 'deleteIssue':
$client->deleteIssue((int) $payload['issue_id']);
$result = ['ok' => true];
break;
default:
throw new RuntimeException('Unknown redMCP smoke action: ' . $input['action']);
}
echo json_encode($result, JSON_UNESCAPED_SLASHES);
'''
request = {
"redmine_url": config.redmine_url,
"api_key": config.api_key,
"action": action,
"payload": payload,
}
result = subprocess.run(
["php", "-r", php],
input=json.dumps(request),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
raise SmokeError(f"redMCP {action} failed: " + (result.stderr or result.stdout).strip())
try:
decoded = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise SmokeError(f"redMCP {action} returned invalid JSON: {result.stdout[:500]}") from exc
if not isinstance(decoded, dict):
raise SmokeError(f"redMCP {action} returned unexpected JSON")
return decoded
def assert_helpdesk_context(
context: dict[str, Any],
issue_id: int,
from_address: str,
subject: str,
require_messages: bool,
) -> None:
issue = context.get("issue")
helpdesk = context.get("helpdesk")
if not isinstance(issue, dict) or int(issue.get("id", 0)) != issue_id:
raise SmokeError("redMCP context did not include the expected issue")
if issue.get("subject") != subject:
raise SmokeError("redMCP context issue subject did not match the smoke-test subject")
if not isinstance(helpdesk, dict) or helpdesk.get("available") is not True:
raise SmokeError("redMCP context did not mark Helpdesk data as available")
ticket = helpdesk.get("ticket")
if not isinstance(ticket, dict):
raise SmokeError("redMCP context did not include Helpdesk ticket metadata")
messages = helpdesk.get("journal_messages")
if not isinstance(messages, list):
raise SmokeError("redMCP context did not include a Helpdesk journal message list")
if require_messages and not messages:
raise SmokeError("redMCP context did not include post-reply Helpdesk journal messages")
text = json.dumps(helpdesk, sort_keys=True)
if from_address not in text:
raise SmokeError(f"redMCP Helpdesk context did not include sender {from_address}")
def mailpit_message_ids(config: SmokeConfig) -> list[str]:
data = mailpit_json(config, "/api/v1/messages", {"limit": "200"})
messages = data.get("messages", []) if isinstance(data, dict) else []
return [message["ID"] for message in messages if isinstance(message, dict) and "ID" in message]
def wait_for_mailpit_text(config: SmokeConfig, before_ids: set[str], needle: str, timeout: int = 60) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
for message_id in mailpit_message_ids(config):
if message_id in before_ids:
continue
message = mailpit_json(config, f"/api/v1/message/{message_id}", {})
if needle in json.dumps(message):
return
time.sleep(2)
raise SmokeError(f"Timed out waiting for Mailpit message containing {needle!r}")
def mailpit_json(config: SmokeConfig, path: str, params: dict[str, str]) -> dict[str, Any]:
query = urllib.parse.urlencode(params)
url = f"http://{config.mailpit_host}:{config.mailpit_http_port}{path}"
if query:
url += "?" + query
try:
with urllib.request.urlopen(url, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, json.JSONDecodeError) as exc:
raise SmokeError(f"Mailpit API failed for {path}: {exc}") from exc
def ssh_redmine(config: SmokeConfig, command: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"ssh",
"-i",
str(config.ssh_key),
"-o",
"IdentitiesOnly=yes",
config.ssh_host,
f"cd {shell_quote(config.remote_redmine)} && {command}",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def run_checked(command: list[str], label: str) -> None:
result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode != 0:
raise SmokeError(f"{label} failed:\n{result.stdout}{result.stderr}")
print(f"[OK] {label}")
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def ruby_string(value: str) -> str:
return json.dumps(value)
def sql_string(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"
if __name__ == "__main__":
raise SystemExit(main())