Merge lp:~allenap/launchpad/lp-app-longpoll into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 13375
Proposed branch: lp:~allenap/launchpad/lp-app-longpoll
Merge into: lp:launchpad
Diff against target: 702 lines (+617/-0)
12 files modified
lib/lp/app/configure.zcml (+1/-0)
lib/lp/app/longpoll/__init__.py (+47/-0)
lib/lp/app/longpoll/adapters/event.py (+47/-0)
lib/lp/app/longpoll/adapters/subscriber.py (+56/-0)
lib/lp/app/longpoll/adapters/tests/test_event.py (+84/-0)
lib/lp/app/longpoll/adapters/tests/test_subscriber.py (+134/-0)
lib/lp/app/longpoll/configure.zcml (+10/-0)
lib/lp/app/longpoll/interfaces.py (+51/-0)
lib/lp/app/longpoll/tests/__init__.py (+2/-0)
lib/lp/app/longpoll/tests/test_longpoll.py (+104/-0)
lib/lp/testing/fixture.py (+18/-0)
lib/lp/testing/tests/test_fixture.py (+63/-0)
To merge this branch: bzr merge lp:~allenap/launchpad/lp-app-longpoll
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+66872@code.launchpad.net

Commit message

[r=gmb][no-qa] New lp.app.longpoll package.

Description of the change

New lp.app.longpoll package.

This provides a higher-level API on top of lp.services.messaging. It's
primary goal for now is to make it trivially easy to connect events in
the application to an interested page.

For example, an event in the application can be generated as simply
as:

    emit(an_object, "event_name", {...})

and a page being prepared can arrange to receive these event via
long-poll with a single line of code:

    subscribe(an_object, "event_name")

Behind the scenes a new, short-lived, queue will be created and
subscribed to the correct routing key for the event. The in-page JSON
cache will have the queue details added to it so that the client can
immediately long-poll for events.

This branch contains the server-side code. The client-side code is
being worked on by Raphael.

To support the emit/subscribe two-step a single adapter must be
registered. It must (multi-) adapt the object that issues events and
an event description to an ILongPollEvent. This branch contains a
suitable base-class, LongPollEvent. Suppose a Job emits events from
_set_status using emit(self, "status", status.name) the following
adapter would work:

    class JobLongPollEvent(LongPollEvent):

        adapts(IJob, Interface)
        implements(ILongPollEvent)

        @property
        def event_key(self):
            return generate_event_key(
                "job", self.source.id, self.event)

This ensures that there's a stable and consistent event naming scheme.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Hi Gavin,

Nice to see all the work from last week landing. This is a good branch; a few nits but nothing huge. r=me pending the changes below.

[1]

396 +class TestModule(TestCase):

This seems like a bit of a generic name... Maybe TestSubscriberModule for clarity?

[2]

446 + source = Attribute(
447 + "The event source.")
448 +
449 + event = Attribute(
450 + "An object indicating the type of event.")

Minor stylistic nit: I don't think these need to be spread over two lines.

[3]

556 +class TestModule(TestCase):

Same as above. TestLongPollModule maybe?

[4]

560 + def test_subscribe(self):

576 + def test_emit(self):

681 + def test_register_and_unregister(self):

DMMT. These could do with some leading comments explaining the expected behaviour so that I don't have to read the tests.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/configure.zcml'
2--- lib/lp/app/configure.zcml 2011-05-27 21:25:58 +0000
3+++ lib/lp/app/configure.zcml 2011-07-05 13:35:30 +0000
4@@ -10,6 +10,7 @@
5 xmlns:lp="http://namespaces.canonical.com/lp"
6 i18n_domain="launchpad">
7 <include package=".browser"/>
8+ <include package=".longpoll" />
9 <include package="lp.app.validators" />
10 <include package="lp.app.widgets" />
11
12
13=== added directory 'lib/lp/app/longpoll'
14=== added file 'lib/lp/app/longpoll/__init__.py'
15--- lib/lp/app/longpoll/__init__.py 1970-01-01 00:00:00 +0000
16+++ lib/lp/app/longpoll/__init__.py 2011-07-05 13:35:30 +0000
17@@ -0,0 +1,47 @@
18+# Copyright 2011 Canonical Ltd. This software is licensed under the
19+# GNU Affero General Public License version 3 (see the file LICENSE).
20+
21+"""Long-poll infrastructure."""
22+
23+__metaclass__ = type
24+__all__ = [
25+ "emit",
26+ "subscribe",
27+ ]
28+
29+from .interfaces import (
30+ ILongPollEvent,
31+ ILongPollSubscriber,
32+ )
33+from lazr.restful.utils import get_current_browser_request
34+from zope.component import getMultiAdapter
35+
36+
37+def subscribe(target, event, request=None):
38+ """Convenience method to subscribe the current request.
39+
40+ :param target: Something that can be adapted to `ILongPollEvent`.
41+ :param event: The name of the event to subscribe to.
42+ :param request: The request for which to get an `ILongPollSubscriber`. It
43+ a request is not specified the currently active request is used.
44+ :return: The `ILongPollEvent` that has been subscribed to.
45+ """
46+ event = getMultiAdapter((target, event), ILongPollEvent)
47+ if request is None:
48+ request = get_current_browser_request()
49+ subscriber = ILongPollSubscriber(request)
50+ subscriber.subscribe(event)
51+ return event
52+
53+
54+def emit(source, event, data):
55+ """Convenience method to emit a message for an event.
56+
57+ :param source: Something, along with `event`, that can be adapted to
58+ `ILongPollEvent`.
59+ :param event: A name/key of the event that is emitted.
60+ :return: The `ILongPollEvent` that has been emitted.
61+ """
62+ event = getMultiAdapter((source, event), ILongPollEvent)
63+ event.emit(data)
64+ return event
65
66=== added directory 'lib/lp/app/longpoll/adapters'
67=== added file 'lib/lp/app/longpoll/adapters/__init__.py'
68=== added file 'lib/lp/app/longpoll/adapters/event.py'
69--- lib/lp/app/longpoll/adapters/event.py 1970-01-01 00:00:00 +0000
70+++ lib/lp/app/longpoll/adapters/event.py 2011-07-05 13:35:30 +0000
71@@ -0,0 +1,47 @@
72+# Copyright 2011 Canonical Ltd. This software is licensed under the
73+# GNU Affero General Public License version 3 (see the file LICENSE).
74+
75+"""Long poll adapters."""
76+
77+__metaclass__ = type
78+__all__ = [
79+ "generate_event_key",
80+ "LongPollEvent",
81+ ]
82+
83+from lp.services.messaging.queue import RabbitRoutingKey
84+
85+
86+def generate_event_key(*components):
87+ """Generate a suitable event name."""
88+ if len(components) == 0:
89+ raise AssertionError(
90+ "Event keys must contain at least one component.")
91+ return "longpoll.event.%s" % ".".join(
92+ str(component) for component in components)
93+
94+
95+class LongPollEvent:
96+ """Base-class for event adapters.
97+
98+ Sub-classes need to declare something along the lines of:
99+
100+ adapts(Interface, Interface)
101+ implements(ILongPollEvent)
102+
103+ """
104+
105+ def __init__(self, source, event):
106+ self.source = source
107+ self.event = event
108+
109+ @property
110+ def event_key(self):
111+ """See `ILongPollEvent`."""
112+ raise NotImplementedError(self.__class__.event_key)
113+
114+ def emit(self, data):
115+ """See `ILongPollEvent`."""
116+ payload = {"event_key": self.event_key, "event_data": data}
117+ routing_key = RabbitRoutingKey(self.event_key)
118+ routing_key.send(payload)
119
120=== added file 'lib/lp/app/longpoll/adapters/subscriber.py'
121--- lib/lp/app/longpoll/adapters/subscriber.py 1970-01-01 00:00:00 +0000
122+++ lib/lp/app/longpoll/adapters/subscriber.py 2011-07-05 13:35:30 +0000
123@@ -0,0 +1,56 @@
124+# Copyright 2011 Canonical Ltd. This software is licensed under the
125+# GNU Affero General Public License version 3 (see the file LICENSE).
126+
127+"""Long poll adapters."""
128+
129+__metaclass__ = type
130+__all__ = [
131+ "generate_subscribe_key",
132+ "LongPollSubscriber",
133+ ]
134+
135+from uuid import uuid4
136+
137+from lazr.restful.interfaces import IJSONRequestCache
138+from zope.component import adapts
139+from zope.interface import implements
140+from zope.publisher.interfaces import IApplicationRequest
141+
142+from lp.app.longpoll.interfaces import ILongPollSubscriber
143+from lp.services.messaging.queue import (
144+ RabbitQueue,
145+ RabbitRoutingKey,
146+ )
147+
148+
149+def generate_subscribe_key():
150+ """Generate a suitable new, unique, subscribe key."""
151+ return "longpoll.subscribe.%s" % uuid4()
152+
153+
154+class LongPollSubscriber:
155+
156+ adapts(IApplicationRequest)
157+ implements(ILongPollSubscriber)
158+
159+ def __init__(self, request):
160+ self.request = request
161+
162+ @property
163+ def subscribe_key(self):
164+ objects = IJSONRequestCache(self.request).objects
165+ if "longpoll" in objects:
166+ return objects["longpoll"]["key"]
167+ return None
168+
169+ def subscribe(self, event):
170+ cache = IJSONRequestCache(self.request)
171+ if "longpoll" not in cache.objects:
172+ cache.objects["longpoll"] = {
173+ "key": generate_subscribe_key(),
174+ "subscriptions": [],
175+ }
176+ subscribe_queue = RabbitQueue(self.subscribe_key)
177+ routing_key = RabbitRoutingKey(event.event_key)
178+ routing_key.associateConsumer(subscribe_queue)
179+ cache.objects["longpoll"]["subscriptions"].append(event.event_key)
180
181=== added directory 'lib/lp/app/longpoll/adapters/tests'
182=== added file 'lib/lp/app/longpoll/adapters/tests/__init__.py'
183=== added file 'lib/lp/app/longpoll/adapters/tests/test_event.py'
184--- lib/lp/app/longpoll/adapters/tests/test_event.py 1970-01-01 00:00:00 +0000
185+++ lib/lp/app/longpoll/adapters/tests/test_event.py 2011-07-05 13:35:30 +0000
186@@ -0,0 +1,84 @@
187+# Copyright 2011 Canonical Ltd. This software is licensed under the
188+# GNU Affero General Public License version 3 (see the file LICENSE).
189+
190+"""Long-poll event adapter tests."""
191+
192+__metaclass__ = type
193+
194+from zope.interface import implements
195+
196+from canonical.testing.layers import (
197+ BaseLayer,
198+ LaunchpadFunctionalLayer,
199+ )
200+from lp.app.longpoll.adapters.event import (
201+ generate_event_key,
202+ LongPollEvent,
203+ )
204+from lp.app.longpoll.interfaces import ILongPollEvent
205+from lp.services.messaging.queue import RabbitMessageBase
206+from lp.testing import TestCase
207+from lp.testing.matchers import Contains
208+
209+
210+class FakeEvent(LongPollEvent):
211+
212+ implements(ILongPollEvent)
213+
214+ @property
215+ def event_key(self):
216+ return "event-key-%s-%s" % (self.source, self.event)
217+
218+
219+class TestLongPollEvent(TestCase):
220+
221+ layer = LaunchpadFunctionalLayer
222+
223+ def test_interface(self):
224+ event = FakeEvent("source", "event")
225+ self.assertProvides(event, ILongPollEvent)
226+
227+ def test_event_key(self):
228+ # event_key is not implemented in LongPollEvent; subclasses must
229+ # provide it.
230+ event = LongPollEvent("source", "event")
231+ self.assertRaises(NotImplementedError, getattr, event, "event_key")
232+
233+ def test_emit(self):
234+ # LongPollEvent.emit() sends the given data to `event_key`.
235+ event = FakeEvent("source", "event")
236+ event_data = {"hello": 1234}
237+ event.emit(event_data)
238+ expected_message = {
239+ "event_key": event.event_key,
240+ "event_data": event_data,
241+ }
242+ pending_messages = [
243+ message for (call, message) in
244+ RabbitMessageBase.class_locals.messages]
245+ self.assertThat(pending_messages, Contains(expected_message))
246+
247+
248+class TestFunctions(TestCase):
249+
250+ layer = BaseLayer
251+
252+ def test_generate_event_key_no_components(self):
253+ self.assertRaises(
254+ AssertionError, generate_event_key)
255+
256+ def test_generate_event_key(self):
257+ self.assertEqual(
258+ "longpoll.event.event-name",
259+ generate_event_key("event-name"))
260+ self.assertEqual(
261+ "longpoll.event.source-name.event-name",
262+ generate_event_key("source-name", "event-name"))
263+ self.assertEqual(
264+ "longpoll.event.type-name.source-name.event-name",
265+ generate_event_key("type-name", "source-name", "event-name"))
266+
267+ def test_generate_event_key_stringifies_components(self):
268+ self.assertEqual(
269+ "longpoll.event.job.1234.COMPLETED",
270+ generate_event_key("job", 1234, "COMPLETED"))
271
272=== added file 'lib/lp/app/longpoll/adapters/tests/test_subscriber.py'
273--- lib/lp/app/longpoll/adapters/tests/test_subscriber.py 1970-01-01 00:00:00 +0000
274+++ lib/lp/app/longpoll/adapters/tests/test_subscriber.py 2011-07-05 13:35:30 +0000
275@@ -0,0 +1,134 @@
276+# Copyright 2011 Canonical Ltd. This software is licensed under the
277+# GNU Affero General Public License version 3 (see the file LICENSE).
278+
279+"""Long-poll subscriber adapter tests."""
280+
281+__metaclass__ = type
282+
283+from itertools import count
284+
285+from lazr.restful.interfaces import IJSONRequestCache
286+from testtools.matchers import (
287+ Not,
288+ StartsWith,
289+ )
290+from zope.interface import implements
291+
292+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
293+from canonical.testing.layers import (
294+ BaseLayer,
295+ LaunchpadFunctionalLayer,
296+ )
297+from lp.app.longpoll.adapters.subscriber import (
298+ generate_subscribe_key,
299+ LongPollSubscriber,
300+ )
301+from lp.app.longpoll.interfaces import (
302+ ILongPollEvent,
303+ ILongPollSubscriber,
304+ )
305+from lp.services.messaging.queue import (
306+ RabbitQueue,
307+ RabbitRoutingKey,
308+ )
309+from lp.testing import TestCase
310+from lp.testing.matchers import Contains
311+
312+
313+class FakeEvent:
314+
315+ implements(ILongPollEvent)
316+
317+ event_key_indexes = count(1)
318+
319+ def __init__(self):
320+ self.event_key = "event-key-%d" % next(self.event_key_indexes)
321+
322+
323+class TestLongPollSubscriber(TestCase):
324+
325+ layer = LaunchpadFunctionalLayer
326+
327+ def test_interface(self):
328+ request = LaunchpadTestRequest()
329+ subscriber = LongPollSubscriber(request)
330+ self.assertProvides(subscriber, ILongPollSubscriber)
331+
332+ def test_subscribe_key(self):
333+ request = LaunchpadTestRequest()
334+ subscriber = LongPollSubscriber(request)
335+ # A subscribe key is not generated yet.
336+ self.assertIs(subscriber.subscribe_key, None)
337+ # It it only generated on the first subscription.
338+ subscriber.subscribe(FakeEvent())
339+ subscribe_key = subscriber.subscribe_key
340+ self.assertIsInstance(subscribe_key, str)
341+ self.assertNotEqual(0, len(subscribe_key))
342+ # It remains the same for later subscriptions.
343+ subscriber.subscribe(FakeEvent())
344+ self.assertEqual(subscribe_key, subscriber.subscribe_key)
345+
346+ def test_adapter(self):
347+ request = LaunchpadTestRequest()
348+ subscriber = ILongPollSubscriber(request)
349+ self.assertIsInstance(subscriber, LongPollSubscriber)
350+ # A difference subscriber is returned on subsequent adaptions, but it
351+ # has the same subscribe_key.
352+ subscriber2 = ILongPollSubscriber(request)
353+ self.assertIsNot(subscriber, subscriber2)
354+ self.assertEqual(subscriber.subscribe_key, subscriber2.subscribe_key)
355+
356+ def test_subscribe_queue(self):
357+ # LongPollSubscriber.subscribe() creates a new queue with a new unique
358+ # name that is bound to the event's event_key.
359+ request = LaunchpadTestRequest()
360+ event = FakeEvent()
361+ subscriber = ILongPollSubscriber(request)
362+ subscriber.subscribe(event)
363+ message = '{"hello": 1234}'
364+ routing_key = RabbitRoutingKey(event.event_key)
365+ routing_key.send_now(message)
366+ subscribe_queue = RabbitQueue(subscriber.subscribe_key)
367+ self.assertEqual(
368+ message, subscribe_queue.receive(timeout=5))
369+
370+ def test_json_cache_not_populated_on_init(self):
371+ # LongPollSubscriber does not put the name of the new queue into the
372+ # JSON cache.
373+ request = LaunchpadTestRequest()
374+ cache = IJSONRequestCache(request)
375+ self.assertThat(cache.objects, Not(Contains("longpoll")))
376+ ILongPollSubscriber(request)
377+ self.assertThat(cache.objects, Not(Contains("longpoll")))
378+
379+ def test_json_cache_populated_on_subscribe(self):
380+ # To aid with debugging the event_key of subscriptions are added to
381+ # the JSON cache.
382+ request = LaunchpadTestRequest()
383+ cache = IJSONRequestCache(request)
384+ event1 = FakeEvent()
385+ ILongPollSubscriber(request).subscribe(event1) # Side-effects!
386+ self.assertThat(cache.objects, Contains("longpoll"))
387+ self.assertThat(cache.objects["longpoll"], Contains("key"))
388+ self.assertThat(cache.objects["longpoll"], Contains("subscriptions"))
389+ self.assertEqual(
390+ [event1.event_key],
391+ cache.objects["longpoll"]["subscriptions"])
392+ # More events can be subscribed.
393+ event2 = FakeEvent()
394+ ILongPollSubscriber(request).subscribe(event2)
395+ self.assertEqual(
396+ [event1.event_key, event2.event_key],
397+ cache.objects["longpoll"]["subscriptions"])
398+
399+
400+class TestFunctions(TestCase):
401+
402+ layer = BaseLayer
403+
404+ def test_generate_subscribe_key(self):
405+ subscribe_key = generate_subscribe_key()
406+ expected_prefix = "longpoll.subscribe."
407+ self.assertThat(subscribe_key, StartsWith(expected_prefix))
408+ # The key contains a 36 character UUID.
409+ self.assertEqual(len(expected_prefix) + 36, len(subscribe_key))
410
411=== added file 'lib/lp/app/longpoll/configure.zcml'
412--- lib/lp/app/longpoll/configure.zcml 1970-01-01 00:00:00 +0000
413+++ lib/lp/app/longpoll/configure.zcml 2011-07-05 13:35:30 +0000
414@@ -0,0 +1,10 @@
415+<!-- Copyright 2011 Canonical Ltd. This software is licensed under the
416+ GNU Affero General Public License version 3 (see the file LICENSE).
417+-->
418+<configure
419+ xmlns="http://namespaces.zope.org/zope"
420+ xmlns:browser="http://namespaces.zope.org/browser"
421+ xmlns:i18n="http://namespaces.zope.org/i18n"
422+ i18n_domain="launchpad">
423+ <adapter factory=".adapters.subscriber.LongPollSubscriber" />
424+</configure>
425
426=== added file 'lib/lp/app/longpoll/interfaces.py'
427--- lib/lp/app/longpoll/interfaces.py 1970-01-01 00:00:00 +0000
428+++ lib/lp/app/longpoll/interfaces.py 2011-07-05 13:35:30 +0000
429@@ -0,0 +1,51 @@
430+# Copyright 2011 Canonical Ltd. This software is licensed under the
431+# GNU Affero General Public License version 3 (see the file LICENSE).
432+
433+"""Long-poll infrastructure interfaces."""
434+
435+__metaclass__ = type
436+__all__ = [
437+ "ILongPollEvent",
438+ "ILongPollSubscriber",
439+ ]
440+
441+
442+from zope.interface import (
443+ Attribute,
444+ Interface,
445+ )
446+
447+
448+class ILongPollEvent(Interface):
449+
450+ source = Attribute("The event source.")
451+
452+ event = Attribute("An object indicating the type of event.")
453+
454+ event_key = Attribute(
455+ "The key with which events will be emitted. Should be predictable "
456+ "and stable.")
457+
458+ def emit(data):
459+ """Emit the given data to `event_key`.
460+
461+ The data will be wrapped up into a `dict` with the keys `event_key`
462+ and `event_data`, where `event_key` is a copy of `self.event_key` and
463+ `event_data` is the `data` argument.
464+
465+ :param data: Any data structure that can be dumped as JSON.
466+ """
467+
468+
469+class ILongPollSubscriber(Interface):
470+
471+ subscribe_key = Attribute(
472+ "The key which the subscriber must know in order to be able "
473+ "to long-poll for subscribed events. Should be infeasible to "
474+ "guess, a UUID for example.")
475+
476+ def subscribe(event):
477+ """Subscribe to the given event.
478+
479+ :type event: ILongPollEvent
480+ """
481
482=== added directory 'lib/lp/app/longpoll/tests'
483=== added file 'lib/lp/app/longpoll/tests/__init__.py'
484--- lib/lp/app/longpoll/tests/__init__.py 1970-01-01 00:00:00 +0000
485+++ lib/lp/app/longpoll/tests/__init__.py 2011-07-05 13:35:30 +0000
486@@ -0,0 +1,2 @@
487+# Copyright 2011 Canonical Ltd. This software is licensed under the
488+# GNU Affero General Public License version 3 (see the file LICENSE).
489
490=== added file 'lib/lp/app/longpoll/tests/test_longpoll.py'
491--- lib/lp/app/longpoll/tests/test_longpoll.py 1970-01-01 00:00:00 +0000
492+++ lib/lp/app/longpoll/tests/test_longpoll.py 2011-07-05 13:35:30 +0000
493@@ -0,0 +1,104 @@
494+# Copyright 2011 Canonical Ltd. This software is licensed under the
495+# GNU Affero General Public License version 3 (see the file LICENSE).
496+
497+"""Tests for lp.app.longpoll."""
498+
499+__metaclass__ = type
500+
501+from zope.component import adapts
502+from zope.interface import (
503+ Attribute,
504+ implements,
505+ Interface,
506+ )
507+
508+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
509+from canonical.testing.layers import LaunchpadFunctionalLayer
510+from lp.app.longpoll import (
511+ emit,
512+ subscribe,
513+ )
514+from lp.app.longpoll.interfaces import (
515+ ILongPollEvent,
516+ ILongPollSubscriber,
517+ )
518+from lp.services.messaging.queue import (
519+ RabbitQueue,
520+ RabbitRoutingKey,
521+ )
522+from lp.testing import TestCase
523+from lp.testing.fixture import ZopeAdapterFixture
524+
525+
526+class IFakeObject(Interface):
527+
528+ ident = Attribute("ident")
529+
530+
531+class FakeObject:
532+
533+ implements(IFakeObject)
534+
535+ def __init__(self, ident):
536+ self.ident = ident
537+
538+
539+class FakeEvent:
540+
541+ adapts(IFakeObject, Interface)
542+ implements(ILongPollEvent)
543+
544+ def __init__(self, source, event):
545+ self.source = source
546+ self.event = event
547+
548+ @property
549+ def event_key(self):
550+ return "event-key-%s-%s" % (
551+ self.source.ident, self.event)
552+
553+ def emit(self, data):
554+ # Don't cargo-cult this; see .adapters.event.LongPollEvent instead.
555+ RabbitRoutingKey(self.event_key).send_now(data)
556+
557+
558+class TestFunctions(TestCase):
559+
560+ layer = LaunchpadFunctionalLayer
561+
562+ def test_subscribe(self):
563+ # subscribe() gets the ILongPollEvent for the given (target, event)
564+ # and the ILongPollSubscriber for the given request (or the current
565+ # request is discovered). It subscribes the latter to the event, then
566+ # returns the event.
567+ request = LaunchpadTestRequest()
568+ an_object = FakeObject(12345)
569+ with ZopeAdapterFixture(FakeEvent):
570+ event = subscribe(an_object, "foo", request=request)
571+ self.assertIsInstance(event, FakeEvent)
572+ self.assertEqual("event-key-12345-foo", event.event_key)
573+ # Emitting an event-key-12345-foo event will put something on the
574+ # subscriber's queue.
575+ event_data = {"1234": 5678}
576+ event.emit(event_data)
577+ subscriber = ILongPollSubscriber(request)
578+ subscribe_queue = RabbitQueue(subscriber.subscribe_key)
579+ message = subscribe_queue.receive(timeout=5)
580+ self.assertEqual(event_data, message)
581+
582+ def test_emit(self):
583+ # subscribe() gets the ILongPollEvent for the given (target, event)
584+ # and passes the given data to its emit() method. It then returns the
585+ # event.
586+ an_object = FakeObject(12345)
587+ with ZopeAdapterFixture(FakeEvent):
588+ event = emit(an_object, "bar", {})
589+ routing_key = RabbitRoutingKey(event.event_key)
590+ subscribe_queue = RabbitQueue("whatever")
591+ routing_key.associateConsumer(subscribe_queue)
592+ # Emit the event again; the subscribe queue was not associated
593+ # with the event before now.
594+ event_data = {"8765": 4321}
595+ event = emit(an_object, "bar", event_data)
596+ message = subscribe_queue.receive(timeout=5)
597+ self.assertEqual(event_data, message)
598
599=== modified file 'lib/lp/testing/fixture.py'
600--- lib/lp/testing/fixture.py 2011-06-10 02:30:43 +0000
601+++ lib/lp/testing/fixture.py 2011-07-05 13:35:30 +0000
602@@ -5,7 +5,9 @@
603
604 __metaclass__ = type
605 __all__ = [
606+ 'ZopeAdapterFixture',
607 'ZopeEventHandlerFixture',
608+ 'ZopeViewReplacementFixture',
609 ]
610
611 from fixtures import Fixture
612@@ -22,6 +24,22 @@
613 )
614
615
616+class ZopeAdapterFixture(Fixture):
617+ """A fixture to register and unregister an adapter."""
618+
619+ def __init__(self, *args, **kwargs):
620+ self._args, self._kwargs = args, kwargs
621+
622+ def setUp(self):
623+ super(ZopeAdapterFixture, self).setUp()
624+ site_manager = getGlobalSiteManager()
625+ site_manager.registerAdapter(
626+ *self._args, **self._kwargs)
627+ self.addCleanup(
628+ site_manager.unregisterAdapter,
629+ *self._args, **self._kwargs)
630+
631+
632 class ZopeEventHandlerFixture(Fixture):
633 """A fixture that provides and then unprovides a Zope event handler."""
634
635
636=== added file 'lib/lp/testing/tests/test_fixture.py'
637--- lib/lp/testing/tests/test_fixture.py 1970-01-01 00:00:00 +0000
638+++ lib/lp/testing/tests/test_fixture.py 2011-07-05 13:35:30 +0000
639@@ -0,0 +1,63 @@
640+# Copyright 2011 Canonical Ltd. This software is licensed under the
641+# GNU Affero General Public License version 3 (see the file LICENSE).
642+
643+"""Tests for lp.testing.fixture."""
644+
645+__metaclass__ = type
646+
647+from zope.component import (
648+ adapts,
649+ queryAdapter,
650+ )
651+from zope.interface import (
652+ implements,
653+ Interface,
654+ )
655+
656+from canonical.testing.layers import BaseLayer
657+from lp.testing import TestCase
658+from lp.testing.fixture import ZopeAdapterFixture
659+
660+
661+class IFoo(Interface):
662+ pass
663+
664+
665+class IBar(Interface):
666+ pass
667+
668+
669+class Foo:
670+ implements(IFoo)
671+
672+
673+class Bar:
674+ implements(IBar)
675+
676+
677+class FooToBar:
678+
679+ adapts(IFoo)
680+ implements(IBar)
681+
682+ def __init__(self, foo):
683+ self.foo = foo
684+
685+
686+class TestZopeAdapterFixture(TestCase):
687+
688+ layer = BaseLayer
689+
690+ def test_register_and_unregister(self):
691+ # Entering ZopeAdapterFixture's context registers the given adapter,
692+ # and exiting the context unregisters the adapter again.
693+ context = Foo()
694+ # No adapter from Foo to Bar is registered.
695+ self.assertIs(None, queryAdapter(context, IBar))
696+ with ZopeAdapterFixture(FooToBar):
697+ # Now there is an adapter from Foo to Bar.
698+ adapter = queryAdapter(context, IBar)
699+ self.assertIsNot(None, adapter)
700+ self.assertIsInstance(adapter, FooToBar)
701+ # The adapter is no longer registered.
702+ self.assertIs(None, queryAdapter(context, IBar))