Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/model.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/model.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/model.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/model.py Sat Nov 15 01:14:46 2014 @@ -1,23 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2013 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.org/wiki/TracLicense. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://trac.edgewall.org/log/. + from __future__ import with_statement from datetime import datetime, timedelta -import os.path from StringIO import StringIO import tempfile import shutil import unittest +import trac.tests.compat from trac import core from trac.attachment import Attachment from trac.core import TracError, implements from trac.resource import ResourceNotFound +from trac.test import EnvironmentStub from trac.ticket.model import ( Ticket, Component, Milestone, Priority, Type, Version ) +from trac.ticket.roadmap import MilestoneModule from trac.ticket.api import ( IMilestoneChangeListener, ITicketChangeListener, TicketSystem ) -from trac.test import EnvironmentStub from trac.tests.resource import TestResourceChangeListener from trac.util.datefmt import from_utimestamp, to_utimestamp, utc @@ -41,6 +55,36 @@ class TestTicketChangeListener(core.Comp self.action = 'deleted' self.ticket = ticket + # the listener has no ticket_comment_modified and ticket_change_deleted + + +class TestTicketChangeListener_2(core.Component): + implements(ITicketChangeListener) + + def ticket_created(self, ticket): + pass + + def ticket_changed(self, ticket, comment, author, old_values): + pass + + def ticket_deleted(self, ticket): + pass + + def ticket_comment_modified(self, ticket, cdate, author, comment, + old_comment): + self.action = 'comment_modified' + self.ticket = ticket + self.cdate = cdate + self.author = author + self.comment = comment + self.old_comment = old_comment + + def ticket_change_deleted(self, ticket, cdate, changes): + self.action = 'change_deleted' + self.ticket = ticket + self.cdate = cdate + self.changes = changes + class TicketTestCase(unittest.TestCase): @@ -118,9 +162,9 @@ class TicketTestCase(unittest.TestCase): log = ticket3.get_changelog() self.assertEqual(len(log), 3) ok_vals = ['foo', 'summary', 'comment'] - self.failUnless(log[0][2] in ok_vals) - self.failUnless(log[1][2] in ok_vals) - self.failUnless(log[2][2] in ok_vals) + self.assertIn(log[0][2], ok_vals) + self.assertIn(log[1][2], ok_vals) + self.assertIn(log[2][2], ok_vals) def test_create_ticket_5(self): ticket3 = self._modify_a_ticket() @@ -157,7 +201,7 @@ class TicketTestCase(unittest.TestCase): ticket.save_changes() for change in ticket.get_changelog(): - self.assertEqual(None, change[1]) + self.assertIsNone(change[1]) def test_comment_with_whitespace_only_is_not_saved(self): ticket = Ticket(self.env) @@ -310,7 +354,7 @@ class TicketTestCase(unittest.TestCase): self.assertEqual('john', ticket['reporter']) # An unknown field - assert ticket['bar'] is None + self.assertIsNone(ticket['bar']) # Custom field self.assertEqual('bar', ticket['foo']) @@ -599,6 +643,19 @@ class TicketCommentEditTestCase(TicketCo self.assertEqual((i, t[i], 'joe (%d)' % i, 'Comment 1 (%d)' % i), history[i]) + def test_change_listener_comment_modified(self): + listener = TestTicketChangeListener_2(self.env) + ticket = Ticket(self.env, self.id) + ticket.modify_comment(cdate=self.t2, author='jack', + comment='New Comment 2', when=datetime.now(utc)) + + self.assertEqual('comment_modified', listener.action) + self.assertEqual(ticket, listener.ticket) + self.assertEqual(self.t2, listener.cdate) + self.assertEqual('jack', listener.author) + self.assertEqual('New Comment 2', listener.comment) + self.assertEqual('Comment 2', listener.old_comment) + class TicketCommentDeleteTestCase(TicketCommentTestCase): @@ -632,8 +689,8 @@ class TicketCommentDeleteTestCase(Ticket ticket.delete_change(cnum=4, when=t) self.assertEqual('a, b', ticket['keywords']) self.assertEqual('change3', ticket['foo']) - self.assertEqual(None, ticket.get_change(cnum=4)) - self.assertNotEqual(None, ticket.get_change(cnum=3)) + self.assertIsNone(ticket.get_change(cnum=4)) + self.assertIsNotNone(ticket.get_change(cnum=3)) self.assertEqual(t, ticket.time_changed) def test_delete_last_comment_when_custom_field_gone(self): @@ -651,13 +708,13 @@ class TicketCommentDeleteTestCase(Ticket ticket.delete_change(cnum=4, when=t) self.assertEqual('a, b', ticket['keywords']) # 'foo' is no longer defined for the ticket - self.assertEqual(None, ticket['foo']) + self.assertIsNone(ticket['foo']) # however, 'foo=change3' is still in the database self.assertEqual([('change3',)], self.env.db_query(""" SELECT value FROM ticket_custom WHERE ticket=%s AND name='foo' """, (self.id,))) - self.assertEqual(None, ticket.get_change(cnum=4)) - self.assertNotEqual(None, ticket.get_change(cnum=3)) + self.assertIsNone(ticket.get_change(cnum=4)) + self.assertIsNotNone(ticket.get_change(cnum=3)) self.assertEqual(t, ticket.time_changed) def test_delete_last_comment_by_date(self): @@ -668,8 +725,8 @@ class TicketCommentDeleteTestCase(Ticket ticket.delete_change(cdate=self.t4, when=t) self.assertEqual('a, b', ticket['keywords']) self.assertEqual('change3', ticket['foo']) - self.assertEqual(None, ticket.get_change(cdate=self.t4)) - self.assertNotEqual(None, ticket.get_change(cdate=self.t3)) + self.assertIsNone(ticket.get_change(cdate=self.t4)) + self.assertIsNotNone(ticket.get_change(cdate=self.t3)) self.assertEqual(t, ticket.time_changed) def test_delete_mid_comment(self): @@ -680,7 +737,7 @@ class TicketCommentDeleteTestCase(Ticket foo=dict(author='joe', old='change3', new='change4')) t = datetime.now(utc) ticket.delete_change(cnum=3, when=t) - self.assertEqual(None, ticket.get_change(cnum=3)) + self.assertIsNone(ticket.get_change(cnum=3)) self.assertEqual('a', ticket['keywords']) self.assertChange(ticket, 4, self.t4, 'joe', comment=dict(author='joe', old='4', new='Comment 4'), @@ -696,7 +753,7 @@ class TicketCommentDeleteTestCase(Ticket foo=dict(author='joe', old='change3', new='change4')) t = datetime.now(utc) ticket.delete_change(cdate=self.t3, when=t) - self.assertEqual(None, ticket.get_change(cdate=self.t3)) + self.assertIsNone(ticket.get_change(cdate=self.t3)) self.assertEqual('a', ticket['keywords']) self.assertChange(ticket, 4, self.t4, 'joe', comment=dict(author='joe', old='4', new='Comment 4'), @@ -718,7 +775,7 @@ class TicketCommentDeleteTestCase(Ticket keywords=dict(author='joe', old='1, 2', new='a'), foo=dict(author='joe', old='change3', new='change4')) ticket.delete_change(3) - self.assertEqual(None, ticket.get_change(3)) + self.assertIsNone(ticket.get_change(3)) self.assertEqual('a', ticket['keywords']) self.assertChange(ticket, 4, self.t4, 'joe', comment=dict(author='joe', old='4', new='Comment 4'), @@ -734,6 +791,25 @@ class TicketCommentDeleteTestCase(Ticket ticket.delete_change(1, when=t) self.assertEqual(t, ticket.time_changed) + def test_ticket_change_deleted(self): + listener = TestTicketChangeListener_2(self.env) + ticket = Ticket(self.env, self.id) + + ticket.delete_change(cdate=self.t3, when=datetime.now(utc)) + self.assertEqual('change_deleted', listener.action) + self.assertEqual(ticket, listener.ticket) + self.assertEqual(self.t3, listener.cdate) + self.assertEqual(dict(keywords=('a, b, c', 'a, b'), + foo=('change2', 'change3')), + listener.changes) + + ticket.delete_change(cnum=2, when=datetime.now(utc)) + self.assertEqual('change_deleted', listener.action) + self.assertEqual(ticket, listener.ticket) + self.assertEqual(self.t2, listener.cdate) + self.assertEqual(dict(owner=('john', 'jack'), + foo=('change 1', 'change2')), + listener.changes) class EnumTestCase(unittest.TestCase): @@ -752,14 +828,14 @@ class EnumTestCase(unittest.TestCase): prio = Priority(self.env) prio.name = 'foo' prio.insert() - self.assertEqual(True, prio.exists) + self.assertTrue(prio.exists) def test_priority_insert_with_value(self): prio = Priority(self.env) prio.name = 'bar' prio.value = 100 prio.insert() - self.assertEqual(True, prio.exists) + self.assertTrue(prio.exists) def test_priority_update(self): prio = Priority(self.env, 'major') @@ -772,7 +848,7 @@ class EnumTestCase(unittest.TestCase): prio = Priority(self.env, 'major') self.assertEqual('3', prio.value) prio.delete() - self.assertEqual(False, prio.exists) + self.assertFalse(prio.exists) self.assertRaises(TracError, Priority, self.env, 'major') prio = Priority(self.env, 'minor') self.assertEqual('3', prio.value) @@ -807,8 +883,9 @@ class MilestoneTestCase(unittest.TestCas def setUp(self): self.env = EnvironmentStub(default_data=True) - self.env.path = os.path.join(tempfile.gettempdir(), 'trac-tempenv') - os.mkdir(self.env.path) + self.env.path = tempfile.mkdtemp(prefix='trac-tempenv-') + self.created_at = datetime(2001, 1, 1, tzinfo=utc) + self.updated_at = self.created_at + timedelta(seconds=1) def tearDown(self): shutil.rmtree(self.env.path) @@ -820,12 +897,25 @@ class MilestoneTestCase(unittest.TestCas setattr(milestone, k, v) return milestone + def _insert_ticket(self, when=None, **kwargs): + ticket = Ticket(self.env) + for name, value in kwargs.iteritems(): + ticket[name] = value + ticket.insert(when or self.created_at) + return ticket + + def _update_ticket(self, ticket, author=None, comment=None, when=None, + **kwargs): + for name, value in kwargs.iteritems(): + ticket[name] = value + ticket.save_changes(author, comment, when or self.updated_at) + def test_new_milestone(self): milestone = Milestone(self.env) - self.assertEqual(False, milestone.exists) - self.assertEqual(None, milestone.name) - self.assertEqual(None, milestone.due) - self.assertEqual(None, milestone.completed) + self.assertFalse(milestone.exists) + self.assertIsNone(milestone.name) + self.assertIsNone(milestone.due) + self.assertIsNone(milestone.completed) self.assertEqual('', milestone.description) def test_new_milestone_empty_name(self): @@ -834,20 +924,20 @@ class MilestoneTestCase(unittest.TestCas milestone being correctly detected as non-existent. """ milestone = Milestone(self.env, '') - self.assertEqual(False, milestone.exists) - self.assertEqual(None, milestone.name) - self.assertEqual(None, milestone.due) - self.assertEqual(None, milestone.completed) + self.assertFalse(milestone.exists) + self.assertIsNone(milestone.name) + self.assertIsNone(milestone.due) + self.assertIsNone(milestone.completed) self.assertEqual('', milestone.description) def test_existing_milestone(self): self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") milestone = Milestone(self.env, 'Test') - self.assertEqual(True, milestone.exists) + self.assertTrue(milestone.exists) self.assertEqual('Test', milestone.name) - self.assertEqual(None, milestone.due) - self.assertEqual(None, milestone.completed) + self.assertIsNone(milestone.due) + self.assertIsNone(milestone.completed) self.assertEqual('', milestone.description) def test_create_and_update_milestone(self): @@ -868,35 +958,119 @@ class MilestoneTestCase(unittest.TestCas WHERE name='Test' """)) + def test_move_tickets(self): + self.env.db_transaction.executemany( + "INSERT INTO milestone (name) VALUES (%s)", + [('Test',), ('Testing',)]) + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + self._update_ticket(tkt2, status='closed', resolution='fixed') + milestone = Milestone(self.env, 'Test') + milestone.move_tickets('Testing', 'anonymous', 'Move tickets') + + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('Testing', tkt1['milestone']) + self.assertEqual('Testing', tkt2['milestone']) + self.assertEqual(tkt1['changetime'], tkt2['changetime']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) + + def test_move_tickets_exclude_closed(self): + self.env.db_transaction.executemany( + "INSERT INTO milestone (name) VALUES (%s)", + [('Test',), ('Testing',)]) + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + self._update_ticket(tkt2, status='closed', resolution='fixed') + milestone = Milestone(self.env, 'Test') + milestone.move_tickets('Testing', 'anonymous', 'Move tickets', + exclude_closed=True) + + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('Testing', tkt1['milestone']) + self.assertEqual('Test', tkt2['milestone']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) + self.assertEqual(self.updated_at, tkt2['changetime']) + + def test_move_tickets_target_doesnt_exist(self): + self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + milestone = Milestone(self.env, 'Test') + self.assertRaises(ResourceNotFound, milestone.move_tickets, + 'Testing', 'anonymous') + + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('Test', tkt1['milestone']) + self.assertEqual('Test', tkt2['milestone']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) + self.assertNotEqual(self.updated_at, tkt2['changetime']) + def test_create_milestone_without_name(self): milestone = Milestone(self.env) self.assertRaises(TracError, milestone.insert) def test_delete_milestone(self): self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") - + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + self._update_ticket(tkt2, status='closed', resolution='fixed') milestone = Milestone(self.env, 'Test') milestone.delete() - self.assertEqual(False, milestone.exists) + self.assertFalse(milestone.exists) self.assertEqual([], self.env.db_query("SELECT * FROM milestone WHERE name='Test'")) - def test_delete_milestone_retarget_tickets(self): - self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('', tkt1['milestone']) + self.assertEqual('', tkt2['milestone']) + self.assertEqual(tkt1['changetime'], tkt2['changetime']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) + + def test_delete_milestone_with_attachment(self): + milestone = Milestone(self.env) + milestone.name = 'MilestoneWithAttachment' + milestone.insert() + + attachment = Attachment(self.env, 'milestone', milestone.name) + attachment.insert('foo.txt', StringIO(), 0, 1) - tkt1 = Ticket(self.env) - tkt1.populate({'summary': 'Foo', 'milestone': 'Test'}) - tkt1.insert() - tkt2 = Ticket(self.env) - tkt2.populate({'summary': 'Bar', 'milestone': 'Test'}) - tkt2.insert() + milestone.delete() + self.assertEqual(False, milestone.exists) + + attachments = Attachment.select(self.env, 'milestone', milestone.name) + self.assertRaises(StopIteration, attachments.next) + def test_delete_milestone_retarget_tickets(self): + self.env.db_transaction.executemany( + "INSERT INTO milestone (name) VALUES (%s)", + [('Test',), ('Other',)]) + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + self._update_ticket(tkt2, status='closed', resolution='fixed') milestone = Milestone(self.env, 'Test') milestone.delete(retarget_to='Other') - self.assertEqual(False, milestone.exists) + self.assertFalse(milestone.exists) - self.assertEqual('Other', Ticket(self.env, tkt1.id)['milestone']) - self.assertEqual('Other', Ticket(self.env, tkt2.id)['milestone']) + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('Other', tkt1['milestone']) + self.assertEqual('Other', tkt2['milestone']) + self.assertEqual(tkt1['changetime'], tkt2['changetime']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) def test_update_milestone(self): self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") @@ -920,23 +1094,6 @@ class MilestoneTestCase(unittest.TestCas milestone.name = None self.assertRaises(TracError, milestone.update) - def test_update_milestone_update_tickets(self): - self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") - - tkt1 = Ticket(self.env) - tkt1.populate({'summary': 'Foo', 'milestone': 'Test'}) - tkt1.insert() - tkt2 = Ticket(self.env) - tkt2.populate({'summary': 'Bar', 'milestone': 'Test'}) - tkt2.insert() - - milestone = Milestone(self.env, 'Test') - milestone.name = 'Testing' - milestone.update() - - self.assertEqual('Testing', Ticket(self.env, tkt1.id)['milestone']) - self.assertEqual('Testing', Ticket(self.env, tkt2.id)['milestone']) - def test_rename_milestone(self): milestone = Milestone(self.env) milestone.name = 'OldName' @@ -958,6 +1115,24 @@ class MilestoneTestCase(unittest.TestCas self.assertEqual('foo.txt', attachments.next().filename) self.assertRaises(StopIteration, attachments.next) + def test_rename_milestone_retarget_tickets(self): + self.env.db_transaction("INSERT INTO milestone (name) VALUES ('Test')") + tkt1 = self._insert_ticket(status='new', summary='Foo', + milestone='Test') + tkt2 = self._insert_ticket(status='new', summary='Bar', + milestone='Test') + self._update_ticket(tkt2, status='closed', resolution='fixed') + milestone = Milestone(self.env, 'Test') + milestone.name = 'Testing' + milestone.update() + + tkt1 = Ticket(self.env, tkt1.id) + tkt2 = Ticket(self.env, tkt2.id) + self.assertEqual('Testing', tkt1['milestone']) + self.assertEqual('Testing', tkt2['milestone']) + self.assertEqual(tkt1['changetime'], tkt2['changetime']) + self.assertNotEqual(self.updated_at, tkt1['changetime']) + def test_select_milestones(self): self.env.db_transaction.executemany( "INSERT INTO milestone (name) VALUES (%s)", @@ -965,9 +1140,9 @@ class MilestoneTestCase(unittest.TestCas milestones = list(Milestone.select(self.env)) self.assertEqual('1.0', milestones[0].name) - assert milestones[0].exists + self.assertTrue(milestones[0].exists) self.assertEqual('2.0', milestones[1].name) - assert milestones[1].exists + self.assertTrue(milestones[1].exists) def test_change_listener_created(self): listener = TestMilestoneChangeListener(self.env) @@ -999,10 +1174,10 @@ class MilestoneTestCase(unittest.TestCas listener = TestMilestoneChangeListener(self.env) milestone = self._create_milestone(name='Milestone 1') milestone.insert() - self.assertEqual(True, milestone.exists) + self.assertTrue(milestone.exists) milestone.delete() self.assertEqual('Milestone 1', milestone.name) - self.assertEqual(False, milestone.exists) + self.assertFalse(milestone.exists) self.assertEqual('deleted', listener.action) self.assertEqual(milestone, listener.milestone) @@ -1180,13 +1355,13 @@ class TicketResourceChangeListenerTestCa def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(TicketTestCase, 'test')) - suite.addTest(unittest.makeSuite(TicketCommentEditTestCase, 'test')) - suite.addTest(unittest.makeSuite(TicketCommentDeleteTestCase, 'test')) - suite.addTest(unittest.makeSuite(EnumTestCase, 'test')) - suite.addTest(unittest.makeSuite(MilestoneTestCase, 'test')) - suite.addTest(unittest.makeSuite(ComponentTestCase, 'test')) - suite.addTest(unittest.makeSuite(VersionTestCase, 'test')) + suite.addTest(unittest.makeSuite(TicketTestCase)) + suite.addTest(unittest.makeSuite(TicketCommentEditTestCase)) + suite.addTest(unittest.makeSuite(TicketCommentDeleteTestCase)) + suite.addTest(unittest.makeSuite(EnumTestCase)) + suite.addTest(unittest.makeSuite(MilestoneTestCase)) + suite.addTest(unittest.makeSuite(ComponentTestCase)) + suite.addTest(unittest.makeSuite(VersionTestCase)) suite.addTest(unittest.makeSuite( ComponentResourceChangeListenerTestCase, 'test')) suite.addTest(unittest.makeSuite(
Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/notification.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/notification.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/notification.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/tests/notification.py Sat Nov 15 01:14:46 2014 @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2005-2009 Edgewall Software +# Copyright (C) 2005-2013 Edgewall Software # Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.b...@free.fr> # All rights reserved. # @@ -16,126 +16,244 @@ # (lsmith...@open-networks.co.uk) extensible Python SMTP Server # -from trac.util.datefmt import utc -from trac.ticket.model import Ticket -from trac.ticket.notification import TicketNotifyEmail -from trac.test import EnvironmentStub, Mock, MockPerm -from trac.tests.notification import SMTPThreadedServer, parse_smtp_message, \ - smtp_address - import base64 -from datetime import datetime import os import quopri import re import unittest +from datetime import datetime + +import trac.tests.compat +from trac.test import EnvironmentStub, Mock, MockPerm +from trac.tests.notification import SMTP_TEST_PORT, SMTPThreadedServer,\ + parse_smtp_message +from trac.ticket.model import Ticket +from trac.ticket.notification import TicketNotifyEmail +from trac.ticket.web_ui import TicketModule +from trac.util.datefmt import utc -SMTP_TEST_PORT = 7000 + os.getpid() % 1000 MAXBODYWIDTH = 76 notifysuite = None -class NotificationTestCase(unittest.TestCase): - """Notification test cases that send email over SMTP""" +class RecipientTestCase(unittest.TestCase): + """Notification test cases for email recipients.""" def setUp(self): self.env = EnvironmentStub(default_data=True) self.env.config.set('project', 'name', 'TracTest') self.env.config.set('notification', 'smtp_enabled', 'true') - self.env.config.set('notification', 'always_notify_owner', 'true') - self.env.config.set('notification', 'always_notify_reporter', 'true') - self.env.config.set('notification', 'smtp_always_cc', - 'joe.u...@example.net, joe....@example.net') - self.env.config.set('notification', 'use_public_cc', 'true') self.env.config.set('notification', 'smtp_port', str(SMTP_TEST_PORT)) - self.env.config.set('notification', 'smtp_server','localhost') - self.req = Mock(href=self.env.href, abs_href=self.env.abs_href, tz=utc, - perm=MockPerm()) def tearDown(self): - """Signal the notification test suite that a test is over""" notifysuite.tear_down() self.env.reset_db() - def test_recipients(self): - """To/Cc recipients""" + def test_no_recipients(self): + """No recipient case""" ticket = Ticket(self.env) - ticket['reporter'] = '"Joe User" < joe.u...@example.org >' - ticket['owner'] = 'joe.u...@example.net' - ticket['cc'] = 'joe.u...@example.com, joe....@example.org, ' \ - 'joe....@example.net' + ticket['reporter'] = 'anonymous' ticket['summary'] = 'Foo' ticket.insert() tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) recipients = notifysuite.smtpd.get_recipients() - # checks there is no duplicate in the recipient list - rcpts = [] - for r in recipients: - self.failIf(r in rcpts) - rcpts.append(r) - # checks that all cc recipients have been notified - cc_list = self.env.config.get('notification', 'smtp_always_cc') - cc_list = "%s, %s" % (cc_list, ticket['cc']) - for r in cc_list.replace(',', ' ').split(): - self.failIf(r not in recipients) - # checks that owner has been notified - self.failIf(smtp_address(ticket['owner']) not in recipients) - # checks that reporter has been notified - self.failIf(smtp_address(ticket['reporter']) not in recipients) - - def test_no_recipient(self): - """No recipient case""" - self.env.config.set('notification', 'smtp_always_cc', '') + sender = notifysuite.smtpd.get_sender() + message = notifysuite.smtpd.get_message() + self.assertEqual(0, len(recipients)) + self.assertIsNone(sender) + self.assertIsNone(message) + + def test_new_ticket_recipients(self): + """Report and CC list should be in recipient list for new tickets.""" + always_cc = ('joe.u...@example.net', 'joe....@example.net') + ticket_cc = ('joe.u...@example.com', 'joe....@example.org') + self.env.config.set('notification', 'smtp_always_cc', + ', '.join(always_cc)) ticket = Ticket(self.env) - ticket['reporter'] = 'anonymous' - ticket['summary'] = 'Foo' + ticket['reporter'] = 'joe....@example.org' + ticket['owner'] = 'joe.u...@example.net' + ticket['cc'] = ' '.join(ticket_cc) + ticket['summary'] = 'New ticket recipients' ticket.insert() tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) - sender = notifysuite.smtpd.get_sender() recipients = notifysuite.smtpd.get_recipients() - message = notifysuite.smtpd.get_message() - # checks that no message has been sent - self.failIf(recipients) - self.failIf(sender) - self.failIf(message) + for r in always_cc + ticket_cc + \ + (ticket['owner'], ticket['reporter']): + self.assertIn(r, recipients) def test_cc_only(self): """Notification w/o explicit recipients but Cc: (#3101)""" + always_cc = ('joe.u...@example.net', 'joe....@example.net') + self.env.config.set('notification', 'smtp_always_cc', + ', '.join(always_cc)) ticket = Ticket(self.env) ticket['summary'] = 'Foo' ticket.insert() tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) recipients = notifysuite.smtpd.get_recipients() - # checks that all cc recipients have been notified - cc_list = self.env.config.get('notification', 'smtp_always_cc') - for r in cc_list.replace(',', ' ').split(): - self.failIf(r not in recipients) + for r in always_cc: + self.assertIn(r, recipients) + + def test_always_notify_updater(self): + """The `always_notify_updater` option.""" + def _test_updater(enabled): + self.env.config.set('notification', 'always_notify_updater', + enabled) + ticket = Ticket(self.env) + ticket['reporter'] = 'joe.u...@example.org' + ticket['summary'] = u'This is a súmmäry' + ticket.insert() + now = datetime.now(utc) + ticket.save_changes('joe.b...@example.com', 'This is a change', + when=now) + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=False, modtime=now) + recipients = notifysuite.smtpd.get_recipients() + if enabled: + self.assertEqual(1, len(recipients)) + self.assertIn('joe.b...@example.com', recipients) + else: + self.assertEqual(0, len(recipients)) + self.assertNotIn('joe.b...@example.com', recipients) + + # Validate with and without a default domain + for enable in False, True: + _test_updater(enable) + + def test_always_notify_owner(self): + """The `always_notify_owner` option.""" + def _test_reporter(enabled): + self.env.config.set('notification', 'always_notify_owner', + enabled) + self.env.config.set('notification', 'always_notify_updater', + 'false') + ticket = Ticket(self.env) + ticket['summary'] = 'Foo' + ticket['reporter'] = u'j...@example.org' + ticket['owner'] = u'j...@example.org' + ticket.insert() + now = datetime.now(utc) + ticket.save_changes('j...@example.org', 'this is my comment', + when=now) + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=True, modtime=now) + recipients = notifysuite.smtpd.get_recipients() + if enabled: + self.assertEqual(1, len(recipients)) + self.assertEqual('j...@example.org', recipients[0]) + else: + self.assertEqual(0, len(recipients)) + + for enable in False, True: + _test_reporter(enable) + + def test_always_notify_reporter(self): + """Notification to reporter w/ updater option disabled (#3780)""" + def _test_reporter(enabled): + self.env.config.set('notification', 'always_notify_updater', + 'false') + self.env.config.set('notification', 'always_notify_reporter', + enabled) + ticket = Ticket(self.env) + ticket['summary'] = 'Foo' + ticket['reporter'] = u'j...@example.org' + ticket.insert() + now = datetime.now(utc) + ticket.save_changes('j...@example.org', 'this is my comment', + when=now) + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=True, modtime=now) + recipients = notifysuite.smtpd.get_recipients() + if enabled: + self.assertEqual(1, len(recipients)) + self.assertEqual('j...@example.org', recipients[0]) + else: + self.assertEqual(0, len(recipients)) + + for enable in False, True: + _test_reporter(enable) + + def test_no_duplicates(self): + """Email addresses should be found only once in the recipient list.""" + self.env.config.set('notification', 'smtp_always_cc', + 'joe.u...@example.com') + ticket = Ticket(self.env) + ticket['reporter'] = 'joe.u...@example.com' + ticket['owner'] = 'joe.u...@example.com' + ticket['cc'] = 'joe.u...@example.com' + ticket['summary'] = 'No duplicates' + ticket.insert() + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=True) + recipients = notifysuite.smtpd.get_recipients() + self.assertEqual(1, len(recipients)) + self.assertIn('joe.u...@example.com', recipients) + + def test_long_forms(self): + """Long forms of SMTP email addresses 'Display Name <address>'""" + self.env.config.set('notification', 'always_notify_owner', True) + ticket = Ticket(self.env) + ticket['reporter'] = '"Joe" <joe.u...@example.com>' + ticket['owner'] = 'Joe <joe.u...@example.net>' + ticket['cc'] = 'Joe < joe.u...@example.org >' + ticket['summary'] = 'Long form' + ticket.insert() + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=True) + recipients = notifysuite.smtpd.get_recipients() + self.assertEqual(3, len(recipients)) + self.assertIn('joe.u...@example.com', recipients) + self.assertIn('joe.u...@example.net', recipients) + self.assertIn('joe.u...@example.org', recipients) + + +class NotificationTestCase(unittest.TestCase): + """Notification test cases that send email over SMTP""" + + def setUp(self): + self.env = EnvironmentStub(default_data=True) + self.env.config.set('project', 'name', 'TracTest') + self.env.config.set('notification', 'smtp_enabled', 'true') + self.env.config.set('notification', 'always_notify_owner', 'true') + self.env.config.set('notification', 'always_notify_reporter', 'true') + self.env.config.set('notification', 'smtp_always_cc', + 'joe.u...@example.net, joe....@example.net') + self.env.config.set('notification', 'use_public_cc', 'true') + self.env.config.set('notification', 'smtp_port', str(SMTP_TEST_PORT)) + self.env.config.set('notification', 'smtp_server', 'localhost') + self.req = Mock(href=self.env.href, abs_href=self.env.abs_href, tz=utc, + perm=MockPerm()) + + def tearDown(self): + """Signal the notification test suite that a test is over""" + notifysuite.tear_down() + self.env.reset_db() def test_structure(self): """Basic SMTP message structure (headers, body)""" ticket = Ticket(self.env) ticket['reporter'] = '"Joe User" <joe.u...@example.org>' - ticket['owner'] = 'joe.u...@example.net' - ticket['cc'] = 'joe.u...@example.com, joe....@example.org, ' \ - 'joe....@example.net' + ticket['owner'] = 'joe.u...@example.net' + ticket['cc'] = 'joe.u...@example.com, joe....@example.org, ' \ + 'joe....@example.net' ticket['summary'] = 'This is a summary' ticket.insert() tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # checks for header existence - self.failIf(not headers) - # checks for body existance - self.failIf(not body) + self.assertTrue(headers) + # checks for body existence + self.assertTrue(body) # checks for expected headers - self.failIf('Date' not in headers) - self.failIf('Subject' not in headers) - self.failIf('Message-ID' not in headers) - self.failIf('From' not in headers) + self.assertIn('Date', headers) + self.assertIn('Subject', headers) + self.assertIn('Message-ID', headers) + self.assertIn('From', headers) def test_date(self): """Date format compliance (RFC822) @@ -148,7 +266,7 @@ class NotificationTestCase(unittest.Test date_re = re.compile(date_str) # python time module does not detect incorrect time values days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', \ + months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] tz = ['UT', 'GMT', 'EST', 'EDT', 'CST', 'CDT', 'MST', 'MDT', 'PST', 'PDT'] @@ -159,24 +277,23 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) - self.failIf('Date' not in headers) + headers, body = parse_smtp_message(message) + self.assertIn('Date', headers) mo = date_re.match(headers['Date']) - self.failIf(not mo) + self.assertTrue(mo) if mo.group('day'): - self.failIf(mo.group('day') not in days) - self.failIf(int(mo.group('dm')) not in range(1, 32)) - self.failIf(mo.group('month') not in months) - self.failIf(int(mo.group('hour')) not in range(0, 24)) + self.assertIn(mo.group('day'), days) + self.assertIn(int(mo.group('dm')), range(1, 32)) + self.assertIn(mo.group('month'), months) + self.assertIn(int(mo.group('hour')), range(0, 24)) if mo.group('tz'): - self.failIf(mo.group('tz') not in tz) + self.assertIn(mo.group('tz'), tz) def test_bcc_privacy(self): """Visibility of recipients""" - def run_bcc_feature(public): + def run_bcc_feature(public_cc): # CC list should be private - self.env.config.set('notification', 'use_public_cc', - 'true' if public else 'false') + self.env.config.set('notification', 'use_public_cc', public_cc) self.env.config.set('notification', 'smtp_always_bcc', 'joe.foo...@example.net') ticket = Ticket(self.env) @@ -186,15 +303,15 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) - if public: + headers, body = parse_smtp_message(message) + if public_cc: # Msg should have a To list - self.failIf('To' not in headers) + self.assertIn('To', headers) # Extract the list of 'To' recipients from the message to = [rcpt.strip() for rcpt in headers['To'].split(',')] else: # Msg should not have a To list - self.failIf('To' in headers) + self.assertNotIn('To', headers) # Extract the list of 'To' recipients from the message to = [] # Extract the list of 'Cc' recipients from the message @@ -207,20 +324,20 @@ class NotificationTestCase(unittest.Test for rcpt in cclist: # Each recipient of the 'Cc' list should appear # in the 'Cc' header - self.failIf(rcpt not in cc) + self.assertIn(rcpt, cc) # Check the message has actually been sent to the recipients - self.failIf(rcpt not in rcptlist) + self.assertIn(rcpt, rcptlist) # Build the list of the expected 'Bcc' recipients bccrcpt = self.env.config.get('notification', 'smtp_always_bcc') bcclist = [bccr.strip() for bccr in bccrcpt.split(',')] for rcpt in bcclist: # Check none of the 'Bcc' recipients appears # in the 'To' header - self.failIf(rcpt in to) + self.assertNotIn(rcpt, to) # Check the message has actually been sent to the recipients - self.failIf(rcpt not in rcptlist) - run_bcc_feature(True) - run_bcc_feature(False) + self.assertIn(rcpt, rcptlist) + for public in False, True: + run_bcc_feature(public) def test_short_login(self): """Email addresses without a FQDN""" @@ -233,31 +350,30 @@ class NotificationTestCase(unittest.Test # send a notification even if other addresses are not valid self.env.config.set('notification', 'smtp_always_cc', 'joe....@example.net') - if enabled: - self.env.config.set('notification', 'use_short_addr', 'true') + self.env.config.set('notification', 'use_short_addr', enabled) tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Msg should not have a 'To' header if not enabled: - self.failIf('To' in headers) + self.assertNotIn('To', headers) else: tolist = [addr.strip() for addr in headers['To'].split(',')] # Msg should have a 'Cc' field - self.failIf('Cc' not in headers) + self.assertIn('Cc', headers) cclist = [addr.strip() for addr in headers['Cc'].split(',')] if enabled: # Msg should be delivered to the reporter - self.failIf(ticket['reporter'] not in tolist) + self.assertIn(ticket['reporter'], tolist) else: # Msg should not be delivered to joeuser - self.failIf(ticket['reporter'] in cclist) + self.assertNotIn(ticket['reporter'], cclist) # Msg should still be delivered to the always_cc list - self.failIf(self.env.config.get('notification', - 'smtp_always_cc') not in cclist) + self.assertIn(self.env.config.get('notification', + 'smtp_always_cc'), cclist) # Validate with and without the short addr option enabled - for enable in [False, True]: + for enable in False, True: _test_short_login(enable) def test_default_domain(self): @@ -282,21 +398,21 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Msg should always have a 'Cc' field - self.failIf('Cc' not in headers) + self.assertIn('Cc', headers) cclist = [addr.strip() for addr in headers['Cc'].split(',')] - self.failIf('joewith...@example.com' not in cclist) - self.failIf('joe....@example.net' not in cclist) - if not enabled: - self.failIf(len(cclist) != 2) - self.failIf('joenodom' in cclist) + self.assertIn('joewith...@example.com', cclist) + self.assertIn('joe....@example.net', cclist) + if enabled: + self.assertEqual(3, len(cclist)) + self.assertIn('joeno...@example.org', cclist) else: - self.failIf(len(cclist) != 3) - self.failIf('joeno...@example.org' not in cclist) + self.assertEqual(2, len(cclist)) + self.assertNotIn('joenodom', cclist) # Validate with and without a default domain - for enable in [False, True]: + for enable in False, True: _test_default_domain(enable) def test_email_map(self): @@ -317,15 +433,15 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Msg should always have a 'To' field - self.failIf('To' not in headers) + self.assertIn('To', headers) tolist = [addr.strip() for addr in headers['To'].split(',')] # 'To' list should have been resolved to the real email address - self.failIf('user-...@example.com' not in tolist) - self.failIf('user-...@example.com' not in tolist) - self.failIf('joeuser' in tolist) - self.failIf('jim@domain' in tolist) + self.assertIn('user-...@example.com', tolist) + self.assertIn('user-...@example.com', tolist) + self.assertNotIn('joeuser', tolist) + self.assertNotIn('jim@domain', tolist) def test_from_author(self): """Using the reporter or change author as the notification sender""" @@ -346,7 +462,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('"Joe User" <user-...@example.com>', headers['From']) # Ticket change uses the change author ticket['summary'] = 'Modified summary' @@ -354,7 +470,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('"Jim User" <user-...@example.com>', headers['From']) # Known author without name uses e-mail address only ticket['summary'] = 'Final summary' @@ -362,7 +478,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('user-non...@example.com', headers['From']) # Known author without e-mail uses smtp_from and smtp_from_name ticket['summary'] = 'Other summary' @@ -370,7 +486,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('"My Trac" <t...@example.com>', headers['From']) # Unknown author with name and e-mail address ticket['summary'] = 'Some summary' @@ -378,7 +494,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('"Test User" <t...@example.com>', headers['From']) # Unknown author with e-mail address only ticket['summary'] = 'Some summary' @@ -386,7 +502,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('t...@example.com', headers['From']) # Unknown author uses smtp_from and smtp_from_name ticket['summary'] = 'Better summary' @@ -394,7 +510,7 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=ticket['changetime']) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) self.assertEqual('"My Trac" <t...@example.com>', headers['From']) def test_ignore_domains(self): @@ -412,16 +528,16 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Msg should always have a 'To' field - self.failIf('To' not in headers) + self.assertIn('To', headers) tolist = [addr.strip() for addr in headers['To'].split(',')] # 'To' list should not contain addresses with non-SMTP domains - self.failIf('kerbe...@example.com' in tolist) - self.failIf('kerbe...@example.org' in tolist) + self.assertNotIn('kerbe...@example.com', tolist) + self.assertNotIn('kerbe...@example.org', tolist) # 'To' list should have been resolved to the actual email address - self.failIf('k...@example.net' not in tolist) - self.failIf(len(tolist) != 1) + self.assertIn('k...@example.net', tolist) + self.assertEqual(1, len(tolist)) def test_admit_domains(self): """SMTP domain inclusion""" @@ -436,16 +552,16 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Msg should always have a 'To' field - self.failIf('Cc' not in headers) + self.assertIn('Cc', headers) cclist = [addr.strip() for addr in headers['Cc'].split(',')] # 'Cc' list should contain addresses with SMTP included domains - self.failIf('joe.user@localdomain' not in cclist) - self.failIf('joe.user@server' not in cclist) + self.assertIn('joe.user@localdomain', cclist) + self.assertIn('joe.user@server', cclist) # 'Cc' list should not contain non-FQDN domains - self.failIf('joe.user@unknown' in cclist) - self.failIf(len(cclist) != 2+2) + self.assertNotIn('joe.user@unknown', cclist) + self.assertEqual(4, len(cclist)) def test_multiline_header(self): """Encoded headers split into multiple lines""" @@ -458,11 +574,11 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) # Discards the project name & ticket number subject = headers['Subject'] summary = subject[subject.find(':')+2:] - self.failIf(ticket['summary'] != summary) + self.assertEqual(ticket['summary'], summary) def test_mimebody_b64(self): """MIME Base64/utf-8 encoding""" @@ -472,8 +588,7 @@ class NotificationTestCase(unittest.Test ticket['summary'] = u'This is a long enough summary to cause Trac ' \ u'to generate a multi-line (2 lines) súmmäry' ticket.insert() - self._validate_mimebody((base64, 'base64', 'utf-8'), \ - ticket, True) + self._validate_mimebody((base64, 'base64', 'utf-8'), ticket, True) def test_mimebody_qp(self): """MIME QP/utf-8 encoding""" @@ -493,8 +608,7 @@ class NotificationTestCase(unittest.Test ticket['reporter'] = 'joe.user' ticket['summary'] = u'This is a summary' ticket.insert() - self._validate_mimebody((None, '7bit', 'utf-8'), \ - ticket, True) + self._validate_mimebody((None, '7bit', 'utf-8'), ticket, True) def test_mimebody_none_8bit(self): """MIME None encoding resulting in 8bit""" @@ -503,8 +617,7 @@ class NotificationTestCase(unittest.Test ticket['reporter'] = 'joe.user' ticket['summary'] = u'This is a summary for Jöe Usèr' ticket.insert() - self._validate_mimebody((None, '8bit', 'utf-8'), \ - ticket, True) + self._validate_mimebody((None, '8bit', 'utf-8'), ticket, True) def test_md5_digest(self): """MD5 digest w/ non-ASCII recipient address (#3491)""" @@ -518,118 +631,77 @@ class NotificationTestCase(unittest.Test tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) + self.assertEqual('joe.u...@example.org', headers['To']) + + def test_previous_cc_list(self): + """Members removed from CC list receive notifications""" + ticket = Ticket(self.env) + ticket['summary'] = 'Foo' + ticket['cc'] = 'joe.us...@example.net' + ticket.insert() + ticket['cc'] = 'joe.us...@example.net' + now = datetime.now(utc) + ticket.save_changes('joe....@example.com', 'Removed from cc', now) + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=False, modtime=now) + recipients = notifysuite.smtpd.get_recipients() + self.assertIn('joe.us...@example.net', recipients) + self.assertIn('joe.us...@example.net', recipients) - def test_updater(self): - """No-self-notification option""" - def _test_updater(disable): - if disable: - self.env.config.set('notification', 'always_notify_updater', - 'false') + def test_previous_owner(self): + """Previous owner is notified when ticket is reassigned (#2311) + if always_notify_owner is set to True""" + def _test_owner(enabled): + self.env.config.set('notification', 'always_notify_owner', enabled) ticket = Ticket(self.env) - ticket['reporter'] = 'joe.u...@example.org' - ticket['summary'] = u'This is a súmmäry' - ticket['cc'] = 'joe....@example.com' + ticket['summary'] = 'Foo' + ticket['owner'] = prev_owner = 'joe.us...@example.net' ticket.insert() - ticket['component'] = 'dummy' + ticket['owner'] = new_owner = 'joe.us...@example.net' now = datetime.now(utc) - ticket.save_changes('joe.b...@example.com', 'This is a change', - when=now) + ticket.save_changes('joe....@example.com', 'Changed owner', now) tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=False, modtime=now) - message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) - # checks for header existence - self.failIf(not headers) - # checks for updater in the 'To' recipient list - self.failIf('To' not in headers) - tolist = [addr.strip() for addr in headers['To'].split(',')] - if disable: - self.failIf('joe.b...@example.com' in tolist) + recipients = notifysuite.smtpd.get_recipients() + if enabled: + self.assertIn(prev_owner, recipients) + self.assertIn(new_owner, recipients) else: - self.failIf('joe.b...@example.com' not in tolist) - - # Validate with and without a default domain - for disable in [False, True]: - _test_updater(disable) + self.assertNotIn(prev_owner, recipients) + self.assertNotIn(new_owner, recipients) - def test_updater_only(self): - """Notification w/ updater, w/o any other recipient (#4188)""" - self.env.config.set('notification', 'always_notify_owner', 'false') - self.env.config.set('notification', 'always_notify_reporter', 'false') - self.env.config.set('notification', 'always_notify_updater', 'true') - self.env.config.set('notification', 'smtp_always_cc', '') - self.env.config.set('notification', 'smtp_always_bcc', '') - self.env.config.set('notification', 'use_public_cc', 'false') - self.env.config.set('notification', 'use_short_addr', 'false') - self.env.config.set('notification', 'smtp_replyto', - 'joeu...@example.net') - ticket = Ticket(self.env) - ticket['summary'] = 'Foo' - ticket.insert() - ticket['summary'] = 'Bar' - ticket['component'] = 'New value' - ticket.save_changes('j...@example.com', 'this is my comment') - tn = TicketNotifyEmail(self.env) - tn.notify(ticket, newticket=True) - recipients = notifysuite.smtpd.get_recipients() - self.failIf(recipients is None) - self.failIf(len(recipients) != 1) - self.failIf(recipients[0] != 'j...@example.com') - - def test_updater_is_reporter(self): - """Notification to reporter w/ updater option disabled (#3780)""" - self.env.config.set('notification', 'always_notify_owner', 'false') - self.env.config.set('notification', 'always_notify_reporter', 'true') - self.env.config.set('notification', 'always_notify_updater', 'false') - self.env.config.set('notification', 'smtp_always_cc', '') - self.env.config.set('notification', 'smtp_always_bcc', '') - self.env.config.set('notification', 'use_public_cc', 'false') - self.env.config.set('notification', 'use_short_addr', 'false') - self.env.config.set('notification', 'smtp_replyto', - 'joeu...@example.net') - ticket = Ticket(self.env) - ticket['summary'] = 'Foo' - ticket['reporter'] = u'j...@example.org' - ticket.insert() - ticket['summary'] = 'Bar' - ticket['component'] = 'New value' - ticket.save_changes('j...@example.org', 'this is my comment') - tn = TicketNotifyEmail(self.env) - tn.notify(ticket, newticket=True) - recipients = notifysuite.smtpd.get_recipients() - self.failIf(recipients is None) - self.failIf(len(recipients) != 1) - self.failIf(recipients[0] != 'j...@example.org') + for enable in False, True: + _test_owner(enable) def _validate_mimebody(self, mime, ticket, newtk): """Body of a ticket notification message""" - (mime_decoder, mime_name, mime_charset) = mime + mime_decoder, mime_name, mime_charset = mime tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=newtk) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) - self.failIf('MIME-Version' not in headers) - self.failIf('Content-Type' not in headers) - self.failIf('Content-Transfer-Encoding' not in headers) - self.failIf(not re.compile(r"1.\d").match(headers['MIME-Version'])) + headers, body = parse_smtp_message(message) + self.assertIn('MIME-Version', headers) + self.assertIn('Content-Type', headers) + self.assertIn('Content-Transfer-Encoding', headers) + self.assertTrue(re.compile(r"1.\d").match(headers['MIME-Version'])) type_re = re.compile(r'^text/plain;\scharset="([\w\-\d]+)"$') charset = type_re.match(headers['Content-Type']) - self.failIf(not charset) + self.assertTrue(charset) charset = charset.group(1) - self.assertEqual(charset, mime_charset) + self.assertEqual(mime_charset, charset) self.assertEqual(headers['Content-Transfer-Encoding'], mime_name) # checks the width of each body line for line in body.splitlines(): - self.failIf(len(line) > MAXBODYWIDTH) - # attempts to decode the body, following the specified MIME endoding + self.assertTrue(len(line) <= MAXBODYWIDTH) + # attempts to decode the body, following the specified MIME encoding # and charset try: if mime_decoder: body = mime_decoder.decodestring(body) body = unicode(body, charset) except Exception, e: - raise AssertionError, e + raise AssertionError(e) # now processes each line of the body bodylines = body.splitlines() # body starts with one of more summary lines, first line is prefixed @@ -637,25 +709,25 @@ class NotificationTestCase(unittest.Test # finds the banner after the summary banner_delim_re = re.compile(r'^\-+\+\-+$') bodyheader = [] - while ( not banner_delim_re.match(bodylines[0]) ): + while not banner_delim_re.match(bodylines[0]): bodyheader.append(bodylines.pop(0)) # summary should be present - self.failIf(not bodyheader) + self.assertTrue(bodyheader) # banner should not be empty - self.failIf(not bodylines) + self.assertTrue(bodylines) # extracts the ticket ID from the first line - (tknum, bodyheader[0]) = bodyheader[0].split(' ', 1) - self.assertEqual(tknum[0], '#') + tknum, bodyheader[0] = bodyheader[0].split(' ', 1) + self.assertEqual('#', tknum[0]) try: tkid = int(tknum[1:-1]) - self.assertEqual(tkid, 1) + self.assertEqual(1, tkid) except ValueError: - raise AssertionError, "invalid ticket number" - self.assertEqual(tknum[-1], ':') + raise AssertionError("invalid ticket number") + self.assertEqual(':', tknum[-1]) summary = ' '.join(bodyheader) self.assertEqual(summary, ticket['summary']) # now checks the banner contents - self.failIf(not banner_delim_re.match(bodylines[0])) + self.assertTrue(banner_delim_re.match(bodylines[0])) banner = True footer = None props = {} @@ -667,11 +739,11 @@ class NotificationTestCase(unittest.Test if banner: # parse banner and fill in a property dict properties = line.split('|') - self.assertEqual(len(properties), 2) + self.assertEqual(2, len(properties)) for prop in properties: if prop.strip() == '': continue - (k, v) = prop.split(':') + k, v = prop.split(':') props[k.strip().lower()] = v.strip() # detect footer marker (weak detection) if not footer: @@ -679,10 +751,10 @@ class NotificationTestCase(unittest.Test footer = 0 continue # check footer - if footer != None: + if footer is not None: footer += 1 # invalid footer detection - self.failIf(footer > 3) + self.assertTrue(footer <= 3) # check ticket link if line[:11] == 'Ticket URL:': ticket_link = self.env.abs_href.ticket(ticket.id) @@ -693,12 +765,13 @@ class NotificationTestCase(unittest.Test xlist = ['summary', 'description', 'comment', 'time', 'changetime'] # check banner content (field exists, msg value matches ticket value) for p in [prop for prop in ticket.values.keys() if prop not in xlist]: - self.failIf(not props.has_key(p)) + self.assertIn(p, props) # Email addresses might be obfuscated if '@' in ticket[p] and '@' in props[p]: - self.failIf(props[p].split('@')[0] != ticket[p].split('@')[0]) + self.assertEqual(props[p].split('@')[0], + ticket[p].split('@')[0]) else: - self.failIf(props[p] != ticket[p]) + self.assertEqual(props[p], ticket[p]) def test_props_format_ambiwidth_single(self): self.env.config.set('notification', 'mime_encoding', 'none') @@ -827,7 +900,7 @@ Resolution: fixed | Type: defect | Status: new Priority: major | Milestone: milestone1 Component: Lorem ipsum dolor sit amet, | Version: 2.0 - consectetur adipisicing elit, sed do eiusmod | Keywords: + consectetur adipisicing elit, sed do eiusmod | tempor incididunt ut labore et dolore magna | aliqua. Ut enim ad minim veniam, quis nostrud | exercitation ullamco laboris nisi ut aliquip | @@ -837,7 +910,7 @@ Resolution: fixed | Excepteur sint occaecat cupidatat non | proident, sunt in culpa qui officia deserunt | mollit anim id est laborum. | -Resolution: fixed |""" +Resolution: fixed | Keywords:""" self._validate_props_format(formatted, ticket) def test_props_format_wrap_leftside_unicode(self): @@ -865,10 +938,10 @@ Resolution: fixed Type: defect | Status: new Priority: major | Milestone: milestone1 Component: Trac 㯠BSD ã©ã¤ã»ã³ã¹ã®ãã¨ã§é å¸ | Version: 2.0 - ããã¦ãã¾ãã[1:]ãã®ã©ã¤ã»ã³ã¹ã®å ¨æã¯ãé | Keywords: + ããã¦ãã¾ãã[1:]ãã®ã©ã¤ã»ã³ã¹ã®å ¨æã¯ãé | å¸ãã¡ã¤ã«ã«å«ã¾ãã¦ãã [3:COPYING] ãã¡ã¤ã« | ã¨åããã®ã[2:ãªã³ã©ã¤ã³]ã§åç §ã§ãã¾ãã | -Resolution: fixed |""" +Resolution: fixed | Keywords:""" self._validate_props_format(formatted, ticket) def test_props_format_wrap_rightside(self): @@ -893,7 +966,7 @@ Resolution: fixed u'culpa qui officia deserunt mollit anim id ' \ u'est laborum.' ticket['component'] = u'component1' - ticket['version'] = u'2.0' + ticket['version'] = u'2.0 Standard and International Edition' ticket['resolution'] = u'fixed' ticket['keywords'] = u'' ticket.insert() @@ -901,8 +974,8 @@ Resolution: fixed Reporter: anonymous | Owner: somebody Type: defect | Status: new Priority: major | Milestone: Lorem ipsum dolor sit amet, - Component: component1 | consectetur adipisicing elit, sed do eiusmod -Resolution: fixed | tempor incididunt ut labore et dolore magna + | consectetur adipisicing elit, sed do eiusmod + | tempor incididunt ut labore et dolore magna | aliqua. Ut enim ad minim veniam, quis nostrud | exercitation ullamco laboris nisi ut aliquip ex | ea commodo consequat. Duis aute irure dolor in @@ -911,8 +984,9 @@ Resolution: fixed | tempor incid | occaecat cupidatat non proident, sunt in culpa | qui officia deserunt mollit anim id est | laborum. - | Version: 2.0 - | Keywords:""" + Component: component1 | Version: 2.0 Standard and International + | Edition +Resolution: fixed | Keywords:""" self._validate_props_format(formatted, ticket) def test_props_format_wrap_rightside_unicode(self): @@ -937,10 +1011,10 @@ Resolution: fixed | tempor incid Reporter: anonymous | Owner: somebody Type: defect | Status: new Priority: major | Milestone: Trac å¨ç»è¿ä¿®æ¹çBSDåè®®ä¸åå¸ã - Component: component1 | [1:]åè®®çå®æ´ææ¬å¯ä»¥[2:å¨çº¿æ¥ç]ä¹å¯å¨åå¸ç -Resolution: fixed | ç [3:COPYING] æ件ä¸æ¾å°ã - | Version: 2.0 - | Keywords:""" + | [1:]åè®®çå®æ´ææ¬å¯ä»¥[2:å¨çº¿æ¥ç]ä¹å¯å¨åå¸ç + | ç [3:COPYING] æ件ä¸æ¾å°ã + Component: component1 | Version: 2.0 +Resolution: fixed | Keywords:""" self._validate_props_format(formatted, ticket) def test_props_format_wrap_bothsides(self): @@ -964,31 +1038,33 @@ Resolution: fixed | ç [3:CO u'occaecat cupidatat non proident, sunt in ' \ u'culpa qui officia deserunt mollit anim id ' \ u'est laborum.' - ticket['component'] = ticket['milestone'] + ticket['component'] = (u'Lorem ipsum dolor sit amet, consectetur ' + u'adipisicing elit, sed do eiusmod tempor ' + u'incididunt ut labore et dolore magna aliqua.') ticket['version'] = u'2.0' ticket['resolution'] = u'fixed' - ticket['keywords'] = u'' + ticket['keywords'] = u'Ut enim ad minim veniam, ....' ticket.insert() formatted = """\ Reporter: anonymous | Owner: somebody Type: defect | Status: new Priority: major | Milestone: Lorem ipsum dolor sit - Component: Lorem ipsum dolor sit | amet, consectetur adipisicing elit, - amet, consectetur adipisicing | sed do eiusmod tempor incididunt ut - elit, sed do eiusmod tempor | labore et dolore magna aliqua. Ut - incididunt ut labore et dolore | enim ad minim veniam, quis nostrud - magna aliqua. Ut enim ad minim | exercitation ullamco laboris nisi - veniam, quis nostrud exercitation | ut aliquip ex ea commodo consequat. - ullamco laboris nisi ut aliquip | Duis aute irure dolor in - ex ea commodo consequat. Duis | reprehenderit in voluptate velit - aute irure dolor in reprehenderit | esse cillum dolore eu fugiat nulla - in voluptate velit esse cillum | pariatur. Excepteur sint occaecat - dolore eu fugiat nulla pariatur. | cupidatat non proident, sunt in - Excepteur sint occaecat cupidatat | culpa qui officia deserunt mollit - non proident, sunt in culpa qui | anim id est laborum. - officia deserunt mollit anim id | Version: 2.0 - est laborum. | Keywords: -Resolution: fixed |""" + | amet, consectetur adipisicing elit, + | sed do eiusmod tempor incididunt ut + | labore et dolore magna aliqua. Ut + | enim ad minim veniam, quis nostrud + | exercitation ullamco laboris nisi + | ut aliquip ex ea commodo consequat. + | Duis aute irure dolor in + | reprehenderit in voluptate velit + | esse cillum dolore eu fugiat nulla + Component: Lorem ipsum dolor sit | pariatur. Excepteur sint occaecat + amet, consectetur adipisicing | cupidatat non proident, sunt in + elit, sed do eiusmod tempor | culpa qui officia deserunt mollit + incididunt ut labore et dolore | anim id est laborum. + magna aliqua. | Version: 2.0 +Resolution: fixed | Keywords: Ut enim ad minim + | veniam, ....""" self._validate_props_format(formatted, ticket) def test_props_format_wrap_bothsides_unicode(self): @@ -1011,7 +1087,7 @@ Resolution: fixed |"" u'ã«å«ã¾ãã¦ãã[3:CÐPYING]ãã¡ã¤' \ u'ã«ã¨åããã®ã[2:ãªã³ã©ã¤ã³]ã§' \ u'åç §ã§ãã¾ãã' - ticket['version'] = u'2.0' + ticket['version'] = u'2.0 International Edition' ticket['resolution'] = u'fixed' ticket['keywords'] = u'' ticket.insert() @@ -1022,17 +1098,74 @@ Resolution: fixed |"" Component: Trac 㯠BSD ã©ã¤ã»ã³ã¹ | è®®ä¸åå¸ã[1:]åè®®çå®æ´ææ¬å¯ä»¥[2: ã®ãã¨ã§é å¸ããã¦ãã¾ãã[1:]ã | å¨çº¿æ¥ç]ä¹å¯å¨åå¸çç [3:COPYING] ã®ã©ã¤ã»ã³ã¹ã®å ¨æã¯ãâ»é å¸ãã¡ | æ件ä¸æ¾å°ã - ã¤ã«ã«å«ã¾ãã¦ãã[3:CÐPYING]ã | Version: 2.0 - ã¡ã¤ã«ã¨åããã®ã[2:ãªã³ã©ã¤ã³] | Keywords: + ã¤ã«ã«å«ã¾ãã¦ãã[3:CÐPYING]ã | Version: 2.0 International + ã¡ã¤ã«ã¨åããã®ã[2:ãªã³ã©ã¤ã³] | Edition ã§åç §ã§ãã¾ãã | -Resolution: fixed |""" +Resolution: fixed | Keywords:""" + self._validate_props_format(formatted, ticket) + + def test_props_format_wrap_ticket_10283(self): + self.env.config.set('notification', 'mime_encoding', 'none') + for name, value in (('blockedby', 'text'), + ('blockedby.label', 'Blocked by'), + ('blockedby.order', '6'), + ('blocking', 'text'), + ('blocking.label', 'Blocking'), + ('blocking.order', '5'), + ('deployment', 'text'), + ('deployment.label', 'Deployment state'), + ('deployment.order', '1'), + ('nodes', 'text'), + ('nodes.label', 'Related nodes'), + ('nodes.order', '3'), + ('privacy', 'text'), + ('privacy.label', 'Privacy sensitive'), + ('privacy.order', '2'), + ('sensitive', 'text'), + ('sensitive.label', 'Security sensitive'), + ('sensitive.order', '4')): + self.env.config.set('ticket-custom', name, value) + + ticket = Ticket(self.env) + ticket['summary'] = u'This is a summary' + ticket['reporter'] = u'anonymous' + ticket['owner'] = u'somebody' + ticket['type'] = u'defect' + ticket['status'] = u'closed' + ticket['priority'] = u'normal' + ticket['milestone'] = u'iter_01' + ticket['component'] = u'XXXXXXXXXXXXXXXXXXXXXXXXXX' + ticket['resolution'] = u'fixed' + ticket['keywords'] = u'' + ticket['deployment'] = '' + ticket['privacy'] = '0' + ticket['nodes'] = 'XXXXXXXXXX' + ticket['sensitive'] = '0' + ticket['blocking'] = '' + ticket['blockedby'] = '' + ticket.insert() + + formatted = """\ + Reporter: anonymous | Owner: + | somebody + Type: defect | Status: + | closed + Priority: normal | Milestone: + | iter_01 + Component: XXXXXXXXXXXXXXXXXXXXXXXXXX | Resolution: + | fixed + Keywords: | Deployment state: + Privacy sensitive: 0 | Related nodes: + | XXXXXXXXXX +Security sensitive: 0 | Blocking: + Blocked by: |""" self._validate_props_format(formatted, ticket) def _validate_props_format(self, expected, ticket): tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) message = notifysuite.smtpd.get_message() - (headers, body) = parse_smtp_message(message) + headers, body = parse_smtp_message(message) bodylines = body.splitlines() # Extract ticket properties delim_re = re.compile(r'^\-+\+\-+$') @@ -1052,7 +1185,7 @@ Resolution: fixed |"" ticket.insert() tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=True) - self.assertNotEqual(None, notifysuite.smtpd.get_message()) + self.assertIsNotNone(notifysuite.smtpd.get_message()) self.assertEqual('My Summary', ticket['summary']) self.assertEqual('Some description', ticket['description']) valid_fieldnames = set([f['name'] for f in ticket.fields]) @@ -1069,6 +1202,52 @@ Resolution: fixed |"" tn.ticket = ticket tn.get_message_id('foo') + def test_mime_meta_characters_in_from_header(self): + """MIME encoding with meta characters in From header""" + + self.env.config.set('notification', 'smtp_from', 't...@example.com') + self.env.config.set('notification', 'mime_encoding', 'base64') + ticket = Ticket(self.env) + ticket['reporter'] = 'joeuser' + ticket['summary'] = 'This is a summary' + ticket.insert() + tn = TicketNotifyEmail(self.env) + + def notify(from_name): + self.env.config.set('notification', 'smtp_from_name', from_name) + tn.notify(ticket, newticket=True) + message = notifysuite.smtpd.get_message() + headers, body = parse_smtp_message(message) + return message, headers, body + + message, headers, body = notify(u'Träc') + self.assertEqual(r'"=?utf-8?b?VHLDpGM=?=" <t...@example.com>', + headers['From']) + message, headers, body = notify(u'Trac\\') + self.assertEqual(r'"Trac\\" <t...@example.com>', headers['From']) + message, headers, body = notify(u'Trac"') + self.assertEqual(r'"Trac\"" <t...@example.com>', headers['From']) + message, headers, body = notify(u'=?utf-8?b?****?=') + self.assertEqual('"=?utf-8?b?PT91dGYtOD9iPyoqKio/PQ==?=" ' + '<t...@example.com>', headers['From']) + + def test_mime_meta_characters_in_subject_header(self): + """MIME encoding with meta characters in Subject header""" + + self.env.config.set('notification', 'smtp_from', 't...@example.com') + self.env.config.set('notification', 'mime_encoding', 'base64') + summary = u'=?utf-8?q?****?=' + ticket = Ticket(self.env) + ticket['reporter'] = 'joeuser' + ticket['summary'] = summary + ticket.insert() + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, newticket=True) + message = notifysuite.smtpd.get_message() + headers, body = parse_smtp_message(message) + self.assertIn('\nSubject: =?utf-8?b?', message) # is mime-encoded + self.assertEqual(summary, + re.split(r' #[0-9]+: ', headers['Subject'], 1)[1]) class NotificationTestSuite(unittest.TestSuite): @@ -1079,18 +1258,20 @@ class NotificationTestSuite(unittest.Tes unittest.TestSuite.__init__(self) self.smtpd = SMTPThreadedServer(SMTP_TEST_PORT) self.smtpd.start() - self.addTest(unittest.makeSuite(NotificationTestCase, 'test')) + self.addTest(unittest.makeSuite(RecipientTestCase)) + self.addTest(unittest.makeSuite(NotificationTestCase)) self.remaining = self.countTestCases() def tear_down(self): """Reset the local SMTP test server""" self.smtpd.cleanup() - self.remaining = self.remaining-1 + self.remaining -= 1 if self.remaining > 0: return # stop the SMTP test server when all tests have been completed self.smtpd.stop() + def suite(): global notifysuite if not notifysuite: @@ -1098,4 +1279,4 @@ def suite(): return notifysuite if __name__ == '__main__': - unittest.TextTestRunner(verbosity=2).run(suite()) + unittest.main(defaultTest='suite')