Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
View File
+272
View File
@@ -0,0 +1,272 @@
"""Unit tests for Redmine client, mapper, and CRM upsert."""
from __future__ import annotations
import re
from http import HTTPStatus
from unittest import mock
import pytest
import responses
from redmine_crm.crm_client import CrmClient, CrmError
from redmine_crm.mapper import enrich, map_contact
from redmine_crm.redmine_client import (
RedmineAuthError,
RedmineClient,
RedmineNotFoundError,
RedmineTransientError,
RetryConfig,
)
from redmine_crm.sync import SyncState, run_sync
BASE_URL = "https://redmine.example.com"
@responses.activate
def test_fetch_contacts_success():
contacts = [{"id": 1, "first_name": "Ada", "last_name": "Lovelace"}]
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": contacts},
status=200,
)
client = RedmineClient(BASE_URL, "secret")
result = client.fetch_contacts()
assert result == contacts
request = responses.calls[0].request
assert request.headers["X-Redmine-API-Key"] == "secret"
@responses.activate
def test_fetch_contacts_transient_retry_then_success():
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=503)
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": [{"id": 1, "first_name": "Grace", "last_name": "Hopper"}]},
status=200,
)
client = RedmineClient(
BASE_URL,
"secret",
retry=RetryConfig(max_attempts=2, base_delay_seconds=0.001),
)
result = client.fetch_contacts()
assert len(result) == 1
assert len(responses.calls) == 2
@responses.activate
def test_fetch_contacts_permanent_failure_after_retries():
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=503)
client = RedmineClient(
BASE_URL,
"secret",
retry=RetryConfig(max_attempts=1, base_delay_seconds=0.001),
)
with pytest.raises(RedmineTransientError):
client.fetch_contacts()
@responses.activate
def test_fetch_contact_not_found_raises():
responses.add(responses.GET, f"{BASE_URL}/contacts/99.json", status=404)
client = RedmineClient(BASE_URL, "secret")
with pytest.raises(RedmineNotFoundError):
client.fetch_contact(99)
@responses.activate
def test_auth_error_is_permanent():
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=401)
client = RedmineClient(BASE_URL, "secret")
with pytest.raises(RedmineAuthError):
client.fetch_contacts()
def test_map_contact_basic():
raw = {
"id": 7,
"first_name": "Alan",
"last_name": "Turing",
"company": "Bletchley Park",
"job_title": "Mathematician",
"email": "alan@example.com",
"phone": "+44 345 678",
"website": "https://turing.example.com",
"birthday": "1912-06-23",
"background": "Father of computer science.",
"tag_list": ["Hero", "pioneer"],
"updated_at": "2026-07-01T12:00:00Z",
"address_attributes": {
"street": "1 Enigma Way",
"city": "Bletchley",
"state": "England",
"country": "UK",
"zip": "MK3 9HB",
},
"custom_fields": [
{"name": "LinkedIn", "value": "https://linkedin.com/in/alan"},
{"name": "Source", "value": "conference"},
],
}
mapped = map_contact(raw, base_url=BASE_URL)
assert mapped["redmine_contact_id"] == 7
assert mapped["full_name"] == "Alan Turing"
assert mapped["company"] == "Bletchley Park"
assert mapped["title"] == "Mathematician"
assert mapped["emails"] == [{"value": "alan@example.com", "type": "work"}]
assert mapped["urls"] == [
{"value": "https://turing.example.com", "type": "work"},
{"value": "https://linkedin.com/in/alan", "type": "linkedin"},
]
assert mapped["address"]["city"] == "Bletchley"
assert mapped["address"]["postal_code"] == "MK3 9HB"
assert "Father of computer science" in mapped["notes"]
assert "LinkedIn" not in mapped["notes"] # promoted to URL
assert "Source" in mapped["notes"]
assert "from-redmine" in mapped["tags"]
assert mapped["tags"] == sorted({"hero", "pioneer", "from-redmine"})
assert mapped["last_redmine_update"] is not None
def test_map_company_record():
raw = {
"id": 8,
"is_company": True,
"company": "Acme Corp",
"email": "info@acme.example.com",
"updated_at": "2026-07-02T10:00:00Z",
}
mapped = map_contact(raw, base_url=BASE_URL)
assert mapped["full_name"] == "Acme Corp"
assert mapped["is_company"] is True
def test_enrich_fills_email_from_background():
raw = {
"id": 9,
"first_name": "Linus",
"last_name": "Torvalds",
"background": "Reach me at linus@linux.org for kernel stuff.",
"updated_at": "2026-07-03T10:00:00Z",
}
mapped = map_contact(raw, base_url=BASE_URL)
assert mapped["emails"] == []
enriched, meta = enrich(mapped, raw)
assert enriched["emails"] == [{"value": "linus@linux.org", "type": "work"}]
assert meta.email_source == "background"
assert enriched["enriched_at"] is not None
def test_enrich_derives_company_from_domain():
raw = {
"id": 10,
"first_name": "Tim",
"last_name": "Berners-Lee",
"email": "tim@w3.org",
"updated_at": "2026-07-03T10:00:00Z",
}
mapped = map_contact(raw, base_url=BASE_URL)
mapped.pop("company", None)
enriched, meta = enrich(mapped, raw)
assert enriched["company"] == "W3 Inc"
assert meta.company_source == "domain_heuristic"
def test_crm_create_and_update_by_redmine_id():
crm = CrmClient()
record = {
"redmine_contact_id": 1,
"full_name": "Ada Lovelace",
"emails": [{"value": "ada@example.com", "type": "work"}],
"last_redmine_update": "2026-07-01T00:00:00Z",
}
result1 = crm.upsert(record)
assert result1.created is True
result2 = crm.upsert(record)
assert result2.skipped_unchanged is True
updated = dict(record)
updated["last_redmine_update"] = "2026-07-02T00:00:00Z"
result3 = crm.upsert(updated)
assert result3.updated is True
def test_crm_email_fallback_links_record():
crm = CrmClient()
crm.upsert({
"redmine_contact_id": 2,
"full_name": "Old Name",
"emails": [{"value": "match@example.com", "type": "work"}],
"last_redmine_update": "2026-07-01T00:00:00Z",
})
result = crm.upsert({
"redmine_contact_id": 3,
"full_name": "New Name",
"emails": [{"value": "match@example.com", "type": "work"}],
"last_redmine_update": "2026-07-02T00:00:00Z",
})
assert result.updated is True
@responses.activate
def test_run_sync_end_to_end():
contacts = [
{
"id": 1,
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace@example.com",
"updated_at": "2026-07-04T12:00:00Z",
}
]
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": contacts},
status=200,
)
redmine = RedmineClient(BASE_URL, "secret")
crm = CrmClient()
crm._store.clear()
state = SyncState()
stats = run_sync(redmine, crm, state, base_url=BASE_URL, batch_size=100)
assert stats["processed"] == 1
assert stats["created"] == 1
assert stats["errors"] == 0
def test_upsert_requires_redmine_id():
crm = CrmClient()
with pytest.raises(CrmError):
crm.upsert({"full_name": "No ID"})
def test_map_contact_notes_include_redmine_source_url():
raw = {
"id": 42,
"first_name": "Marie",
"last_name": "Curie",
"updated_at": "2026-07-01T00:00:00Z",
}
mapped = map_contact(raw, base_url=BASE_URL)
assert re.search(r"Redmine source: https://redmine\.example\.com/contacts/42", mapped["notes"])
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from redmine_ticket_attachments import (
attachments_from_issue,
pick_attachment,
safe_filename,
upload_file_token,
)
from redmine_tickets import build_parser, inject_default_sync_command
class AttachmentHelpersTest(unittest.TestCase):
def test_pick_and_safe(self):
atts = [{"id": 1, "filename": "a.pdf"}, {"id": 2, "filename": "b.pdf"}]
self.assertEqual(2, pick_attachment(atts, 2)["id"])
with self.assertRaises(LookupError):
pick_attachment(atts, None)
self.assertEqual(1, pick_attachment([atts[0]], None)["id"])
self.assertEqual("foo_bar.pdf", safe_filename("foo/bar.pdf"))
def test_attachments_from_issue(self):
self.assertEqual([], attachments_from_issue({}))
self.assertEqual(1, len(attachments_from_issue({"attachments": [{"id": 9}]})))
def test_cli_parse(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"attachments",
"list",
"--issue-id",
"1",
"--refresh",
]
)
)
self.assertEqual("attachments", args.command)
self.assertEqual("list", args.attachments_command)
self.assertTrue(args.refresh)
args2 = build_parser().parse_args(
inject_default_sync_command(
[
"attachments",
"upload",
"--issue-id",
"2",
"--file",
"/tmp/x.pdf",
"--apply",
]
)
)
self.assertEqual("upload", args2.attachments_command)
self.assertTrue(args2.apply)
def test_upload_token_mock(self):
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "note.txt"
path.write_text("hello", encoding="utf-8")
class FakeResp:
def read(self):
return b'{"upload":{"token":"tok.123","id":1}}'
def __enter__(self):
return self
def __exit__(self, *a):
return False
with patch("redmine_ticket_attachments.urllib_request.urlopen", return_value=FakeResp()):
upload = upload_file_token(
base_url="http://192.168.50.170",
api_key="x",
path=path,
)
self.assertEqual("tok.123", upload["token"])
self.assertEqual("note.txt", upload["filename"])
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
from __future__ import annotations
import unittest
from redmine_ticket_browse import build_lines, issue_line, parse_id_from_line
from redmine_tickets import build_parser, inject_default_sync_command
class BrowseHelpersTest(unittest.TestCase):
def test_lines(self):
issue = {
"id": 42,
"status": {"name": "New"},
"assigned_to": {"name": "Jason"},
"subject": "Foo\tbar\nbaz",
"_control_tags": ["spammy"],
}
line = issue_line(issue)
self.assertTrue(line.startswith("42\t"))
self.assertIn("Jason", line)
self.assertIn("spammy", line)
self.assertNotIn("\n", line)
self.assertEqual(42, parse_id_from_line(line))
self.assertEqual([line], build_lines([issue]))
def test_cli(self):
args = build_parser().parse_args(
inject_default_sync_command(
["browse", "--where", "assigned_to IS NULL", "--pager", "cat", "--once"]
)
)
self.assertEqual("browse", args.command)
self.assertTrue(args.once)
self.assertEqual("cat", args.pager)
if __name__ == "__main__":
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
from redmine_ticket_drafts import KIND_INTERNAL_NOTE, add_draft
from redmine_tickets import (
apply_runtime_config_to_argv,
build_parser,
inject_default_sync_command,
load_runtime_config,
print_draft_queued,
resolve_apply_base_url,
)
class ProdWritesParentFlagTest(unittest.TestCase):
def test_parent_allow_prod_writes_before_draft_apply(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"--base-url",
"https://rm.ldrprep.com",
"--allow-prod-writes",
"draft",
"apply",
"deadbeefcafe",
]
)
)
self.assertTrue(args.allow_prod_writes)
self.assertEqual("draft", args.command)
self.assertEqual("apply", args.draft_command)
class DraftBaseUrlTest(unittest.TestCase):
def test_resolve_prefers_draft_url(self):
args = Namespace(base_url="https://rm.ldrprep.com", allow_base_url_override=False)
draft = {"base_url": "http://192.168.50.170"}
# default CLI equals DEFAULT which is often prod; draft LAN should win when cli is "default-like"
# Here cli is explicit prod vs draft LAN — should refuse without override
with self.assertRaises(Exception):
resolve_apply_base_url(args, draft)
args.allow_base_url_override = True
self.assertEqual("https://rm.ldrprep.com", resolve_apply_base_url(args, draft))
def test_resolve_uses_draft_when_cli_default(self):
from redmine_contacts import DEFAULT_BASE_URL
args = Namespace(base_url=DEFAULT_BASE_URL, allow_base_url_override=False)
draft = {"base_url": "http://192.168.50.170"}
self.assertEqual("http://192.168.50.170", resolve_apply_base_url(args, draft))
class ConfigAndDraftIdTest(unittest.TestCase):
def test_config_inject(self):
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "c.json"
cfg.write_text(
'{"base-url":"http://192.168.50.170","project":"customer-service","cache-dir":"/tmp/x"}',
encoding="utf-8",
)
out = apply_runtime_config_to_argv(["--config", str(cfg), "search", "--max-tickets", "1"])
self.assertIn("--base-url", out)
self.assertIn("http://192.168.50.170", out)
self.assertIn("--project", out)
# explicit flag not overridden
out2 = apply_runtime_config_to_argv(
["--project", "other", "--config", str(cfg), "search"]
)
# project other should remain; config injects base-url/cache-dir only if missing
self.assertIn("other", out2)
def test_print_draft_id(self):
with tempfile.TemporaryDirectory() as td:
path = add_draft(
Path(td),
issue_id=9,
kind=KIND_INTERNAL_NOTE,
body="hi",
base_url="https://rm.ldrprep.com",
)
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
did = print_draft_queued(
path,
kind=KIND_INTERNAL_NOTE,
issue_id=9,
base_url="https://rm.ldrprep.com",
)
text = buf.getvalue()
self.assertEqual(path.stem, did)
self.assertIn(f"draft_id={did}", text)
self.assertIn("base_url=https://rm.ldrprep.com", text)
if __name__ == "__main__":
unittest.main()
+276
View File
@@ -0,0 +1,276 @@
import json
import time
import unittest
from unittest import mock
import contact_enrichment
class EnrichmentMappingTest(unittest.TestCase):
def test_basic_contact_mapping(self):
redmine = {
"id": 42,
"first_name": "Alice",
"middle_name": "Marie",
"last_name": "Nguyen",
"company": "Acme Inc",
"job_title": "Engineer",
"email": "alice@acme.com",
"phone": "+1 555-123-4567",
"website": "https://acme.com",
"birthday": "1990-03-15",
"background": "VIP customer",
"tag_list": ["customer", "vip"],
"updated_at": "2026-07-01T12:00:00Z",
}
result = contact_enrichment.enrich_contact(redmine)
e = result.enriched
self.assertEqual(42, e["redmine_contact_id"])
self.assertEqual("Alice", e["first_name"])
self.assertEqual("Marie", e["middle_name"])
self.assertEqual("Nguyen", e["last_name"])
self.assertEqual("Alice Marie Nguyen", e["full_name"])
self.assertEqual("Acme Inc", e["company"])
self.assertEqual("Engineer", e["title"])
self.assertEqual([{"value": "alice@acme.com", "type": "work"}], e["emails"])
self.assertEqual([{"value": "+15551234567", "type": "work"}], e["phones"])
self.assertEqual("https://acme.com", e["website"])
self.assertEqual("1990-03-15", e["birthday"])
self.assertEqual("redmine", e["source"])
self.assertIn("customer", e["tags"])
self.assertIn("vip", e["tags"])
self.assertIn("from-redmine", e["tags"])
self.assertIn("VIP customer", e["notes"])
self.assertEqual("2026-07-01T12:00:00+00:00", e["last_redmine_update"])
def test_company_record_uses_company_as_name(self):
redmine = {
"id": 7,
"is_company": True,
"company": "Globex",
"first_name": "Globex",
"email": "info@globex.com",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual("Globex", result.enriched["full_name"])
self.assertEqual("Globex", result.enriched["first_name"])
def test_address_attributes_mapped(self):
redmine = {
"id": 3,
"first_name": "Bob",
"last_name": "Smith",
"address_attributes": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62701",
"country": "USA",
},
}
result = contact_enrichment.enrich_contact(redmine)
address = result.enriched["address"]
self.assertEqual("123 Main St", address["street"])
self.assertEqual("Springfield", address["city"])
self.assertEqual("IL", address["region"])
self.assertEqual("62701", address["postal_code"])
self.assertEqual("USA", address["country"])
def test_email_extracted_from_background_when_missing(self):
redmine = {
"id": 9,
"first_name": "Carol",
"last_name": "Danvers",
"background": "Reach her at carol@shield.gov for details.",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual([{"value": "carol@shield.gov", "type": "work"}], result.enriched["emails"])
def test_company_inferred_from_email_domain(self):
redmine = {
"id": 11,
"first_name": "Diana",
"last_name": "Prince",
"email": "diana@themyscira.io",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual("Themyscira Inc", result.enriched["company"])
def test_custom_domain_to_company_mapping(self):
config = contact_enrichment.EnrichmentConfig(
domain_to_company={"themyscira.io": "Themyscira Labs"}
)
redmine = {
"id": 12,
"first_name": "Diana",
"last_name": "Prince",
"email": "diana@themyscira.io",
}
result = contact_enrichment.enrich_contact(redmine, config)
self.assertEqual("Themyscira Labs", result.enriched["company"])
def test_external_lookup_fills_company(self):
config = contact_enrichment.EnrichmentConfig(
external_lookup=lambda _cid: {"company": "Wayne Enterprises"}
)
redmine = {
"id": 13,
"first_name": "Bruce",
"last_name": "Wayne",
"email": "bruce@wayne.com",
}
result = contact_enrichment.enrich_contact(redmine, config)
self.assertEqual("Wayne Enterprises", result.enriched["company"])
def test_missing_contact_id_is_skipped(self):
result = contact_enrichment.enrich_contact({"first_name": "Ghost"})
self.assertTrue(result.skipped)
self.assertIn("Missing Redmine contact id", result.reason)
class PhoneEnrichmentTest(unittest.TestCase):
def test_us_phone_normalized_to_e164(self):
redmine = {
"id": 20,
"first_name": "Eve",
"last_name": "Polastri",
"phone": "555.867.5309",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual([{"value": "+15558675309", "type": "work"}], result.enriched["phones"])
def test_mobile_phone_classified(self):
redmine = {
"id": 21,
"first_name": "Frank",
"last_name": "Castle",
"phone": "mobile: 555-0100",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual("mobile", result.enriched["phones"][0]["type"])
def test_short_phone_is_ignored(self):
redmine = {
"id": 22,
"first_name": "Grace",
"last_name": "Hopper",
"phone": "12345",
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual([], result.enriched["phones"])
class NotesAndTagsTest(unittest.TestCase):
def test_notes_merge_background_and_custom_fields(self):
redmine = {
"id": 30,
"first_name": "Hank",
"last_name": "Pym",
"background": "Shrinking tech",
"custom_fields": [
{"name": "LinkedIn", "value": "https://linkedin.com/in/hankpym"},
{"name": "Source", "value": "referral"},
],
}
result = contact_enrichment.enrich_contact(redmine)
notes = result.enriched["notes"]
self.assertIn("Shrinking tech", notes)
self.assertIn("LinkedIn: https://linkedin.com/in/hankpym", notes)
self.assertIn("Source: referral", notes)
self.assertIn("Redmine contact: 30", notes)
def test_tags_are_lowercased_and_deduplicated(self):
redmine = {
"id": 31,
"first_name": "Irene",
"last_name": "Adler",
"tag_list": "Customer, VIP, customer",
"project_id": 1,
}
result = contact_enrichment.enrich_contact(redmine)
self.assertEqual(["customer", "from-redmine", "project-1", "vip"], result.enriched["tags"])
class RateLimitTest(unittest.TestCase):
def test_rate_limit_wrapper_respects_max_calls(self):
calls = []
def worker(x):
calls.append(x)
return x * 2
wrapped = contact_enrichment.with_rate_limit(
worker, max_calls=2, per_seconds=1.0, max_retries=0
)
start = time.monotonic()
self.assertEqual(2, wrapped(1))
self.assertEqual(4, wrapped(2))
self.assertEqual(6, wrapped(3))
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.45)
self.assertEqual([1, 2, 3], calls)
def test_rate_limit_wrapper_retries_transient_errors(self):
source = mock.Mock(side_effect=[RuntimeError("boom"), RuntimeError("boom"), "ok"])
wrapped = contact_enrichment.with_rate_limit(
source, max_calls=10, per_seconds=1.0, max_retries=3, backoff_base=0.01
)
self.assertEqual("ok", wrapped())
self.assertEqual(3, source.call_count)
def test_rate_limit_error_propagates_without_retry(self):
def worker():
raise contact_enrichment.RateLimitError("throttled")
wrapped = contact_enrichment.with_rate_limit(
worker, max_calls=10, per_seconds=1.0, max_retries=3
)
with self.assertRaises(contact_enrichment.RateLimitError):
wrapped()
class BatchSyncTest(unittest.TestCase):
def test_build_sync_payload_skips_invalid_and_warns(self):
contacts = [
{"id": 1, "first_name": "A", "last_name": "B", "email": "a@b.com"},
{"first_name": "Invalid"},
]
payload = contact_enrichment.build_sync_payload(contacts)
self.assertEqual(1, payload["count"])
self.assertEqual(1, len(payload["records"]))
self.assertEqual(1, len(payload["skipped"]))
self.assertEqual("Missing Redmine contact id", payload["skipped"][0]["reason"])
class DateParsingTest(unittest.TestCase):
def test_birthday_formats(self):
for fmt, value in (
("%Y-%m-%d", "1990-03-15"),
("%Y/%m/%d", "1990/03/15"),
("%m/%d/%Y", "03/15/1990"),
("%d/%m/%Y", "15/03/1990"),
):
with self.subTest(fmt=fmt):
result = contact_enrichment._format_birthday(value)
self.assertEqual("1990-03-15", result)
def test_invalid_birthday_returns_none(self):
self.assertIsNone(contact_enrichment._format_birthday("not-a-date"))
if __name__ == "__main__":
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from redmine_ticket_contacts import contact_id_from_issue
from redmine_ticket_drafts import KIND_CONTACT_FIELDS, add_draft, kind_label, load_draft, normalize_kind
from redmine_tickets import apply_draft_to_redmine, build_parser, inject_default_sync_command
class ContactBridgeTest(unittest.TestCase):
def test_contact_id_from_issue(self):
self.assertEqual(
99,
contact_id_from_issue({"helpdesk_ticket": {"contact_id": 99}}),
)
self.assertEqual(
7,
contact_id_from_issue({"helpdesk_ticket": {"contact": {"id": 7, "email": "a@b.c"}}}),
)
self.assertIsNone(contact_id_from_issue({"subject": "x"}))
class ContactDraftTest(unittest.TestCase):
def test_kind(self):
self.assertEqual(KIND_CONTACT_FIELDS, normalize_kind("contact_fields"))
self.assertIn("CONTACT UPDATE", kind_label(KIND_CONTACT_FIELDS))
def test_queue_and_apply_mock(self):
with tempfile.TemporaryDirectory() as td:
path = add_draft(
Path(td),
contact_id=42,
kind=KIND_CONTACT_FIELDS,
fields={"tag_list": ["maybe-customer"], "company": "Acme"},
base_url="http://192.168.50.170",
)
_p, draft = load_draft(Path(td), path.stem)
self.assertEqual(42, draft["contact_id"])
self.assertIsNone(draft["issue_id"])
client = MagicMock()
result = apply_draft_to_redmine(
client,
draft,
impersonate_user="rebot",
allow_customer_email=False,
)
client.update_contact.assert_called_once()
self.assertEqual(42, result["contact_id"])
self.assertFalse(result["emailed_customer"])
def test_cli_parse(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"contact-set",
"--contact-id",
"14",
"--add-tag",
"maybe-customer",
"--company",
"Test Co",
]
)
)
self.assertEqual("contact-set", args.command)
self.assertEqual(14, args.contact_id)
self.assertEqual(["maybe-customer"], args.add_tag)
args2 = build_parser().parse_args(
inject_default_sync_command(
["--project", "customer-service", "ticket-contact", "--issue-id", "41934", "--show"]
)
)
self.assertEqual("ticket-contact", args2.command)
self.assertTrue(args2.show)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from redmine_ticket_entities import extract_entities, extract_entities_from_issue
from redmine_ticket_control_export import export_control_tag_jsonl
from redmine_ticket_control_tags import add_tag, load_store
from redmine_tickets import KNOWN_COMMANDS, build_parser, inject_default_sync_command
class EntityExtractTest(unittest.TestCase):
def test_patterns(self):
text = (
"Order 123-4567890-1234567 UPS 1Z999AA10123456784 "
"Item # SKU-ABC-99 USPS 9400111899223344556677"
)
types = {e["type"] for e in extract_entities(text)}
self.assertIn("tracking_ups", types)
self.assertIn("tracking_usps", types)
self.assertIn("order_amazon", types)
self.assertIn("item_or_sku", types)
def test_noise_suppressed(self):
htmlish = "order-mode responsive-title order-text"
hits = extract_entities(htmlish)
self.assertEqual([], hits)
def test_from_issue(self):
issue = {
"subject": "RET 1ZY228K59066639253",
"description": "Order ID: 1013179 shipped",
"journals": [],
}
ents = extract_entities_from_issue(issue)
types = {e["type"] for e in ents}
self.assertIn("tracking_ups", types)
self.assertIn("order_id", types)
class ControlExportTest(unittest.TestCase):
def test_export(self):
with tempfile.TemporaryDirectory() as td:
cache = Path(td)
add_tag(cache, tag="spammy", issue_id=7, properties=["subject"], annotation="x")
store = load_store(cache)
out = cache / "out.jsonl"
n = export_control_tag_jsonl(
cache_dir=cache,
tags_payload=store,
issues_by_id={7: {"id": 7, "subject": "hello", "status": {"name": "New"}}},
out_path=out,
)
self.assertEqual(1, n)
line = out.read_text(encoding="utf-8").strip()
self.assertIn("spammy", line)
self.assertIn('"issue_id": 7', line)
class CliRegistrationTest(unittest.TestCase):
def test_known_commands(self):
self.assertIn("entities", KNOWN_COMMANDS)
self.assertIn("browse", KNOWN_COMMANDS)
args = build_parser().parse_args(
inject_default_sync_command(["entities", "show", "--issue-id", "1"])
)
self.assertEqual("entities", args.command)
self.assertEqual("show", args.entities_command)
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from redmine_ticket_control_tags import (
add_tag,
filter_issues_by_control_tag,
list_tags,
remove_tag,
tags_for_issue,
tickets_for_tag,
wipe_all,
)
from redmine_ticket_drafts import KIND_FIELDS, add_draft, kind_label, load_draft, normalize_kind
from redmine_tickets import build_fields_payload_from_args, build_parser, inject_default_sync_command
class ControlTagsTest(unittest.TestCase):
def test_add_list_filter_remove(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
add_tag(
root,
tag="Bavaria",
issue_id=10,
properties=["subject", "helpdesk_ticket.contact.company"],
annotation="looks like BavariaTrachten",
)
add_tag(root, tag="bavaria", issue_id=11, properties=["subject"])
tags = list_tags(root)
self.assertEqual(1, len(tags))
self.assertEqual("bavaria", tags[0]["tag"])
self.assertEqual([10, 11], tags[0]["issue_ids"])
self.assertEqual(["bavaria"], tags_for_issue(root, 10))
issues = [{"id": 10}, {"id": 99}, {"id": 11}]
filtered = filter_issues_by_control_tag(issues, root, "bavaria")
self.assertEqual([10, 11], [i["id"] for i in filtered])
self.assertTrue(remove_tag(root, tag="bavaria", issue_id=10))
self.assertEqual([11], tickets_for_tag(root, "bavaria"))
wipe_all(root)
self.assertEqual([], list_tags(root))
def test_no_spaces(self):
with tempfile.TemporaryDirectory() as td:
with self.assertRaises(ValueError):
add_tag(Path(td), tag="bad tag", issue_id=1)
class FieldsDraftTest(unittest.TestCase):
def test_fields_kind(self):
self.assertEqual(KIND_FIELDS, normalize_kind("fields"))
self.assertIn("FIELD UPDATE", kind_label(KIND_FIELDS))
with tempfile.TemporaryDirectory() as td:
p = add_draft(
Path(td),
issue_id=5,
kind=KIND_FIELDS,
fields={"status_id": 2, "assigned_to_id": None},
)
_path, data = load_draft(Path(td), p.stem)
self.assertEqual(KIND_FIELDS, data["kind"])
self.assertFalse(data["emails_customer"])
self.assertEqual(2, data["fields"]["status_id"])
def test_set_fields_cli_parse(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"set-fields",
"--issue-id",
"1",
"--status-id",
"5",
"--assigned-to-id",
"0",
"--due-date",
"2026-08-01",
"--notes",
"closing",
"--private",
]
)
)
payload = build_fields_payload_from_args(args)
self.assertEqual(5, payload["status_id"])
self.assertIsNone(payload["assigned_to_id"])
self.assertEqual("2026-08-01", payload["due_date"])
self.assertTrue(payload["private_notes"])
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from redmine_ticket_entities import extract_entities
from redmine_tickets import (
is_closed_status,
maybe_convert_to_textile,
resolve_body_text,
build_parser,
inject_default_sync_command,
)
import argparse
class OpenOnlyTest(unittest.TestCase):
def test_closed(self):
self.assertTrue(is_closed_status({"status": {"name": "Closed", "is_closed": True}}))
self.assertTrue(is_closed_status({"status": {"name": "Closed"}}))
self.assertFalse(is_closed_status({"status": {"name": "New"}}))
class EntityTightenTest(unittest.TestCase):
def test_item_no_and_noise(self):
text = "Order ID: 1013179 UPS 1ZT33T62A898848403 item 212345678 SKU-ABC-99 order-mode responsive-title"
ents = extract_entities(text)
types = {e["type"]: e["value"] for e in ents}
self.assertEqual("1ZT33T62A898848403", types.get("tracking_ups"))
self.assertIn("order_id", types)
self.assertEqual("1013179", types["order_id"])
self.assertEqual("212345678", types.get("item_no"))
# HTML noise should not become order_id
values = [e["value"] for e in ents if e["type"] == "order_id"]
self.assertNotIn("responsive-title", values)
self.assertNotIn("lation-mode", values)
class BodyFileTextileTest(unittest.TestCase):
def test_body_file(self):
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "note.md"
p.write_text("# Hello\n\nWorld", encoding="utf-8")
ns = argparse.Namespace(body=None, body_file=p, to_textile=False)
self.assertIn("Hello", resolve_body_text(ns))
def test_to_textile_without_pandoc(self):
with patch("shutil.which", return_value=None):
out = maybe_convert_to_textile("plain")
self.assertEqual("plain", out)
class CliFlagsTest(unittest.TestCase):
def test_flags(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"search",
"--open-only",
"--where",
"assigned_to IS NULL",
"--max-tickets",
"5",
]
)
)
self.assertTrue(args.open_only)
args2 = build_parser().parse_args(
inject_default_sync_command(
["browse", "--open-only", "--no-preview", "--once"]
)
)
self.assertTrue(args2.open_only)
self.assertTrue(args2.no_preview)
args3 = build_parser().parse_args(
inject_default_sync_command(
[
"note",
"--issue-id",
"1",
"--body-file",
"/tmp/x",
"--to-textile",
"--private",
]
)
)
self.assertTrue(args3.to_textile)
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from redmine_ticket_meta import (
normalize_name,
resolve_id_by_name,
users_from_memberships,
)
from redmine_ticket_contacts import (
ContactCache,
contact_display_name,
filter_contacts,
primary_email,
)
from redmine_tickets import build_fields_payload_from_args, build_parser, inject_default_sync_command
class MetaResolveTest(unittest.TestCase):
def test_resolve_status(self):
statuses = [{"id": 1, "name": "New"}, {"id": 5, "name": "Closed"}]
self.assertEqual(5, resolve_id_by_name(statuses, "closed"))
self.assertEqual(5, resolve_id_by_name(statuses, "5"))
def test_users_from_memberships(self):
users = users_from_memberships(
[
{"user": {"id": 2, "name": "Jason T.", "login": "jason"}},
{"user": {"id": 2, "name": "Jason T."}},
{"group": {"id": 9, "name": "CS"}},
]
)
self.assertEqual(1, len(users))
self.assertEqual(2, resolve_id_by_name(users, "jason", name_keys=("name", "login")))
def test_set_fields_name_cli_parse(self):
args = build_parser().parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"set-fields",
"--issue-id",
"1",
"--status",
"Closed",
"--assigned-to",
"unassigned",
]
)
)
self.assertEqual("Closed", args.status_name)
self.assertEqual("unassigned", args.assignee_name)
# build with resolved
payload = build_fields_payload_from_args(
args,
resolved_ids={"status_id": 5, "assigned_to_id": 0},
)
self.assertEqual(5, payload["status_id"])
self.assertIsNone(payload["assigned_to_id"])
class ContactsCacheTest(unittest.TestCase):
def test_write_filter(self):
with tempfile.TemporaryDirectory() as td:
cache = ContactCache(Path(td))
cache.write_contact(
{
"id": 7,
"name": "Ada Lovelace",
"email": "ada@example.com",
"company": "Analytical Engines",
"updated_on": "2026-07-01T00:00:00Z",
"tag_list": ["customer", "vip"],
}
)
loaded = cache.load_contact(7)
self.assertEqual("Ada Lovelace", contact_display_name(loaded))
self.assertEqual("ada@example.com", primary_email(loaded))
allc = list(cache.iter_contacts())
hits = filter_contacts(allc, query="analytical", tag="customer")
self.assertEqual(1, len(hits))
self.assertFalse(cache.should_fetch({"id": 7, "updated_on": "2026-07-01T00:00:00Z"}))
self.assertTrue(cache.should_fetch({"id": 7, "updated_on": "2026-07-02T00:00:00Z"}))
class NormalizeTest(unittest.TestCase):
def test_normalize(self):
self.assertEqual("jason t.", normalize_name(" Jason T. "))
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""NMI runtime-profile contract for the Redmine CLI/TUI."""
from __future__ import annotations
from pathlib import Path
import pytest
import redmine_ticket_nmi
def test_runtime_profile_honors_explicit_production_mode_and_env_file(tmp_path):
production_env = tmp_path / "production.env"
production_env.write_text("NMI_ENDPOINT_PRODUCTION=https://app.ldrprep.com/api.php\n")
profile = redmine_ticket_nmi.resolve_runtime_profile(
environ={
"NMI_MODE": "production",
"NMI_ENV_FILE": str(production_env),
}
)
assert profile.mode == "production"
assert profile.env_file == production_env
assert profile.ready is True
assert profile.problem is None
def test_runtime_profile_rejects_empty_env_file(tmp_path):
empty_env = tmp_path / "empty.env"
empty_env.touch()
profile = redmine_ticket_nmi.resolve_runtime_profile(
environ={
"NMI_MODE": "production",
"NMI_ENV_FILE": str(empty_env),
}
)
assert profile.mode == "production"
assert profile.ready is False
assert "empty" in profile.problem.lower()
def test_brief_refuses_unready_runtime_profile(monkeypatch, tmp_path):
empty_env = tmp_path / "empty.env"
empty_env.touch()
monkeypatch.setattr(
redmine_ticket_nmi,
"resolve_runtime_profile",
lambda **_kwargs: redmine_ticket_nmi.NmiRuntimeProfile(
mode="production",
env_file=empty_env,
nmi_bin=Path("/tmp/nmi"),
endpoint=None,
ready=False,
problem="NMI env file is empty",
),
)
payload = redmine_ticket_nmi.brief_for_issue(
{"id": 123, "subject": "item 200000001"},
[{"type": "item_no", "value": "200000001", "confidence": "high"}],
mode="production",
env_file=str(empty_env),
)
assert payload["ok"] is False
assert payload["error"] == "nmi_runtime_not_ready"
assert "empty" in payload["runtime"]["problem"].lower()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from redmine_ticket_drafts import (
KIND_CUSTOMER_EMAIL,
KIND_INTERNAL_NOTE,
add_draft,
assert_write_target_allowed,
host_is_safe_for_writes,
kind_label,
list_drafts,
load_draft,
mark_draft_applied,
normalize_kind,
)
class DraftKindsTest(unittest.TestCase):
def test_normalize_legacy(self):
self.assertEqual(KIND_INTERNAL_NOTE, normalize_kind("note"))
self.assertEqual(KIND_CUSTOMER_EMAIL, normalize_kind("helpdesk_reply"))
def test_customer_email_cannot_be_private(self):
with tempfile.TemporaryDirectory() as td:
with self.assertRaises(ValueError):
add_draft(Path(td), issue_id=1, kind=KIND_CUSTOMER_EMAIL, body="hi", private=True)
def test_labels_are_explicit(self):
self.assertIn("NOT email", kind_label(KIND_INTERNAL_NOTE))
self.assertIn("WILL email", kind_label(KIND_CUSTOMER_EMAIL))
def test_queue_and_list(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
p = add_draft(root, issue_id=42, kind=KIND_INTERNAL_NOTE, body="staff only", private=True)
self.assertTrue(p.is_file())
items = list_drafts(root, status="pending")
self.assertEqual(1, len(items))
self.assertFalse(items[0].get("emails_customer"))
p2 = add_draft(root, issue_id=43, kind=KIND_CUSTOMER_EMAIL, body="hello customer")
items = list_drafts(root, status="pending")
self.assertEqual(2, len(items))
cust = [i for i in items if i["kind"] == KIND_CUSTOMER_EMAIL][0]
self.assertTrue(cust.get("emails_customer"))
mark_draft_applied(root, cust["id"], result={"ok": True})
pending = list_drafts(root, status="pending")
self.assertEqual(1, len(pending))
def test_write_target_safety(self):
self.assertTrue(host_is_safe_for_writes("http://192.168.50.170/"))
self.assertTrue(host_is_safe_for_writes("http://retired.int.ldrprep.com"))
self.assertFalse(host_is_safe_for_writes("https://rm.ldrprep.com"))
with self.assertRaises(RuntimeError):
assert_write_target_allowed("https://rm.ldrprep.com", allow_prod_writes=False)
assert_write_target_allowed("https://rm.ldrprep.com", allow_prod_writes=True)
assert_write_target_allowed("http://192.168.50.170", allow_prod_writes=False)
class CliParseSafetyTest(unittest.TestCase):
def test_customer_email_requires_confirm_flag(self):
from redmine_tickets import build_parser, inject_default_sync_command
parser = build_parser()
# Missing mandatory confirm flag should fail parse
with self.assertRaises(SystemExit):
parser.parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"customer-email",
"--issue-id",
"1",
"--body",
"hi",
]
)
)
def test_note_command_exists(self):
from redmine_tickets import build_parser, inject_default_sync_command
args = build_parser().parse_args(
inject_default_sync_command(
[
"--project",
"customer-service",
"note",
"--issue-id",
"1",
"--body",
"internal only",
"--private",
]
)
)
self.assertEqual("note", args.command)
self.assertTrue(args.private)
self.assertFalse(args.apply)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from redmine_ticket_ocr import (
build_ocr_note,
extract_text_from_file,
looks_like_generic_scan_subject,
suggest_subject_from_text,
)
from redmine_tickets import KNOWN_COMMANDS, build_parser, inject_default_sync_command, is_closed_status
class OcrSuggestTest(unittest.TestCase):
def test_generic_subject(self):
self.assertTrue(looks_like_generic_scan_subject("Scanned document from HP"))
self.assertFalse(looks_like_generic_scan_subject("Invoice — Acme Corp"))
def test_suggest_w2(self):
text = "Form W-2 Wage and Tax Statement\nName: Jane Doe\nCompany: Acme LLC\nJanuary 15, 2026"
subj = suggest_subject_from_text(text, original_subject="Scanned document")
self.assertIn("W-2", subj)
self.assertIn("Jane Doe", subj)
def test_suggest_vendor_invoice_skips_fee_bullet(self):
text = (
"- Return Check Fee up to $45.00\n"
"- A 1.5% Service Charge, 18% Annually ($1.00 Minimum) will be charged\n"
"Best Way Disposal\n"
"Invoice Number: 2404778\n"
"Invoice Due Date 9/25/2026\n"
"Total Due $307.52\n"
)
subj = suggest_subject_from_text(text, original_subject="Scanned document from LDR Office")
self.assertIn("Invoice 2404778", subj)
self.assertIn("Best Way Disposal", subj)
self.assertNotIn("Return Check Fee", subj)
def test_txt_extract(self):
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "a.txt"
p.write_text("hello ocr world " * 5, encoding="utf-8")
text, method = extract_text_from_file(p)
self.assertEqual("read", method)
self.assertIn("hello", text)
def test_note_builder(self):
note = build_ocr_note(
issue_id=1,
files=[{"filename": "a.pdf", "method": "pdftotext", "text": "body"}],
proposed_subject="Invoice — X",
)
self.assertIn("proposed_subject: Invoice — X", note)
self.assertIn("a.pdf", note)
class CliRegistrationTest(unittest.TestCase):
def test_commands(self):
self.assertIn("ocr-rename", KNOWN_COMMANDS)
self.assertIn("categories", KNOWN_COMMANDS)
args = build_parser().parse_args(
inject_default_sync_command(
["ocr-rename", "--issue-id", "1", "--no-queue-note", "--no-queue-subject"]
)
)
self.assertEqual("ocr-rename", args.command)
args2 = build_parser().parse_args(
inject_default_sync_command(["categories", "list", "--refresh"])
)
self.assertEqual("categories", args2.command)
args3 = build_parser().parse_args(
inject_default_sync_command(["walk", "--open-only", "--max-tickets", "3"])
)
self.assertTrue(args3.open_only)
if __name__ == "__main__":
unittest.main()
+364
View File
@@ -0,0 +1,364 @@
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import unittest
import redmine_contacts
class RedmineContactsBehaviorTest(unittest.TestCase):
class FakeLoopClient:
def __init__(self):
self.updated = []
def helpdesk_issues_by_contact(self, contact_id, limit):
return {"issues": [{"issue_id": 9001, "subject": "Sample ticket"}]}
def issue_with_helpdesk(self, issue_id, message_limit=100):
return {"issue": {"id": issue_id}, "helpdesk_messages": {"messages": []}}
def update_contact(self, contact_id, fields):
self.updated.append((contact_id, fields))
def test_filter_contacts_by_dates_and_tags(self):
contacts = [
{
"id": 1,
"created_on": "2026-07-01T10:00:00Z",
"updated_on": "2026-07-03T12:00:00Z",
"tag_list": ["customer", "prep"],
},
{
"id": 2,
"created_on": "2026-06-20T10:00:00Z",
"updated_on": "2026-06-25T12:00:00Z",
"tag_list": ["vendor"],
},
{
"id": 3,
"created_on": "2026-07-02T10:00:00Z",
"updated_on": "2026-07-02T12:00:00Z",
"tag_list": [],
},
]
filtered = redmine_contacts.filter_contacts(
contacts,
created_after="2026-07-01",
updated_before="2026-07-03",
with_tags=["customer"],
without_tags=["prep"],
no_tags=False,
)
self.assertEqual([], [contact["id"] for contact in filtered])
filtered_no_tags = redmine_contacts.filter_contacts(
contacts,
created_after=None,
updated_before=None,
with_tags=[],
without_tags=[],
no_tags=True,
)
self.assertEqual([3], [contact["id"] for contact in filtered_no_tags])
def test_filter_contacts_tag_matching_is_case_insensitive(self):
contacts = [{"id": 1, "tag_list": ["Customer", "Needs-Enriched"]}]
filtered = redmine_contacts.filter_contacts(
contacts,
with_tags=["customer"],
without_tags=["NEEDS-ENRICHED"],
)
self.assertEqual([], [contact["id"] for contact in filtered])
filtered_required_only = redmine_contacts.filter_contacts(
contacts,
with_tags=["CUSTOMER"],
without_tags=[],
)
self.assertEqual([1], [contact["id"] for contact in filtered_required_only])
def test_merge_tags_add_and_remove(self):
merged = redmine_contacts.merge_tags(
existing=["customer", "needs-enriched"],
add_tags=["needs-nmi-sync", "customer"],
remove_tags=["needs-enriched"],
)
self.assertEqual(["customer", "needs-nmi-sync"], merged)
def test_build_custom_field_patch_by_slug(self):
existing = [
{"id": 70, "name": "Account Number", "value": "old"},
{"id": 88, "name": "X/Twitter", "value": "@old"},
]
patch = redmine_contacts.build_custom_field_values_patch(
existing,
{"account-number": "A-123", "x-twitter": "@new"},
)
self.assertEqual({"70": "A-123", "88": "@new"}, patch)
def test_build_custom_field_patch_rejects_unknown_slug(self):
with self.assertRaises(redmine_contacts.RedmineError):
redmine_contacts.build_custom_field_values_patch(
[{"id": 70, "name": "Account Number", "value": "old"}],
{"linkedin": "https://example.com/u/abc"},
)
def test_has_contact_filters_detects_configured_filters(self):
args = argparse.Namespace(
created_after=None,
created_before=None,
updated_after="2026-07-01",
updated_before=None,
with_tag=[],
without_tag=[],
no_tags=False,
)
self.assertTrue(redmine_contacts.has_contact_filters(args))
def test_has_contact_filters_false_without_filters(self):
args = argparse.Namespace(
created_after=None,
created_before=None,
updated_after=None,
updated_before=None,
with_tag=[],
without_tag=[],
no_tags=False,
)
self.assertFalse(redmine_contacts.has_contact_filters(args))
def test_resolve_fetch_cache_mode_uses_filtered_when_filters_present_and_mode_auto(self):
args = argparse.Namespace(
cache_mode="auto",
created_after=None,
created_before=None,
updated_after="2026-07-01",
updated_before=None,
with_tag=[],
without_tag=[],
no_tags=False,
)
self.assertEqual("filtered", redmine_contacts.resolve_fetch_cache_mode(args))
def test_resolve_fetch_cache_mode_uses_full_when_no_filters_and_mode_auto(self):
args = argparse.Namespace(
cache_mode="auto",
created_after=None,
created_before=None,
updated_after=None,
updated_before=None,
with_tag=[],
without_tag=[],
no_tags=False,
)
self.assertEqual("full", redmine_contacts.resolve_fetch_cache_mode(args))
def test_build_update_fields_for_contact(self):
contact = {
"id": 42,
"tag_list": ["Customer"],
"custom_fields": [{"id": 70, "name": "Account Number", "value": "old"}],
}
args = argparse.Namespace(
set=["job_title=Buyer"],
add_tag=["needs-nmi-sync"],
remove_tag=["Customer"],
set_custom_field=["account-number=A-9"],
)
fields = redmine_contacts.build_update_fields_for_contact(contact, args)
self.assertEqual("Buyer", fields["job_title"])
self.assertEqual(["needs-nmi-sync"], fields["tag_list"])
self.assertEqual({"70": "A-9"}, fields["custom_field_values"])
def test_select_batch_contacts_respects_filters_and_limit(self):
contacts = [
{"id": 1, "tag_list": ["customer"], "updated_on": "2026-07-01T00:00:00Z"},
{"id": 2, "tag_list": ["customer"], "updated_on": "2026-06-01T00:00:00Z"},
{"id": 3, "tag_list": ["vendor"], "updated_on": "2026-05-01T00:00:00Z"},
]
args = argparse.Namespace(
created_after=None,
created_before=None,
updated_after=None,
updated_before=None,
with_tag=["customer"],
without_tag=[],
no_tags=False,
max_contacts=1,
order="as-is",
)
selected = redmine_contacts.select_batch_contacts(contacts, args)
self.assertEqual([1], [item["id"] for item in selected])
def test_select_batch_contacts_orders_oldest_updated_first(self):
contacts = [
{"id": 10, "tag_list": ["customer"], "updated_on": "2026-07-03T00:00:00Z"},
{"id": 11, "tag_list": ["customer"], "updated_on": "2026-04-01T00:00:00Z"},
{"id": 12, "tag_list": ["customer"], "updated_on": "2026-06-01T00:00:00Z"},
]
args = argparse.Namespace(
created_after=None,
created_before=None,
updated_after=None,
updated_before=None,
with_tag=["customer"],
without_tag=[],
no_tags=False,
max_contacts=2,
order="oldest-updated-first",
)
selected = redmine_contacts.select_batch_contacts(contacts, args)
self.assertEqual([11, 12], [item["id"] for item in selected])
def test_select_batch_contacts_oldest_updated_first_puts_missing_timestamps_last(self):
contacts = [
{"id": 21, "tag_list": ["customer"], "updated_on": None},
{"id": 22, "tag_list": ["customer"], "updated_on": "2026-01-01T00:00:00Z"},
{"id": 23, "tag_list": ["customer"], "updated_on": "2026-02-01T00:00:00Z"},
]
args = argparse.Namespace(
created_after=None,
created_before=None,
updated_after=None,
updated_before=None,
with_tag=["customer"],
without_tag=[],
no_tags=False,
max_contacts=3,
order="oldest-updated-first",
)
selected = redmine_contacts.select_batch_contacts(contacts, args)
self.assertEqual([22, 23, 21], [item["id"] for item in selected])
def test_load_loop_profile_normalizes_optional_defaults(self):
with tempfile.TemporaryDirectory() as tmpdir:
profile_path = Path(tmpdir) / "profile.json"
profile_path.write_text(
json.dumps(
{
"default_tag": "ai-checked",
"filter_1": {"type": "builtin", "name": "fixed-ticket-limit", "limit": 3},
"filter_2": {"type": "command", "argv": ["python3", "noop.py"]},
}
),
encoding="utf-8",
)
profile = redmine_contacts.load_loop_profile(profile_path)
self.assertEqual("ai-checked", profile["default_tag"])
self.assertEqual([], profile["remove_tags"])
self.assertEqual(False, profile["ticket_fetch"]["fetch_full_issues"])
self.assertEqual(25, profile["ticket_fetch"]["message_limit"])
def test_run_filter1_builtin_fixed_ticket_limit_returns_integer_decision(self):
profile = {
"filter_1": {"type": "builtin", "name": "fixed-ticket-limit", "limit": 4},
}
decision = redmine_contacts.run_loop_filter1(profile, {"id": 123})
self.assertEqual(4, decision["result"])
self.assertEqual("builtin fixed-ticket-limit", decision["reason"])
def test_run_loop_filter2_command_passes_bundle_and_dry_run_env(self):
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "filter2.py"
script_path.write_text(
"import json, os, sys\n"
"payload = json.load(sys.stdin)\n"
"json.dump({\n"
" 'result': True,\n"
" 'contact_id': payload['contact']['id'],\n"
" 'dry_run': os.environ.get('REDMINE_LOOP_DRY_RUN'),\n"
"}, sys.stdout)\n",
encoding="utf-8",
)
profile = {
"filter_2": {"type": "command", "argv": [sys.executable, str(script_path)]},
}
result = redmine_contacts.run_loop_filter2(
profile,
{"contact": {"id": 123}, "issues": []},
runtime={"dry_run": True, "base_url": "http://redmine.test", "project": "customer-service"},
)
self.assertEqual(True, result["result"])
self.assertEqual(123, result["contact_id"])
self.assertEqual("1", result["dry_run"])
def test_run_loop_for_contact_false_filter2_plans_default_tag_without_applying(self):
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "filter2.py"
script_path.write_text(
"import json, sys\n"
"json.load(sys.stdin)\n"
"json.dump({'result': False, 'summary': 'noop'}, sys.stdout)\n",
encoding="utf-8",
)
profile = {
"name": "test-loop",
"default_tag": "ai-checked",
"remove_tags": [],
"filter_1": {"type": "builtin", "name": "fixed-ticket-limit", "limit": 1},
"ticket_fetch": {"fetch_full_issues": False, "message_limit": 25, "sanitize_text": True},
"filter_2": {"type": "command", "argv": [sys.executable, str(script_path)]},
}
client = self.FakeLoopClient()
outcome = redmine_contacts.run_loop_for_contact(
client,
profile,
{"id": 123, "tag_list": ["needs-review"]},
runtime={"dry_run": True, "base_url": "http://redmine.test", "project": "customer-service"},
)
self.assertEqual("default-tag", outcome["action"])
self.assertEqual({"tag_list": ["ai-checked", "needs-review"]}, outcome["fields"])
self.assertEqual([], client.updated)
def test_search_help_shows_examples_and_doc_path(self):
repo = Path(__file__).resolve().parents[1]
script = repo / "redmine_contacts.py"
search_help = subprocess.run(
[sys.executable, str(script), "search", "--help"],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(search_help.returncode, 0, search_help.stderr)
self.assertIn("docs/redmine_contacts_search.md", search_help.stdout)
self.assertIn("--with-tag promotional-spam", search_help.stdout)
self.assertIn("helpdesk_ticket.contact_id", search_help.stdout)
root_help = subprocess.run(
[sys.executable, str(script), "--help"],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(root_help.returncode, 0, root_help.stderr)
self.assertIn("search --help", root_help.stdout)
self.assertIn("docs/redmine_contacts_search.md", root_help.stdout)
if __name__ == "__main__":
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from redmine_ticket_query import QueryError, compile_query, filter_by_query, issue_matches
from redmine_ticket_view import render_ticket_markdown
class PropertyQueryTest(unittest.TestCase):
def setUp(self):
self.issue = {
"id": 1,
"subject": "Please send verification code",
"updated_on": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"status": {"name": "New"},
"assigned_to": None,
"helpdesk_ticket": {
"from_address": "same@example.com",
"contact": {
"id": 9,
"email": "same@example.com",
"company": "same@example.com",
"name": "Same",
},
},
}
def test_includes(self):
self.assertTrue(issue_matches(self.issue, 'subject.includes("verification code")'))
self.assertFalse(issue_matches(self.issue, 'subject.includes("magic link")'))
def test_path_eq_path(self):
expr = "helpdesk_ticket.contact.email == helpdesk_ticket.contact.company"
self.assertTrue(issue_matches(self.issue, expr))
def test_is_null(self):
self.assertTrue(issue_matches(self.issue, "assigned_to IS NULL"))
self.issue["assigned_to"] = {"name": "Jason T."}
self.assertFalse(issue_matches(self.issue, "assigned_to IS NULL"))
self.assertTrue(issue_matches(self.issue, 'assigned_to != "Other"'))
self.assertFalse(issue_matches(self.issue, 'assigned_to != "Jason T."'))
def test_and_or_parens(self):
expr = (
'(subject.includes("verification code") OR subject.includes("magic link")) '
'AND (assigned_to IS NULL)'
)
self.assertTrue(issue_matches(self.issue, expr))
def test_ampersand_and(self):
self.assertTrue(
issue_matches(self.issue, 'subject.includes("verification") && status.name == "New"')
)
def test_since(self):
self.assertTrue(issue_matches(self.issue, 'updated_on.since("1 hour")'))
old = {
**self.issue,
"updated_on": (datetime.now(timezone.utc) - timedelta(days=10))
.isoformat()
.replace("+00:00", "Z"),
}
self.assertFalse(issue_matches(old, 'updated_on.since("1 day")'))
def test_filter_multiple_and(self):
issues = [self.issue, {**self.issue, "id": 2, "subject": "hello"}]
out = filter_by_query(
issues,
expressions=['subject.includes("verification")', 'status.name == "New"'],
)
self.assertEqual([1], [i["id"] for i in out])
def test_bad_expr(self):
with self.assertRaises(QueryError):
compile_query("subject ~~ boom")
class MarkdownRenderTest(unittest.TestCase):
def test_render_contains_core_fields(self):
md = render_ticket_markdown(
{
"id": 42,
"subject": "Test",
"status": {"name": "New"},
"project": {"name": "customer-service"},
"description": "Body here",
"journals": [],
"updated_on": "2026-07-14T00:00:00Z",
"helpdesk_ticket": {"from_address": "a@b.c", "contact": {"id": 1}},
}
)
self.assertIn("# #42", md)
self.assertIn("Body here", md)
self.assertIn("a@b.c", md)
if __name__ == "__main__":
unittest.main()
+541
View File
@@ -0,0 +1,541 @@
import tempfile
import unittest
from pathlib import Path
import redmine_tickets
class RedmineTicketsCacheTest(unittest.TestCase):
def test_ticket_cache_skips_when_manifest_updated_on_matches(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
issue = {"id": 123, "updated_on": "2026-07-01T10:00:00Z"}
cache.write_issue(issue)
self.assertFalse(cache.should_fetch({"id": 123, "updated_on": "2026-07-01T10:00:00Z"}))
self.assertTrue(cache.should_fetch({"id": 123, "updated_on": "2026-07-02T10:00:00Z"}))
def test_manifest_records_action_idempotency(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.record_action(123, "spam-check", "not-spam", {"score": 0})
self.assertTrue(cache.has_action(123, "spam-check"))
entry = cache.manifest["tickets"]["123"]["actions"]["spam-check"]
self.assertEqual("not-spam", entry["result"])
self.assertEqual({"score": 0}, entry["metadata"])
def test_ticket_cache_can_use_alternate_state_store(self):
class MemoryStateStore:
def __init__(self):
self.states = {}
def get_ticket_state(self, issue_id):
return self.states.get(str(issue_id))
def upsert_ticket_state(self, issue_id, fields):
self.states.setdefault(str(issue_id), {}).update(fields)
def has_action(self, issue_id, action_name):
state = self.get_ticket_state(issue_id) or {}
return action_name in state.get("actions", {})
def record_action(self, issue_id, action_name, result, metadata=None):
state = self.states.setdefault(str(issue_id), {})
state.setdefault("actions", {})[action_name] = {
"result": result,
"metadata": metadata or {},
}
with tempfile.TemporaryDirectory() as tmp:
store = MemoryStateStore()
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service", state_store=store)
cache.write_issue({"id": 123, "updated_on": "2026-07-01T10:00:00Z"})
cache.record_action(123, "spam-check", "spam", {"score": 2})
self.assertFalse(cache.should_fetch({"id": 123, "updated_on": "2026-07-01T10:00:00Z"}))
self.assertTrue(store.has_action(123, "spam-check"))
class RedmineTicketsBehaviorTest(unittest.TestCase):
def test_filter_issues_by_status_category_and_updated_since(self):
issues = [
{
"id": 1,
"updated_on": "2026-07-01T10:00:00Z",
"status": {"name": "New"},
"category": {"name": "Spam"},
},
{
"id": 2,
"updated_on": "2026-06-01T10:00:00Z",
"status": {"name": "Closed"},
"category": {"name": "Spam"},
},
{
"id": 3,
"updated_on": "2026-07-02T10:00:00Z",
"status": {"name": "New"},
"category": {"name": "Support"},
},
]
filtered = redmine_tickets.filter_issues(
issues,
updated_since="2026-07-01",
status="New",
category="Spam",
)
self.assertEqual([1], [issue["id"] for issue in filtered])
def test_select_issues_orders_and_limits(self):
issues = [
{"id": 1, "updated_on": "2026-07-01T00:00:00Z"},
{"id": 2, "updated_on": "2026-07-03T00:00:00Z"},
{"id": 3, "updated_on": "2026-07-02T00:00:00Z"},
]
selected = redmine_tickets.select_issues(
issues,
order="newest-updated-first",
max_tickets=2,
)
self.assertEqual([2, 3], [issue["id"] for issue in selected])
class RedmineTicketsDecompositionTest(unittest.TestCase):
def test_decompose_repackify_ticket_plans_timeline_entity_and_contact_proposal(self):
issue = {
"id": 41424,
"subject": "Gaylord inquiry-RePackify",
"created_on": "2026-06-23T13:06:02Z",
"updated_on": "2026-06-23T13:06:02Z",
"description": "Hi there,\nWe’re interested in purchasing used Gaylord boxes and wanted to see if you have any available.\nSarah Johnson\nsarah@repackify.com",
"helpdesk_ticket": {
"from_address": "support@repackify.com",
"contact": {"id": 4470, "email": "support@repackify.com", "name": "support"},
},
}
plan = redmine_tickets.decompose_issue_dry_run(issue)
self.assertEqual(41424, plan["source"]["redmine_issue_id"])
self.assertIn("partnership-buy-from-us", plan["buckets"])
self.assertEqual("Timeline/2026-06/2026-06-23_repackify-gaylord-inquiry.md", plan["timeline_events"][0]["path"])
self.assertEqual("entities/organizations/repackify.md", plan["entities"][0]["path"])
self.assertIn("wiki/", "\n".join(plan["compatibility_notes"]))
self.assertEqual(4470, plan["redmine_contact_updates"][0]["contact_id"])
self.assertIn("buy-from-us", plan["redmine_contact_updates"][0]["add_tags"])
def test_decompose_u2wear_forwarded_message_extracts_fulfillment_context(self):
issue = {
"id": 41448,
"subject": "Fwd: Have You Found a Suitable Returns Management Provider?",
"created_on": "2026-06-23T19:38:02Z",
"description": "---------- Forwarded message ---------\nFrom: The Whole Shebang <sales@u2wear.com>\nSubject: Re: Have You Found a Suitable Returns Management Provider?\n\nHi Christian,\nI am now back in the US and ready to activate the winter fulfillment strategy for my brands, ELORA and u2wear.\nDoes your team still have the capacity to support the fulfillment and returns workflow we previously discussed?\n\nOn Mon, Mar 2, 2026 at 10:29 AM wrote:",
"helpdesk_ticket": {
"from_address": "sales@u2wear.com",
"contact": {"id": 3919, "email": "sales@u2wear.com", "name": "The Whole Shebang"},
},
}
cleaned = redmine_tickets.clean_ticket_text(issue)
self.assertIn("winter fulfillment strategy", cleaned)
self.assertNotIn("Forwarded message", cleaned)
plan = redmine_tickets.decompose_issue_dry_run(issue)
self.assertIn("customer-new-project", plan["buckets"])
self.assertIn("meaningful-event", plan["buckets"])
self.assertIn("fulfillment/returns opportunity", plan["analysis"]["summary"])
self.assertTrue(any("returns management" in concept["name"] for concept in plan["concepts"]))
self.assertNotIn("rate-sheet-gap", plan["tags"])
def test_decompose_snuggle_puppy_ticket_detects_rate_sheet_gap(self):
issue = {
"id": 41504,
"subject": "3PL & FBA Returns",
"created_on": "2026-06-29T14:11:51Z",
"updated_on": "2026-07-06T19:53:44Z",
"description": "From: Justine Micks <justine.micks@snugglepuppy.com>\nSubject: 3PL & FBA Returns\n\nMessage Body:\nHello, we are looking for new potential partners for warehousing & FBA returns. I would be interested in capabilities and pricing.\n\n--\nThis e-mail was sent from a contact form on (https://ldrprep.com)",
"helpdesk_ticket": {
"from_address": "justine.micks@snugglepuppy.com",
"contact": {"id": 4476, "email": "justine.micks@snugglepuppy.com", "name": "justine micks"},
},
"journals": [
{"created_on": "2026-07-06T18:29:02Z", "user": {"name": "Anonymous"}, "notes": "That link doesn't seem to calculate anything. Could you send me a rate sheet?"},
{"created_on": "2026-07-06T19:53:44Z", "user": {"name": "Matthew C."}, "notes": "We do not have a sheet for returns pricing. Rates are typically as follows."},
],
}
cleaned = redmine_tickets.clean_ticket_text(issue)
self.assertNotIn("Message Body:", cleaned)
self.assertNotIn("This e-mail was sent from", cleaned)
self.assertIn("Could you send me a rate sheet?", cleaned)
plan = redmine_tickets.decompose_issue_dry_run(issue)
self.assertIn("customer-new-project", plan["buckets"])
self.assertIn("meaningful-event", plan["buckets"])
self.assertIn("rate-sheet-gap", plan["tags"])
self.assertEqual("Timeline/2026-07/2026-07-06_snuggle-puppy-3pl-fba-returns.md", plan["timeline_events"][0]["path"])
self.assertEqual("entities/organizations/snuggle-puppy.md", plan["entities"][0]["path"])
self.assertIn("rate sheet", plan["analysis"]["summary"].lower())
self.assertTrue(any("calculator" in point.lower() for point in plan["analysis"]["key_points"]))
self.assertTrue(plan["review_required"])
class RedmineTicketsSyncTest(unittest.TestCase):
class FakeClient:
def __init__(self):
self.full_fetches = []
def list_issues(self, **kwargs):
return [
{"id": 100, "updated_on": "2026-07-01T00:00:00Z", "subject": "new"},
{"id": 101, "updated_on": "2026-07-02T00:00:00Z", "subject": "newer"},
]
def get_issue_with_includes(self, issue_id, includes):
self.full_fetches.append((issue_id, tuple(includes)))
return {"id": issue_id, "updated_on": f"2026-07-0{issue_id - 99}T00:00:00Z", "helpdesk_ticket": {"id": issue_id + 1000}}
def test_sync_fetches_only_missing_or_updated_tickets(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue({"id": 100, "updated_on": "2026-07-01T00:00:00Z"})
client = self.FakeClient()
result = redmine_tickets.sync_tickets(
client,
cache,
updated_since=None,
updated_before=None,
status=None,
category=None,
max_tickets=None,
order="as-is",
apply=True,
include_journals=True,
)
self.assertEqual(2, result.seen)
self.assertEqual(1, result.skipped_cached)
self.assertEqual(1, result.fetched)
self.assertEqual([(101, ("journals", "helpdesk"))], client.full_fetches)
self.assertTrue(cache.issue_path(101).is_file())
class RedmineTicketsSpamCheckTest(unittest.TestCase):
def test_score_ticket_spam_flags_promotional_helpdesk_ticket(self):
issue = {
"id": 41789,
"subject": "Up to 20% Off Select Fans & Blowers",
"helpdesk_ticket": {
"from_address": "offers@example.com",
"contact": {"email": "offers@example.com"},
},
"journals": [{"notes": "Limited time sale and discount coupon."}],
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertTrue(result.is_spam)
self.assertEqual("spam-promotional", result.category)
self.assertGreaterEqual(result.score, 3)
self.assertIn("promotional language", "; ".join(result.reasons))
def test_score_ticket_classifies_sales_pitch_as_promotional_spam(self):
issue = {
"id": 50001,
"subject": "Commercial cleaning services for your warehouse",
"helpdesk_ticket": {"from_address": "sales@sparkleclean.example"},
"journals": [{"notes": "We would like to offer janitorial cleaning services for your facility."}],
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("spam-promotional", result.category)
self.assertTrue(result.is_spam)
def test_score_ticket_classifies_fba_notification_as_operational_not_promotional(self):
issue = {
"id": 41753,
"subject": "Automated unfulfillable FBA inventory removal notification",
"helpdesk_ticket": {"from_address": "fba-noreply@amazon.com"},
"journals": [{"notes": "This is an automated notification about your FBA inventory."}],
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("automated-operational", result.category)
self.assertFalse(result.is_spam)
def test_score_ticket_classifies_receipts_bounces_and_security_as_operational(self):
examples = [
("USPS eReceipt", "donotreply@ereceipt.usps.gov", "Money Order Fee Total"),
("Undelivered Mail Returned to Sender", "bounces-noreply@mandrillapp.com", "message could not be delivered"),
("Critical security alert", "no-reply@accounts.google.com", "saved passwords were found online"),
("Your call transcription from 240-521-4036", "noreply@voip.ms", "This email contains the transcription of the following call"),
("New voicemail in mailbox 1", "noreply@voipinterface.net", "You have a new message in mailbox"),
]
for subject, sender, body in examples:
with self.subTest(subject=subject):
issue = {
"id": 90001,
"subject": subject,
"helpdesk_ticket": {"from_address": sender},
"description": body,
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("automated-operational", result.category)
self.assertFalse(result.is_spam)
def test_score_ticket_does_not_close_established_or_inbound_business_inquiries(self):
examples = [
(
"Fwd: Have You Found a Suitable Returns Management Provider?",
"sales@u2wear.com",
"Does your team still have the capacity to support the fulfillment and returns workflow we previously discussed?",
),
(
"Gaylord inquiry-RePackify",
"support@repackify.com",
"We’re interested in purchasing used Gaylord boxes and wanted to see if you have any available.",
),
]
for subject, sender, body in examples:
with self.subTest(subject=subject):
issue = {
"id": 90002,
"subject": subject,
"helpdesk_ticket": {"from_address": sender},
"description": body,
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("unknown-review", result.category)
self.assertFalse(result.is_spam)
def test_score_ticket_does_not_treat_ldr_reply_to_real_inquiry_as_spam(self):
issue = {
"id": 41504,
"subject": "3PL & FBA Returns",
"description": "Hello, do you process FBA returns for brands?",
"helpdesk_ticket": {"from_address": "justine.micks@snugglepuppy.com"},
"journals": [
{"user": {"name": "Matthew C."}, "notes": "Rates are typically as follows. We advertise prep pricing separately."}
],
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("unknown-review", result.category)
self.assertFalse(result.is_spam)
def test_score_ticket_does_not_treat_customer_seo_context_as_sales_spam(self):
issue = {
"id": 41237,
"subject": "RE: New message from our team may require attention",
"description": "They are both the same. We just use different titles for SEO. Please ensure all repackaged outfits include pants.",
"helpdesk_ticket": {"from_address": "manager@sonisha.com"},
"journals": [],
}
result = redmine_tickets.score_ticket_spam(issue)
self.assertEqual("unknown-review", result.category)
self.assertFalse(result.is_spam)
def test_run_spam_check_skips_already_checked_ticket(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue(
{
"id": 1,
"updated_on": "2026-07-01T00:00:00Z",
"subject": "Up to 20% Off",
"helpdesk_ticket": {"from_address": "deals@example.com"},
"journals": [{"notes": "coupon discount"}],
}
)
cache.record_action(1, "spam-check", "spam", {"score": 4})
results = redmine_tickets.run_spam_check(cache, apply=True)
self.assertEqual(0, len(results))
class RedmineTicketsCloseIfSpamTest(unittest.TestCase):
class FakeClient:
def __init__(self):
self.updated = []
self.contact_lookups = []
self.contacts = {}
self.contact_updates = []
def get_contact(self, contact_id):
return self.contacts.get(contact_id, {"id": contact_id, "tag_list": []})
def update_contact(self, contact_id, fields, impersonate_user=None):
self.contact_updates.append((contact_id, fields, impersonate_user))
def recent_issues_for_contact(self, contact_id, limit):
self.contact_lookups.append((contact_id, limit))
return [
{
"id": 10,
"subject": "Up to 20% Off Select Fans & Blowers",
"helpdesk_ticket": {"from_address": "reply@globalindustrial.com"},
"journals": [],
},
{
"id": 11,
"subject": "Limited time sale",
"helpdesk_ticket": {"from_address": "reply@globalindustrial.com"},
"journals": [],
},
]
def update_issue(self, issue_id, fields, impersonate_user=None):
self.updated.append((issue_id, fields, impersonate_user))
def test_close_if_spam_dry_run_builds_payload_and_contact_review(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue(
{
"id": 41788,
"updated_on": "2026-07-07T20:38:01Z",
"subject": "Up to 20% Off Select Fans & Blowers",
"helpdesk_ticket": {
"from_address": "reply@globalindustrial.com",
"contact": {"id": 321, "email": "reply@globalindustrial.com"},
},
"journals": [],
}
)
cache.record_action(41788, "spam-check", "spam", {"score": 2})
client = self.FakeClient()
plans = redmine_tickets.run_close_if_spam(
client,
cache,
close_status_id=5,
contact_ticket_limit=10,
apply=False,
)
self.assertEqual(1, len(plans))
self.assertEqual(41788, plans[0].issue_id)
self.assertEqual({"status_id": 5, "notes": "Closed as likely spam by redmine_tickets.py close-if-spam."}, plans[0].payload)
self.assertEqual(321, plans[0].contact_id)
self.assertTrue(plans[0].contact_review["likely_spammer"])
self.assertEqual([(321, 10)], client.contact_lookups)
self.assertEqual([], client.updated)
self.assertFalse(cache.has_action(41788, "close-if-spam"))
def test_close_if_spam_apply_updates_issue_and_records_idempotency(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue({"id": 1, "updated_on": "2026-07-01T00:00:00Z", "helpdesk_ticket": {}})
cache.record_action(1, "spam-check", "spam", {"score": 2})
client = self.FakeClient()
redmine_tickets.run_close_if_spam(client, cache, close_status_id=5, apply=True)
second = redmine_tickets.run_close_if_spam(client, cache, close_status_id=5, apply=True)
self.assertEqual(
[(1, {"status_id": 5, "notes": "Closed as likely spam by redmine_tickets.py close-if-spam."}, "rebot")],
client.updated,
)
self.assertEqual([], second)
self.assertTrue(cache.has_action(1, "close-if-spam"))
def test_close_if_spam_apply_can_override_impersonation_user(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue({"id": 2, "updated_on": "2026-07-01T00:00:00Z", "helpdesk_ticket": {}})
cache.record_action(2, "spam-check", "spam", {"score": 2})
client = self.FakeClient()
redmine_tickets.run_close_if_spam(
client,
cache,
close_status_id=5,
apply=True,
impersonate_user="alternate-agent",
)
self.assertEqual("alternate-agent", client.updated[0][2])
def test_close_if_spam_skips_automated_operational_ticket_by_default(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue(
{
"id": 41753,
"updated_on": "2026-07-01T00:00:00Z",
"subject": "Automated unfulfillable FBA inventory removal notification",
"helpdesk_ticket": {"from_address": "fba-noreply@amazon.com", "contact": {"id": 51}},
}
)
cache.record_action(41753, "spam-check", "automated-operational", {"score": 2, "category": "automated-operational"})
client = self.FakeClient()
plans = redmine_tickets.run_close_if_spam(client, cache, close_status_id=5, apply=False)
self.assertEqual([], plans)
self.assertEqual([], client.contact_lookups)
def test_close_if_spam_skips_contact_tagged_customer(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue(
{
"id": 500,
"updated_on": "2026-07-01T00:00:00Z",
"subject": "Up to 20% Off",
"helpdesk_ticket": {"from_address": "deals@example.com", "contact": {"id": 777}},
}
)
cache.record_action(500, "spam-check", "spam", {"score": 2, "category": "spam-promotional"})
client = self.FakeClient()
client.contacts[777] = {"id": 777, "tag_list": ["customer"]}
plans = redmine_tickets.run_close_if_spam(client, cache, close_status_id=5, apply=False)
self.assertEqual([], plans)
self.assertEqual([], client.updated)
self.assertEqual([], client.contact_lookups)
def test_tag_maybe_customer_contacts_adds_review_tag_for_inbound_inquiry(self):
with tempfile.TemporaryDirectory() as tmp:
cache = redmine_tickets.TicketCache(Path(tmp), "customer-service")
cache.write_issue(
{
"id": 501,
"updated_on": "2026-07-01T00:00:00Z",
"subject": "Gaylord inquiry-RePackify",
"description": "We’re interested in purchasing used Gaylord boxes.",
"helpdesk_ticket": {"from_address": "support@repackify.com", "contact": {"id": 888}},
}
)
cache.record_action(
501,
"spam-check",
"not-spam",
{"score": 0, "category": "unknown-review", "reasons": ["inbound or established business inquiry"]},
)
client = self.FakeClient()
client.contacts[888] = {"id": 888, "tag_list": ["vendor"]}
results = redmine_tickets.run_tag_maybe_customer_contacts(client, cache, apply=True)
self.assertEqual([888], [result["contact_id"] for result in results])
self.assertEqual([(888, {"tag_list": ["maybe-customer", "vendor"]}, "rebot")], client.contact_updates)
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Unit tests for server-side remote filter helpers in redmine_tickets.py."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from redmine_tickets import ( # noqa: E402
build_parser,
build_remote_updated_on_value,
coerce_time_value,
format_redmine_time,
inject_default_sync_command,
)
from redmine_contacts import RedmineError # noqa: E402
class RemoteFilterHelpersTest(unittest.TestCase):
def test_format_redmine_time_date_only(self):
dt = datetime(2026, 6, 14, 12, 30, tzinfo=timezone.utc)
self.assertEqual("2026-06-14", format_redmine_time(dt, date_only=True))
self.assertEqual("2026-06-14T12:30:00Z", format_redmine_time(dt, date_only=False))
def test_coerce_absolute_date_is_date_only(self):
dt, date_only = coerce_time_value("2026-06-14")
self.assertTrue(date_only)
self.assertEqual(dt.date().isoformat(), "2026-06-14")
def test_coerce_relative_days(self):
now = datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.utc)
dt, date_only = coerce_time_value("30d", now=now)
self.assertFalse(date_only)
self.assertEqual(dt.isoformat().replace("+00:00", "Z"), "2026-06-14T12:00:00Z")
def test_coerce_relative_days_ago_phrase(self):
now = datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.utc)
dt, date_only = coerce_time_value("2 days ago", now=now)
self.assertFalse(date_only)
self.assertEqual(dt.isoformat().replace("+00:00", "Z"), "2026-07-12T12:00:00Z")
def test_build_remote_updated_on_raw(self):
self.assertEqual(
">=2014-01-02T08:12:32Z",
build_remote_updated_on_value(raw=">=2014-01-02T08:12:32Z"),
)
def test_build_remote_updated_on_bare_timestamp_implies_gte(self):
self.assertEqual(
">=2026-06-14",
build_remote_updated_on_value(raw="2026-06-14"),
)
def test_build_remote_updated_on_since_date(self):
self.assertEqual(
">=2026-06-14",
build_remote_updated_on_value(since="2026-06-14"),
)
def test_build_remote_updated_on_range(self):
self.assertEqual(
"><2026-01-01|2026-06-01",
build_remote_updated_on_value(since="2026-01-01", before="2026-06-01"),
)
def test_build_remote_updated_on_rejects_mixed_raw_and_convenience(self):
with self.assertRaises(RedmineError):
build_remote_updated_on_value(raw=">=2026-01-01", since="30d")
def test_cli_accepts_remote_flags_without_explicit_sync_word(self):
raw = [
"--base-url",
"https://rm.ldrprep.com",
"--project",
"customer-service",
"--cache-dir",
"/tmp/cache",
"--remote-updated-since",
"30d",
"--remote-status-id",
"*",
]
norm = inject_default_sync_command(raw)
args = build_parser().parse_args(norm)
self.assertEqual(args.command, "sync")
self.assertEqual(args.remote_updated_since, "30d")
self.assertEqual(args.status_id, "*")
value = build_remote_updated_on_value(
raw=args.remote_updated_on,
since=args.remote_updated_since,
before=args.remote_updated_before,
now=datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.utc),
)
self.assertEqual(value, ">=2026-06-14T12:00:00Z")
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
"""End-to-end tests for the Redmine -> CRM sync pipeline."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from unittest import mock
import pytest
import responses
import contact_enrichment
import sync_redmine_contacts
from redmine_crm.crm_client import CrmClient
from redmine_crm.redmine_client import RedmineClient
BASE_URL = "https://redmine.example.com"
@responses.activate
def test_pipeline_full_run_creates_contact():
contacts = [
{
"id": 1,
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace@example.com",
"updated_at": "2026-07-04T12:00:00Z",
}
]
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": contacts, "total_count": 1},
status=200,
)
with tempfile.TemporaryDirectory() as tmpdir:
state_file = Path(tmpdir) / "state.json"
redmine = RedmineClient(BASE_URL, "secret", project_id=1)
crm = CrmClient()
crm._store.clear()
state = sync_redmine_contacts.FileSyncState(state_file)
config = contact_enrichment.EnrichmentConfig()
result = sync_redmine_contacts.run_pipeline(
redmine=redmine,
crm=crm,
enrichment_config=config,
state=state,
batch_size=100,
dry_run=False,
)
assert result["metrics"]["fetched"] == 1
assert result["metrics"]["enriched"] == 1
assert result["metrics"]["created"] == 1
assert result["metrics"]["errors"] == 0
assert state.last_sync_timestamp is not None
@responses.activate
def test_dry_run_does_not_write_crm():
contacts = [
{
"id": 2,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"updated_at": "2026-07-04T12:00:00Z",
}
]
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": contacts, "total_count": 1},
status=200,
)
with tempfile.TemporaryDirectory() as tmpdir:
state_file = Path(tmpdir) / "state.json"
redmine = RedmineClient(BASE_URL, "secret", project_id=1)
crm = CrmClient()
crm._store.clear()
state = sync_redmine_contacts.FileSyncState(state_file)
result = sync_redmine_contacts.run_pipeline(
redmine=redmine,
crm=crm,
state=state,
batch_size=100,
dry_run=True,
)
assert result["dry_run"] is True
assert result["metrics"]["created"] == 0
assert not crm._store
@responses.activate
def test_pipeline_uses_last_sync_timestamp():
contacts = [
{
"id": 3,
"first_name": "Alan",
"last_name": "Turing",
"email": "alan@example.com",
"updated_at": "2026-07-05T12:00:00Z",
}
]
responses.add(
responses.GET,
f"{BASE_URL}/contacts.json",
json={"contacts": contacts, "total_count": 1},
status=200,
)
with tempfile.TemporaryDirectory() as tmpdir:
state_file = Path(tmpdir) / "state.json"
state_file.write_text(json.dumps({"last_sync_timestamp": "2026-07-04T00:00:00Z"}), encoding="utf-8")
redmine = RedmineClient(BASE_URL, "secret", project_id=1)
crm = CrmClient()
crm._store.clear()
state = sync_redmine_contacts.FileSyncState(state_file)
result = sync_redmine_contacts.run_pipeline(
redmine=redmine,
crm=crm,
state=state,
batch_size=100,
dry_run=False,
)
assert result["metrics"]["fetched"] == 1
request = responses.calls[0].request
assert "updated_on" in (request.url or "")
def test_cli_requires_api_key():
env = {"REDMINE_API_KEY": ""}
with mock.patch.dict(os.environ, env, clear=True):
with pytest.raises(RuntimeError, match="REDMINE_API_KEY"):
sync_redmine_contacts._redmine_client_from_args(
sync_redmine_contacts.build_argument_parser().parse_args(["--base-url", BASE_URL, "--dry-run"])
)
def test_file_sync_state_persists_and_reads():
with tempfile.TemporaryDirectory() as tmpdir:
state_file = Path(tmpdir) / "state.json"
state = sync_redmine_contacts.FileSyncState(state_file)
metrics = sync_redmine_contacts.SyncMetrics()
metrics.fetched = 5
state.record_run("2026-07-05T08:00:00Z", metrics)
new_state = sync_redmine_contacts.FileSyncState(state_file)
assert new_state.last_sync_timestamp == "2026-07-05T08:00:00Z"
assert new_state.last_metrics is not None
assert new_state.last_metrics["fetched"] == 5
def test_metrics_aggregate_counts():
metrics = sync_redmine_contacts.SyncMetrics()
metrics.fetched = 10
metrics.enriched = 9
metrics.skipped = 1
metrics.created = 5
metrics.updated = 3
metrics.unchanged = 1
metrics.errors = 0
metrics.finished_at = sync_redmine_contacts._utc_now_iso()
d = metrics.to_dict()
assert d["fetched"] == 10
assert d["enriched"] == 9
assert d["created"] + d["updated"] + d["unchanged"] == 9
+133
View File
@@ -0,0 +1,133 @@
"""Regression: `rtl tui` must reach its TUI launcher instead of falling through to exit 1."""
from __future__ import annotations
import redmine_tickets
def test_tui_command_dispatches_to_tui_launcher(monkeypatch, tmp_path):
called = {}
class DummyCache:
def __init__(self, cache_dir, project):
self.cache_dir = cache_dir
self.project = project
def fake_tui_launcher(args, cache):
called["command"] = args.command
called["project"] = cache.project
return 73
monkeypatch.setattr(redmine_tickets, "TicketCache", DummyCache)
monkeypatch.setattr(redmine_tickets, "run_tui_cli", fake_tui_launcher)
config = tmp_path / "live.json"
config.write_text(
'{"base-url":"https://rm.ldrprep.com","project":"customer-service",'
'"cache-dir":"/tmp/redmine-cache"}',
encoding="utf-8",
)
rc = redmine_tickets.main(
["--config", str(config), "tui", "--open-only", "--max-tickets", "5"]
)
assert rc == 73
assert called == {"command": "tui", "project": "customer-service"}
class DummyCache:
def __init__(self, cache_dir, project):
self.cache_dir = cache_dir
self.project = project
def test_ocr_rename_command_reaches_handler_not_silent_exit(monkeypatch, tmp_path):
"""ocr-rename used to be nested under search after `return 0` and fell through."""
called = {}
def fake_load(cache, issue_id):
called["issue_id"] = issue_id
raise RuntimeError("ocr-rename handler reached")
monkeypatch.setattr(redmine_tickets, "TicketCache", DummyCache)
monkeypatch.setattr(redmine_tickets, "load_cached_issue", fake_load)
try:
rc = redmine_tickets.main(
[
"--project",
"hr",
"--cache-dir",
str(tmp_path),
"ocr-rename",
"--issue-id",
"43476",
"--json",
]
)
except RuntimeError as exc:
assert "ocr-rename handler reached" in str(exc)
assert called == {"issue_id": 43476}
return
raise AssertionError(f"ocr-rename fell through without reaching handler (rc={rc})")
def test_attachments_list_reaches_handler_not_silent_exit(monkeypatch, tmp_path):
called = {}
def fake_load(cache, issue_id):
called["issue_id"] = issue_id
raise RuntimeError("attachments handler reached")
monkeypatch.setattr(redmine_tickets, "TicketCache", DummyCache)
monkeypatch.setattr(redmine_tickets, "load_cached_issue", fake_load)
try:
rc = redmine_tickets.main(
[
"--project",
"hr",
"--cache-dir",
str(tmp_path),
"attachments",
"list",
"--issue-id",
"43476",
]
)
except RuntimeError as exc:
assert "attachments handler reached" in str(exc)
assert called == {"issue_id": 43476}
return
raise AssertionError(f"attachments fell through without reaching handler (rc={rc})")
def test_view_command_reaches_handler_not_silent_exit(monkeypatch, tmp_path):
called = {}
def fake_load(cache, issue_id):
called["issue_id"] = issue_id
raise RuntimeError("view handler reached")
monkeypatch.setattr(redmine_tickets, "TicketCache", DummyCache)
monkeypatch.setattr(redmine_tickets, "load_cached_issue", fake_load)
try:
rc = redmine_tickets.main(
[
"--project",
"hr",
"--cache-dir",
str(tmp_path),
"view",
"--issue-id",
"43476",
"--print",
]
)
except RuntimeError as exc:
assert "view handler reached" in str(exc)
assert called == {"issue_id": 43476}
return
raise AssertionError(f"view fell through without reaching handler (rc={rc})")
+28
View File
@@ -0,0 +1,28 @@
"""Curses launch must fail clearly outside a real terminal."""
from __future__ import annotations
from pathlib import Path
import redmine_ticket_tui
def test_tui_refuses_non_tty_with_clear_message(monkeypatch, capsys):
class Args:
cache_dir = Path("/tmp/redmine-cache")
project = "customer-service"
base_url = "https://rm.ldrprep.com"
allow_prod_writes = False
apply = False
class Cache:
cache_dir = Args.cache_dir
project = Args.project
monkeypatch.setattr(redmine_ticket_tui.os, "isatty", lambda _fd: False)
rc = redmine_ticket_tui.main_cli(Args(), Cache())
captured = capsys.readouterr()
assert rc == 1
assert "requires an interactive terminal" in captured.err