Add Helpdesk smoke test
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ...]`.
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Executable
+433
@@ -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())
|
||||||
Reference in New Issue
Block a user