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 \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 \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)