Skip to content

Commit

Permalink
New test framework in place, a few tests for the test framework as well.
Browse files Browse the repository at this point in the history
  • Loading branch information
Thomi Richards committed May 19, 2015
1 parent 815b568 commit c1730e3
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 1 deletion.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.egg-info/
build/
__pycache__
2 changes: 1 addition & 1 deletion gmailfilter/_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from imapclient import IMAPClient

from gmailfilter._message import Message
from gmailfilter._message import EmailMessage as Message


# TODO: Accept config from command line, encapsulate in a dict and pass
Expand Down
29 changes: 29 additions & 0 deletions gmailfilter/_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@

class Message(object):

"""An interface to represent an email message."""

def subject(self):
"""Return the subject string of the email message."""

def from_(self):
"""Return the Sender of the email."""

def uid(self):
"""Return the mesage uid."""

def get_headers(self):
"""Get the dictionary of headers."""
# TODO: Define a better interface here that deals with the fact that
# message headers can be added multiple times, so it's not actually
# good enough to store them in a dictionary.

def get_date(self):
"""Get the date the message was received."""

def get_flags(self):
"""Get the flags set on the message."""

def __repr__(self):
return "<Message %d %r>" % (self.uid(), self.subject())


class EmailMessage(Message):

"""An interface to represent an email message.
The message is lazily-created. Methods such as 'subject' cause network
Expand Down
106 changes: 106 additions & 0 deletions gmailfilter/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

"""Message Test Classes.
This module contains all the tests we ship. These can be used in the rules
file to select messages.
"""


__all__ = [
'Test',
'And',
'Or',
]

class Test(object):

"""This class represents a single test on a message.
The only contractual obligation is the 'match' method, which should
return a truthy value when the test matches.
"""

def match(self, message):
"""Check if this test matches a given message.
If the message matches this test, this method must return True.
If the message does not match this test, the method may return False,
or an instance of the Mismatch object. The latter is used to signal
that we know that a message needs to be re-checked at a certain point
in the future.
TODO: Implement Mismatch.
"""


class And(Test):

"""An aggregate test that performs a boolean and operation over multiple
other tests.
Can be constructed with an arbitrary number of sub, tests, like so:
>>> And(test1, test2)
>>> And(test1, test2, test3)
...and can even be constructed without any sub-tests. In this configuration
it will *not* match.
"""
def __init__(self, *tests):
self._tests = tests

def match(self, message):
if not self._tests:
return False
return all([t.match(message) for t in self._tests])


class Or(Test):

"""An aggregate test that performs a boolean 'or' operation over multiple
other tests.
Similar to And, this test can be constructed with an arbitrary number of
sub-tests. If it is constructed with no sub-tests, it will never match.
"""

def __init__(self, *tests):
self._tests = tests

def match(self, message):
return any([t.match(message) for t in self._tests])


class MatchesHeader(Test):

"""Check whether an email has a given header.
Can be used to check the existance of the header, by passing in the header
name::
>>> MatchesHeader('X-Launchpad-Message-Rationale')
...or can be used to match the header value, by passing in both the name
and value::
>>> MatchesHeader('X-Launchpad-Message-Rationale', 'subscriber')
"""

def __init__(self, expected_key, expected_value=None):
self.expected_key = expected_key
self.expected_value = expected_value

def match(self, message):
headers = message.get_headers()
if self.expected_key in headers:
if self.expected_value:
return headers[self.expected_key] == self.expected_value
else:
return True
return False
28 changes: 28 additions & 0 deletions gmailfilter/tests/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Test factory for gmailfilter tests."""

from gmailfilter._message import Message

class TestFactoryMixin(object):

"""A mixin class that generates test fake values."""

def get_email_message(self, headers=None):
"""Get an email message.
:param headers: If set, must be a dict or 2-tuple iteratble of key/value
pairs that will be set as the email message header values.
"""
message = FakeMessage()
if headers:
message.headers = dict(headers)
return message


class FakeMessage(Message):

def __init__(self):
self.headers = {}

def get_headers(self):
return self.headers

88 changes: 88 additions & 0 deletions gmailfilter/tests/test_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from unittest import TestCase


from gmailfilter.tests.factory import TestFactoryMixin
from gmailfilter.test import (
Test,
And,
Or,
MatchesHeader,
)
from gmailfilter._message import Message

# Let's define some tests that will pass and fail regardless of their input:
class AlwaysPassingTest(Test):

def match(self, message):
return True


class AlwaysFailingTest(Test):

def match(self, message):
return False


class TestBooleanTests(TestCase, TestFactoryMixin):

def test_and_can_be_Created_without_tests(self):
And()

def test_and_yields_false_with_no_tests(self):
self.assertFalse(And().match(self.get_email_message()))

def test_and_boolean_table(self):
# boolean truth table - expected result is always first, operands
# are after.
table = [
(True, AlwaysPassingTest()),
(True, AlwaysPassingTest(), AlwaysPassingTest()),
(False, AlwaysFailingTest(), AlwaysPassingTest()),
(False, AlwaysFailingTest()),
]
for expected_value, *operands in table:
self.assertEqual(
expected_value,
And(*operands).match(self.get_email_message())
)

def test_or_can_be_Created_without_tests(self):
Or()

def test_or_yields_false_with_no_tests(self):
self.assertFalse(Or().match(self.get_email_message()))

def test_or_boolean_table(self):
# boolean truth table - expected result is always first, operands
# are after.
table = [
(True, AlwaysPassingTest()),
(True, AlwaysPassingTest(), AlwaysPassingTest()),
(True, AlwaysFailingTest(), AlwaysPassingTest()),
(False, AlwaysFailingTest()),
]
for expected_value, *operands in table:
self.assertEqual(
expected_value,
Or(*operands).match(self.get_email_message())
)


class TestMatchesHeaderTests(TestCase, TestFactoryMixin):

def test_fails_when_header_is_missing(self):
self.assertFalse(
MatchesHeader('SomeHeader').match(self.get_email_message())
)

def test_passes_when_header_is_present(self):
message = self.get_email_message(headers=dict(SomeHeader='123'))
self.assertTrue(MatchesHeader('SomeHeader').match(message))

def test_fails_when_header_value_is_wrong(self):
message = self.get_email_message(headers=dict(SomeHeader='123'))
self.assertFalse(MatchesHeader('SomeHeader', 'foo').match(message))

def test_passes_with_correct_value(self):
message = self.get_email_message(headers=dict(SomeHeader='123'))
self.assertTrue(MatchesHeader('SomeHeader', '123').match(message))

0 comments on commit c1730e3

Please sign in to comment.