#!/usr/bin/env python3 """Live Helpdesk outbox/worker validation for the LAN Redmine copy. This creates one controlled Helpdesk ticket through Mailpit, then inspects the outbox rows for that issue and dry-runs worker enrichment without claiming or marking rows processed. """ from __future__ import annotations import argparse import json import os import sys from collections import Counter from dataclasses import dataclass from pathlib import Path from typing import Any from helpdesk_smoke_test import ( DEFAULT_CONTROL_PROJECT, DEFAULT_MAILPIT_HOST, DEFAULT_MAILPIT_HTTP_PORT, DEFAULT_MAILPIT_SMTP_PORT, DEFAULT_PROJECT, DEFAULT_REDMINE_URL, DEFAULT_REMOTE_REDMINE, DEFAULT_SSH_HOST, DEFAULT_SSH_KEY, SmokeConfig, SmokeError, load_env_file, run_smoke, ) from redmine_outbox_worker import OutboxWorkerError, RemoteRedmine, enrich_event, sql_int EXPECTED_EVENT_TYPES = { "helpdesk_ticket.created", "helpdesk_ticket.updated", "journal_message.created", "journal.created", "issue.updated", } class ValidationError(RuntimeError): pass @dataclass(frozen=True) class ValidationConfig: smoke: SmokeConfig remote: RemoteRedmine def main() -> int: parser = argparse.ArgumentParser( description="Create a controlled Helpdesk issue and validate outbox worker enrichment for it." ) 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() try: config = build_config(args) run_validation(config) return 0 except (SmokeError, OutboxWorkerError, ValidationError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 def build_config(args: argparse.Namespace) -> ValidationConfig: 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: raise ValidationError("missing Redmine API key. Set REDMINE_API_KEY or redMCP/.env.") smoke = 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, ) remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine) return ValidationConfig(smoke=smoke, remote=remote) def run_validation(config: ValidationConfig) -> None: smoke_result = run_smoke(config.smoke) events = fetch_issue_events(config.remote, smoke_result.issue_id) assert_expected_events(events) assert_events_unprocessed(events) documents: list[dict[str, Any]] = [] for event in events: documents.extend(enrich_event(config.remote, event)) assert_worker_documents(events, documents) assert_no_bcc_address_leak(documents) print_summary(smoke_result.issue_id, events, documents) def fetch_issue_events(remote: RemoteRedmine, issue_id: int) -> list[dict[str, Any]]: events = remote.mysql_json_lines( f""" SELECT HEX(CAST(JSON_OBJECT( 'id', id, 'event_type', event_type, 'source_type', source_type, 'source_id', source_id, 'project_id', project_id, 'issue_id', issue_id, 'journal_id', journal_id, 'user_id', user_id, 'occurred_at', DATE_FORMAT(occurred_at, '%Y-%m-%dT%H:%i:%sZ'), 'attempts', attempts, 'processed_at', IF(processed_at IS NULL, NULL, DATE_FORMAT(processed_at, '%Y-%m-%dT%H:%i:%sZ')), 'locked_at', IF(locked_at IS NULL, NULL, DATE_FORMAT(locked_at, '%Y-%m-%dT%H:%i:%sZ')), 'payload', payload ) AS CHAR)) AS document FROM event_outbox_events WHERE issue_id = {sql_int(issue_id)} ORDER BY id; """ ) if not events: raise ValidationError(f"no event_outbox_events rows found for issue #{issue_id}") return events def assert_expected_events(events: list[dict[str, Any]]) -> None: found = {str(event.get("event_type") or "") for event in events} missing = sorted(EXPECTED_EVENT_TYPES - found) if missing: raise ValidationError(f"missing expected Helpdesk outbox event types: {', '.join(missing)}") def assert_events_unprocessed(events: list[dict[str, Any]]) -> None: mutated = [ event for event in events if event.get("processed_at") is not None or event.get("locked_at") is not None ] if mutated: ids = ", ".join(str(event.get("id")) for event in mutated) raise ValidationError(f"new smoke-test outbox rows were already processed or locked: {ids}") def assert_worker_documents(events: list[dict[str, Any]], documents: list[dict[str, Any]]) -> None: doc_types = {str(document.get("doc_type") or "") for document in documents} required_doc_types = {"event", "ticket", "message"} missing_doc_types = sorted(required_doc_types - doc_types) if missing_doc_types: raise ValidationError(f"worker dry-run did not derive document types: {', '.join(missing_doc_types)}") event_doc_ids = {document.get("event_id") for document in documents if document.get("doc_type") == "event"} missing_events = [str(event.get("id")) for event in events if event.get("id") not in event_doc_ids] if missing_events: raise ValidationError(f"worker dry-run did not emit event documents for rows: {', '.join(missing_events)}") def assert_no_bcc_address_leak(documents: list[dict[str, Any]]) -> None: for document in documents: if contains_key(document, "bcc_address"): raise ValidationError(f"worker document leaked raw bcc_address: {document.get('doc_id')}") def contains_key(value: Any, needle: str) -> bool: if isinstance(value, dict): return any(key == needle or contains_key(child, needle) for key, child in value.items()) if isinstance(value, list): return any(contains_key(child, needle) for child in value) return False def print_summary(issue_id: int, events: list[dict[str, Any]], documents: list[dict[str, Any]]) -> None: event_types = Counter(str(event.get("event_type") or "") for event in events) doc_types = Counter(str(document.get("doc_type") or "") for document in documents) ticket_ids = sorted( { int(document["helpdesk_ticket_id"]) for document in documents if document.get("doc_type") == "ticket" and document.get("helpdesk_ticket_id") } ) message_ids = sorted( { int(document["journal_message_id"]) for document in documents if document.get("doc_type") == "message" and document.get("journal_message_id") } ) event_rows = [f"{event.get('id')}:{event.get('event_type')}" for event in events] print("\nHelpdesk outbox worker validation passed.") print(f" issue_id: {issue_id}") print(f" helpdesk_ticket_ids: {json.dumps(ticket_ids)}") print(f" journal_message_ids: {json.dumps(message_ids)}") print(f" event_rows: {', '.join(event_rows)}") print(f" event_type_counts: {json.dumps(dict(sorted(event_types.items())), sort_keys=True)}") print(f" derived_doc_type_counts: {json.dumps(dict(sorted(doc_types.items())), sort_keys=True)}") print(" worker_mode: dry-run enrichment only; rows were not claimed or marked processed") if __name__ == "__main__": raise SystemExit(main())