1
from BaseHTTPServer import (
3
BaseHTTPRequestHandler,
5
3
from email.message import Message
6
4
from email.mime.multipart import MIMEMultipart
7
5
from email.mime.text import MIMEText
11
from signal import SIGKILL
13
6
from StringIO import StringIO
15
7
from unittest import TestCase
16
from urlparse import urlparse
17
from urlparse import parse_qs
19
9
from testtools import ExpectedException
21
from grackle.client import (
11
from grackle.client import GrackleClient
12
from grackle.error import (
23
15
UnsupportedDisplayType,
18
from grackle.service import ForkedFakeService
19
from grackle.store import make_json_message
28
22
def make_message(message_id, body='body', headers=None, hidden=False):
29
23
if headers is None:
31
headers['Message-Id'] = message_id
33
'message_id': message_id,
35
'thread_id': message_id,
36
'date': headers.get('date', '2005-01-01'),
37
'subject': headers.get('subject', 'subject'),
38
'author': headers.get('author', 'author'),
41
'replies': headers.get('in-reply-to', None),
26
'Message-Id': message_id,
32
message_headers.update(headers.items())
34
message.set_payload(body)
35
for key, value in message_headers.items():
37
return make_json_message(message_id, message.as_string(), hidden)
47
40
def make_mime_message(message_id, body='body', headers=None, hidden=False,
48
41
attachment_type=None):
49
message = MIMEMultipart()
50
message.attach(MIMEText(body))
42
parts = MIMEMultipart()
43
parts.attach(MIMEText(body))
51
44
if attachment_type is not None:
52
45
attachment = Message()
53
46
attachment.set_payload('attactment data.')
54
47
attachment['Content-Type'] = attachment_type
55
48
attachment['Content-Disposition'] = 'attachment; filename="file.ext"'
56
message.attach(attachment)
57
return make_message(message_id, message.get_payload(), headers, hidden)
60
def threaded_messages(messages):
64
for message in messages:
65
if message.get('replies') is None:
66
threads[message['message_id']] = [message]
69
pending.append(message)
70
for message in pending:
71
threads[message['replies']].append(message)
72
return threads.values()
76
"""A memory-backed message store."""
78
def __init__(self, messages):
80
self.messages = messages
83
def is_multipart(message):
84
return isinstance(message['body'], list)
86
def get_messages(self, archive_id, query_string):
87
"""Return matching messages.
89
:param archive_id: The archive to retrieve from.
90
:param query_string: Contains 'parameters', which is a JSON-format
91
string describing parameters.
93
query = parse_qs(query_string)
94
parameters = simplejson.loads(query['parameters'][0])
95
order = parameters.get('order')
96
messages = self.messages[archive_id]
98
if order not in SUPPORTED_ORDERS:
99
raise UnsupportedOrder
100
elif order.startswith('thread_'):
101
threaded = threaded_messages(messages)
103
if order == 'thread_subject':
104
threaded.sort(key=lambda t: t[0]['subject'])
105
if order == 'thread_oldest':
106
threaded.sort(key=lambda t: min(m['date'] for m in t))
107
if order == 'thread_newest':
108
threaded.sort(key=lambda t: max(m['date'] for m in t))
109
for thread in threaded:
110
messages.extend(thread)
112
messages.sort(key=lambda m: m[order])
113
display_type = parameters.get('display_type', 'all')
114
if display_type not in SUPPORTED_DISPLAY_TYPES:
115
raise UnsupportedDisplayType
117
for message in messages:
118
if (not parameters['include_hidden'] and message['hidden']):
120
if ('message_ids' in parameters
121
and message['message_id'] not in parameters['message_ids']):
123
message = dict(message)
124
if 'headers' in parameters:
126
(k, v) for k, v in message['headers'].iteritems()
127
if k in parameters['headers'])
128
message['headers'] = headers
129
if display_type == 'headers-only':
131
elif display_type == 'text-only' and self.is_multipart(message):
133
part.get_payload() for part in message['body']
134
if part.get_content_type() == 'text/plain']
135
message['body'] = '\n\n'.join(text_parts)
136
elif display_type == 'all' and self.is_multipart(message):
137
parts = [str(part.get_payload()) for part in message['body']]
138
message['body'] = '\n\n'.join(parts)
139
max_body = parameters.get('max_body_length')
140
if max_body is not None and display_type != 'headers-only':
141
message['body'] = message['body'][:max_body]
142
new_messages.append(message)
143
messages = new_messages
144
limit = parameters.get('limit', 100)
145
memo = parameters.get('memo')
146
message_id_indices = dict(
147
(m['message_id'], idx) for idx, m in enumerate(messages))
151
start = message_id_indices[memo.encode('rot13')]
153
previous_memo = messages[start - 1]['message_id'].encode('rot13')
156
end = min(start + limit, len(messages))
157
if end < len(messages):
158
next_memo = messages[end]['message_id'].encode('rot13')
161
messages = messages[start:end]
164
'messages': messages,
165
'next_memo': next_memo,
166
'previous_memo': previous_memo
171
class ForkedFakeService:
172
"""A Grackle service fake, as a ContextManager."""
174
def __init__(self, port, messages=None, write_logs=False):
177
:param port: The tcp port to use.
178
:param messages: A dict of lists of dicts representing messages. The
179
outer dict represents the archive, the list represents the list of
180
messages for that archive.
181
:param write_logs: If true, log messages will be written to stdout.
188
self.messages = messages
189
self.read_end, self.write_end = os.pipe()
190
self.write_logs = write_logs
193
def from_client(client, messages=None):
194
"""Instantiate a ForkedFakeService from the client.
196
:param port: The client to provide service for.
197
:param messages: A dict of lists of dicts representing messages. The
198
outer dict represents the archive, the list represents the list of
199
messages for that archive.
201
return ForkedFakeService(client.port, messages)
204
"""Tell the parent process that the server is ready for writes."""
205
os.write(self.write_end, 'asdf')
210
Fork and start a server in the child. Return when the server is ready
216
os.read(self.read_end, 1)
219
def start_server(self):
220
"""Start the HTTP server."""
221
service = HTTPServer(('', self.port), FakeGrackleRequestHandler)
222
service.store = GrackleStore(self.messages)
223
for archive_id, messages in service.store.messages.iteritems():
224
for message in messages:
225
message.setdefault('headers', {})
229
stream=sys.stderr, level=logging.INFO)
230
service.serve_forever()
232
def __exit__(self, exc_type, exc_val, traceback):
233
os.kill(self.pid, SIGKILL)
236
SUPPORTED_DISPLAY_TYPES = set(['all', 'text-only', 'headers-only'])
239
SUPPORTED_ORDERS = set(
240
['date', 'author', 'subject', 'thread_newest', 'thread_oldest',
244
class FakeGrackleRequestHandler(BaseHTTPRequestHandler):
245
"""A request handler that forwards to server.store."""
247
def __init__(self, *args, **kwargs):
248
"""Constructor. Sets up logging."""
249
self.logger = logging.getLogger('http')
250
BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
253
"""Create a message on POST."""
254
message = self.rfile.read(int(self.headers['content-length']))
255
if message == 'This is a message':
256
self.send_response(httplib.CREATED)
260
self.send_error(httplib.BAD_REQUEST)
263
"""Retrieve a list of messages on GET."""
264
scheme, netloc, path, params, query_string, fragments = (
266
parts = path.split('/')
267
if parts[1] == 'archive':
269
response = self.server.store.get_messages(
270
parts[2], query_string)
271
self.send_response(httplib.OK)
273
self.wfile.write(simplejson.dumps(response))
274
except UnsupportedOrder:
276
httplib.BAD_REQUEST, UnsupportedOrder.__doc__)
278
except UnsupportedDisplayType:
280
httplib.BAD_REQUEST, UnsupportedDisplayType.__doc__)
283
def log_message(self, format, *args):
284
"""Override log_message to use standard Python logging."""
285
message = "%s - - [%s] %s\n" % (
286
self.address_string(), self.log_date_time_string(), format % args)
287
self.logger.info(message)
49
parts.attach(attachment)
50
return make_message(message_id, parts.as_string(), headers, hidden)
53
class TestPutArchive(TestCase):
55
def test_put_archive(self):
56
client = GrackleClient('localhost', 8410)
58
with ForkedFakeService.from_client(client, message_archives):
59
client.put_archive('arch1')
60
response = client.get_messages('arch1')
61
self.assertEqual(0, len(response['messages']))
63
def test_put_archive_existing_archive(self):
64
client = GrackleClient('localhost', 8411)
65
message_archives = {'arch1': []}
66
with ForkedFakeService.from_client(client, message_archives):
67
with ExpectedException(ArchiveIdExists, ''):
68
client.put_archive('arch1')
290
71
class TestPutMessage(TestCase):
292
73
def test_put_message(self):
293
client = GrackleClient('localhost', 8436)
294
with ForkedFakeService.from_client(client):
295
client.put_message('arch1', 'asdf', StringIO('This is a message'))
74
client = GrackleClient('localhost', 8420)
75
message_archives = {'arch1': []}
76
with ForkedFakeService.from_client(client, message_archives):
77
client.put_message('arch1', 'id1', StringIO('This is a message'))
78
response = client.get_messages('arch1')
79
self.assertEqual(1, len(response['messages']))
80
message = response['messages'][0]
81
self.assertEqual('id1', message['message_id'])
83
def test_put_message_without_archive(self):
84
client = GrackleClient('localhost', 8421)
85
message_archives = {'arch1': []}
86
with ForkedFakeService.from_client(client, message_archives):
296
87
with ExpectedException(Exception, 'wtf'):
297
client.put_message('arch1', 'asdf',
298
StringIO('This is not a message'))
88
client.put_message('no-archive', 'id1', StringIO('message'))
301
91
class TestGetMessages(TestCase):
532
325
self.assertEqual('foo', first_message['message_id'])
533
326
self.assertEqual('me', first_message['headers']['From'])
534
327
self.assertEqual('you', first_message['headers']['To'])
536
'abcdefghi\n\nattactment data.', first_message['body'])
328
self.assertEqual(archive['baz'][0]['body'], first_message['body'])
330
def test_date_range(self):
331
client = GrackleClient('localhost', 8448)
335
'foo', 'abcdefghi', headers={'date': '2011-12-31'}),
337
'bar', 'abcdefghi', headers={'date': '2012-01-01'}),
339
'qux', 'abcdefghi', headers={'date': '2012-01-15'}),
341
'naf', 'abcdefghi', headers={'date': '2012-01-31'}),
343
'doh', 'abcdefghi', headers={'date': '2012-02-01'}),
345
with ForkedFakeService.from_client(client, archive):
346
response = client.get_messages(
347
'baz', date_range='2012-01-01..2012-01-31')
348
ids = sorted(m['message_id'] for m in response['messages'])
349
self.assertEqual(['bar', 'naf', 'qux'], ids)
351
def test_date_range_unparsabledaterange(self):
352
client = GrackleClient('localhost', 8449)
353
archive = {'baz': [make_message('foo', body=u'abcdefghi')]}
354
with ForkedFakeService.from_client(client, archive):
355
with ExpectedException(UnparsableDateRange, ''):
356
client.get_messages('baz', date_range='2012-01-01')
358
def test_date_range_unparsabledaterange_missing_part(self):
359
client = GrackleClient('localhost', 8450)
360
archive = {'baz': [make_message('foo', body=u'abcdefghi')]}
361
with ForkedFakeService.from_client(client, archive):
362
with ExpectedException(UnparsableDateRange, ''):
363
client.get_messages('baz', date_range='2012-01-01..')
365
def test_date_range_unparsabledaterange_extra_part(self):
366
client = GrackleClient('localhost', 8451)
367
archive = {'baz': [make_message('foo', body=u'abcdefghi')]}
368
with ForkedFakeService.from_client(client, archive):
369
with ExpectedException(UnparsableDateRange, ''):
370
client.get_messages('baz', date_range='2012-01..12-02..12-03')
373
class TestHideMessages(TestCase):
375
def test_hide_message_true(self):
376
client = GrackleClient('localhost', 8470)
379
make_message('foo', hidden=False),
381
with ForkedFakeService.from_client(client, archive):
382
response = client.hide_message('baz', 'foo', hidden=True)
383
self.assertEqual('foo', response['message_id'])
384
self.assertIs(True, response['hidden'])
386
def test_hide_message_false(self):
387
client = GrackleClient('localhost', 8470)
390
make_message('foo', hidden=True),
392
with ForkedFakeService.from_client(client, archive):
393
response = client.hide_message('baz', 'foo', hidden=False)
394
self.assertEqual('foo', response['message_id'])
395
self.assertIs(False, response['hidden'])