changeset 45:c8cb4e867897

made proper package + installation
author Arnaud Joset <info@agayon.be>
date Thu, 02 Nov 2017 22:50:59 +0100
parents 7430d1f6db22
children 725ca928fa2c
files __init__.py import_test.py sat_tmp/__init__.py sat_tmp/wokkel/__init__.py sat_tmp/wokkel/mam.py sat_tmp/wokkel/pubsub.py sat_tmp/wokkel/rsm.py sat_tmp/wokkel/test/__init__.py sat_tmp/wokkel/test/test_pubsub.py sat_tmp/wokkel/test/test_rsm.py setup.py wokkel/__init__.py wokkel/mam.py wokkel/pubsub.py wokkel/rsm.py wokkel/test/__init__.py wokkel/test/test_pubsub.py wokkel/test/test_rsm.py
diffstat 12 files changed, 7705 insertions(+), 7642 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/import_test.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,9 @@
+import wokkel
+
+from sat_tmp.wokkel import pubsub as tmp_pubsub, rsm as tmp_rsm, mam as tmp_mam
+
+wokkel.pubsub = tmp_pubsub
+
+wokkel.rsm = tmp_rsm
+
+wokkel.mam = tmp_mam
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_tmp/wokkel/mam.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,620 @@
+# -*- coding: utf-8 -*-
+# -*- test-case-name: wokkel.test.test_mam -*-
+#
+# SàT Wokkel extension for Message Archive Management (XEP-0313)
+# Copyright (C) 2015 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+XMPP Message Archive Management protocol.
+
+This protocol is specified in
+U{XEP-0313<http://xmpp.org/extensions/xep-0313.html>}.
+"""
+
+from dateutil import tz
+
+from zope.interface import implements
+from zope.interface import Interface
+
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from twisted.internet import defer
+from twisted.python import log
+
+from wokkel import subprotocols
+from wokkel import disco
+from wokkel import data_form
+from wokkel import delay
+
+import rsm
+
+NS_MAM = 'urn:xmpp:mam:1'
+NS_FORWARD = 'urn:xmpp:forward:0'
+
+FIELDS_REQUEST = "/iq[@type='get']/query[@xmlns='%s']" % NS_MAM
+ARCHIVE_REQUEST = "/iq[@type='set']/query[@xmlns='%s']" % NS_MAM
+PREFS_GET_REQUEST = "/iq[@type='get']/prefs[@xmlns='%s']" % NS_MAM
+PREFS_SET_REQUEST = "/iq[@type='set']/prefs[@xmlns='%s']" % NS_MAM
+
+# TODO: add the tests!
+
+
+class MAMError(error.StanzaError):
+    """
+    MAM error.
+    """
+    def __init__(self, text=None):
+        error.StanzaError.__init__(self, 'bad-request', text=text)
+
+
+class Unsupported(MAMError):
+    def __init__(self, feature, text=None):
+        self.feature = feature
+        MAMError.__init__(self, 'feature-not-implemented',
+                                'unsupported',
+                                feature,
+                                text)
+
+    def __str__(self):
+        message = MAMError.__str__(self)
+        message += ', feature %r' % self.feature
+        return message
+
+
+class MAMRequest(object):
+    """
+    A Message Archive Management <query/> request.
+
+    @ivar form: Data Form specifing the filters.
+    @itype form: L{data_form.Form}
+
+    @ivar rsm: RSM request instance.
+    @itype rsm: L{rsm.RSMRequest}
+
+    @ivar node: pubsub node id if querying a pubsub node, else None.
+    @itype node: C{unicode}
+
+    @ivar query_id: id to use to track the query
+    @itype query_id: C{unicode}
+    """
+    # FIXME: should be based on generic.Stanza
+
+    def __init__(self, form=None, rsm_=None, node=None, query_id=None, sender=None, recipient=None):
+        if form is not None:
+            assert form.formType == 'submit'
+            assert form.formNamespace == NS_MAM
+        self.form = form
+        self.rsm = rsm_
+        self.node = node
+        self.query_id = query_id
+        self.sender = sender
+        self.recipient = recipient
+
+    @classmethod
+    def fromElement(cls, iq):
+        """Parse the DOM representation of a MAM <query/> request.
+
+        @param iq: <iq/> element containing a MAM <query/>.
+        @type iq: L{Element<twisted.words.xish.domish.Element>}
+
+        @return: MAMRequest instance.
+        @rtype: L{MAMRequest}
+        """
+        sender = jid.JID(iq.getAttribute('from'))
+        recipient = jid.JID(iq.getAttribute('to'))
+        try:
+            query = iq.elements(NS_MAM, 'query').next()
+        except StopIteration:
+            raise MAMError("Can't find MAM <query/> in element")
+        form = data_form.findForm(query, NS_MAM)
+        try:
+            rsm_request = rsm.RSMRequest.fromElement(query)
+        except rsm.RSMNotFoundError:
+            rsm_request = None
+        node = query.getAttribute('node')
+        query_id = query.getAttribute('queryid')
+        return MAMRequest(form, rsm_request, node, query_id, sender, recipient)
+
+    def toElement(self):
+        """
+        Return the DOM representation of this RSM <query/> request.
+
+        @rtype: L{Element<twisted.words.xish.domish.Element>}
+        """
+        mam_elt = domish.Element((NS_MAM, 'query'))
+        if self.node is not None:
+            mam_elt['node'] = self.node
+        if self.query_id is not None:
+            mam_elt['queryid'] = self.query_id
+        if self.form is not None:
+            mam_elt.addChild(self.form.toElement())
+        if self.rsm is not None:
+            mam_elt.addChild(self.rsm.toElement())
+
+        return mam_elt
+
+    def render(self, parent):
+        """Embed the DOM representation of this MAM request in the given element.
+
+        @param parent: parent IQ element.
+        @type parent: L{Element<twisted.words.xish.domish.Element>}
+
+        @return: MAM request element.
+        @rtype: L{Element<twisted.words.xish.domish.Element>}
+        """
+        assert parent.name == 'iq'
+        mam_elt = self.toElement()
+        parent.addChild(mam_elt)
+        return mam_elt
+
+
+class MAMPrefs(object):
+    """
+    A Message Archive Management <prefs/> request.
+
+    @param default: A value in ('always', 'never', 'roster').
+    @type : C{unicode} or C{None}
+
+    @param always (list): A list of JID instances.
+    @type always: C{list}
+
+    @param never (list): A list of JID instances.
+    @type never: C{list}
+    """
+
+    def __init__(self, default=None, always=None, never=None):
+        if default is not None:
+            # default must be defined in response, but can be empty in request (see http://xmpp.org/extensions/xep-0313.html#config)
+            assert default in ('always', 'never', 'roster')
+        self.default = default
+        if always is not None:
+            assert isinstance(always, list)
+        else:
+            always = []
+        self.always = always
+        if never is not None:
+            assert isinstance(never, list)
+        else:
+            never = []
+        self.never = never
+
+    @classmethod
+    def fromElement(cls, prefs_elt):
+        """Parse the DOM representation of a MAM <prefs/> request.
+
+        @param prefs_elt: MAM <prefs/> request element.
+        @type prefs_elt: L{Element<twisted.words.xish.domish.Element>}
+
+        @return: MAMPrefs instance.
+        @rtype: L{MAMPrefs}
+        """
+        if prefs_elt.uri != NS_MAM or prefs_elt.name != 'prefs':
+            raise MAMError('Element provided is not a MAM <prefs/> request')
+        try:
+            default = prefs_elt['default']
+        except KeyError:
+            # FIXME: return proper error here
+            raise MAMError('Element provided is not a valid MAM <prefs/> request')
+
+        prefs = {}
+        for attr in ('always', 'never'):
+            prefs[attr] = []
+            try:
+                pref = prefs_elt.elements(NS_MAM, attr).next()
+            except StopIteration:
+                # FIXME: return proper error here
+                raise MAMError('Element provided is not a valid MAM <prefs/> request')
+            else:
+                for jid_s in pref.elements(NS_MAM, 'jid'):
+                    prefs[attr].append(jid.JID(jid_s))
+        return MAMPrefs(default, **prefs)
+
+    def toElement(self):
+        """
+        Return the DOM representation of this RSM <prefs/>request.
+
+        @rtype: L{Element<twisted.words.xish.domish.Element>}
+        """
+        mam_elt = domish.Element((NS_MAM, 'prefs'))
+        if self.default:
+            mam_elt['default'] = self.default
+        for attr in ('always', 'never'):
+            attr_elt = mam_elt.addElement(attr)
+            jids = getattr(self, attr)
+            for jid_ in jids:
+                attr_elt.addElement('jid', content=jid_.full())
+        return mam_elt
+
+    def render(self, parent):
+        """Embed the DOM representation of this MAM request in the given element.
+
+        @param parent: parent IQ element.
+        @type parent: L{Element<twisted.words.xish.domish.Element>}
+
+        @return: MAM request element.
+        @rtype: L{Element<twisted.words.xish.domish.Element>}
+        """
+        assert parent.name == 'iq'
+        mam_elt = self.toElement()
+        parent.addChild(mam_elt)
+        return mam_elt
+
+
+class MAMClient(subprotocols.XMPPHandler):
+    """
+    MAM client.
+
+    This handler implements the protocol for sending out MAM requests.
+    """
+
+    def queryArchive(self, mam_query, service=None, sender=None):
+        """Query a user, MUC or pubsub archive.
+
+        @param mam_query: query to use
+        @type form: L{MAMRequest}
+
+        @param service: Entity offering the MAM service (None for user server).
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param sender: Optional sender address.
+        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: A deferred that fires upon receiving a response.
+        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
+        """
+        iq = xmlstream.IQ(self.xmlstream, 'set')
+        mam_query.render(iq)
+        if sender is not None:
+            iq['from'] = unicode(sender)
+        return iq.send(to=service.full() if service else None)
+
+    def queryFields(self, service=None, sender=None):
+        """Ask the server about supported fields.
+
+        @param service: Entity offering the MAM service (None for user archives).
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param sender: Optional sender address.
+        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: data Form with the fields, or None if not found
+        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
+        """
+        # http://xmpp.org/extensions/xep-0313.html#query-form
+        iq = xmlstream.IQ(self.xmlstream, 'get')
+        MAMRequest().render(iq)
+        if sender is not None:
+            iq['from'] = unicode(sender)
+        d = iq.send(to=service.full() if service else None)
+        d.addCallback(lambda iq_result: iq_result.elements(NS_MAM, 'query').next())
+        d.addCallback(data_form.findForm, NS_MAM)
+        return d
+
+    def queryPrefs(self, service=None, sender=None):
+        """Retrieve the current user preferences.
+
+        @param service: Entity offering the MAM service (None for user archives).
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param sender: Optional sender address.
+        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: A deferred that fires upon receiving a response.
+        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
+        """
+        # http://xmpp.org/extensions/xep-0313.html#prefs
+        iq = xmlstream.IQ(self.xmlstream, 'get')
+        MAMPrefs().render(iq)
+        if sender is not None:
+            iq['from'] = unicode(sender)
+        return iq.send(to=service.full() if service else None)
+
+    def setPrefs(self, service=None, default='roster', always=None, never=None, sender=None):
+        """Set new user preferences.
+
+        @param service: Entity offering the MAM service (None for user archives).
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param default: A value in ('always', 'never', 'roster').
+        @type : C{unicode}
+
+        @param always (list): A list of JID instances.
+        @type always: C{list}
+
+        @param never (list): A list of JID instances.
+        @type never: C{list}
+
+        @param sender: Optional sender address.
+        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: A deferred that fires upon receiving a response.
+        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
+        """
+        # http://xmpp.org/extensions/xep-0313.html#prefs
+        assert default is not None
+        iq = xmlstream.IQ(self.xmlstream, 'set')
+        MAMPrefs(default, always, never).render(iq)
+        if sender is not None:
+            iq['from'] = unicode(sender)
+        return iq.send(to=service.full() if service else None)
+
+
+class IMAMResource(Interface):
+
+    def onArchiveRequest(self, mam):
+        """
+
+        @param mam: The MAM <query/> request.
+        @type mam: L{MAMQueryReques<wokkel.mam.MAMRequest>}
+
+        @return: The RSM answer.
+        @rtype: L{RSMResponse<wokkel.rsm.RSMResponse>}
+        """
+
+    def onPrefsGetRequest(self, requestor):
+        """
+
+        @param requestor: JID of the requestor.
+        @type requestor: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: The current settings.
+        @rtype: L{wokkel.mam.MAMPrefs}
+        """
+
+    def onPrefsSetRequest(self, prefs, requestor):
+        """
+
+        @param prefs: The new settings to set.
+        @type prefs: L{wokkel.mam.MAMPrefs}
+
+        @param requestor: JID of the requestor.
+        @type requestor: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @return: The new current settings.
+        @rtype: L{wokkel.mam.MAMPrefs}
+        """
+
+class IMAMService(Interface):
+    """
+    Interface for XMPP MAM service.
+    """
+
+    def addFilter(self, field):
+        """
+        Add a new filter for querying MAM archive.
+
+        @param field: data form field of the filter
+        @type field: L{Form<wokkel.data_form.Field>}
+        """
+
+
+class MAMService(subprotocols.XMPPHandler, subprotocols.IQHandlerMixin):
+    """
+    Protocol implementation for a MAM service.
+
+    This handler waits for XMPP Ping requests and sends a response.
+    """
+    implements(IMAMService, disco.IDisco)
+
+    _request_class = MAMRequest
+
+    iqHandlers = {FIELDS_REQUEST: '_onFieldsRequest',
+                  ARCHIVE_REQUEST: '_onArchiveRequest',
+                  PREFS_GET_REQUEST: '_onPrefsGetRequest',
+                  PREFS_SET_REQUEST: '_onPrefsSetRequest'
+                  }
+
+    _legacyFilters = {'start': {'fieldType': 'text-single',
+                                'var': 'start',
+                                'label': 'Starting time',
+                                'desc': 'Starting time a the result period.',
+                                },
+                      'end': {'fieldType': 'text-single',
+                              'var': 'end',
+                              'label': 'Ending time',
+                              'desc': 'Ending time of the result period.',
+                              },
+                      'with': {'fieldType': 'jid-single',
+                               'var': 'with',
+                               'label': 'Entity',
+                               'desc': 'Entity against which to match message.',
+                               },
+                      }
+
+    def __init__(self, resource):
+        """
+        @param resource: instance implementing IMAMResource
+        @type resource: L{object}
+        """
+        self.resource = resource
+        self.extra_fields = {}
+
+    def connectionInitialized(self):
+        """
+        Called when the XML stream has been initialized.
+
+        This sets up an observer for incoming ping requests.
+        """
+        self.xmlstream.addObserver(FIELDS_REQUEST, self.handleRequest)
+        self.xmlstream.addObserver(ARCHIVE_REQUEST, self.handleRequest)
+        self.xmlstream.addObserver(PREFS_GET_REQUEST, self.handleRequest)
+        self.xmlstream.addObserver(PREFS_SET_REQUEST, self.handleRequest)
+
+    def addFilter(self, field):
+        """
+        Add a new filter for querying MAM archive.
+
+        @param field: data form field of the filter
+        @type field: L{Form<wokkel.data_form.Field>}
+        """
+        self.extra_fields[field.var] = field
+
+    def _onFieldsRequest(self, iq):
+        """
+        Called when a fields request has been received.
+
+        This immediately replies with a result response.
+        """
+        iq.handled = True
+        query = domish.Element((NS_MAM, 'query'))
+        query.addChild(buildForm(extra_fields=self.extra_fields).toElement(), formType='form')
+        return query
+
+    def _onArchiveRequest(self, iq):
+        """
+        Called when a message archive request has been received.
+
+        This replies with the list of archived message and the <iq> result
+        @return: A tuple with list of message data (id, element, data) and RSM element
+        @rtype: C{tuple}
+        """
+        iq.handled = True
+        mam_ = self._request_class.fromElement(iq)
+
+        # remove unsupported filters
+        unsupported_fields = []
+        if mam_.form:
+            for key, field in mam_.form.fields.iteritems():
+                if key not in self._legacyFilters and key not in self.extra_fields:
+                    log.msg('Ignored unsupported MAM filter: %s' % field)
+                    unsupported_fields.append(key)
+        for key in unsupported_fields:
+            del mam_.form.fields[key]
+
+        def forwardMessage(id_, elt, date):
+            msg = domish.Element((None, 'message'))
+            msg['to'] = iq['from']
+            result = msg.addElement((NS_MAM, 'result'))
+            if mam_.query_id is not None:
+                result['queryid'] = mam_.query_id
+            result['id'] = id_
+            forward = result.addElement((NS_FORWARD, 'forwarded'))
+            forward.addChild(delay.Delay(date).toElement())
+            forward.addChild(elt)
+            self.xmlstream.send(msg)
+
+        def cb(result):
+            msg_data, rsm_elt = result
+            for data in msg_data:
+                forwardMessage(*data)
+
+            fin_elt = domish.Element((NS_MAM, 'fin'))
+
+            if rsm_elt is not None:
+                fin_elt.addChild(rsm_elt)
+            return fin_elt
+
+        d = defer.maybeDeferred(self.resource.onArchiveRequest, mam_)
+        d.addCallback(cb)
+        return d
+
+    def _onPrefsGetRequest(self, iq):
+        """
+        Called when a prefs get request has been received.
+
+        This immediately replies with a result response.
+        """
+        iq.handled = True
+        requestor = jid.JID(iq['from'])
+
+        def cb(prefs):
+            return prefs.toElement()
+
+        d = self.resource.onPrefsGetRequest(requestor).addCallback(cb)
+        return d
+
+    def _onPrefsSetRequest(self, iq):
+        """
+        Called when a prefs get request has been received.
+
+        This immediately replies with a result response.
+        """
+        iq.handled = True
+
+        prefs = MAMPrefs.fromElement(iq.prefs)
+        requestor = jid.JID(iq['from'])
+
+        def cb(prefs):
+            return prefs.toElement()
+
+        d = self.resource.onPrefsSetRequest(prefs, requestor).addCallback(cb)
+        return d
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        if nodeIdentifier:
+            return []
+        return [disco.DiscoFeature(NS_MAM)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        return []
+
+
+def datetime2utc(datetime_obj):
+    """Convert a datetime to a XEP-0082 compliant UTC datetime.
+
+    @param datetime_obj: Offset-aware timestamp to convert.
+    @type datetime_obj: L{datetime<datetime.datetime>}
+
+    @return: The datetime converted to UTC.
+    @rtype: C{unicode}
+    """
+    stampFormat = '%Y-%m-%dT%H:%M:%SZ'
+    return datetime_obj.astimezone(tz.tzutc()).strftime(stampFormat)
+
+
+def buildForm(start=None, end=None, with_jid=None, extra_fields=None, formType='submit'):
+    """Prepare a Data Form for MAM.
+
+    @param start: Offset-aware timestamp to filter out older messages.
+    @type start: L{datetime<datetime.datetime>}
+
+    @param end: Offset-aware timestamp to filter out later messages.
+    @type end: L{datetime<datetime.datetime>}
+
+    @param with_jid: JID against which to match messages.
+    @type with_jid: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+    @param extra_fields: list of extra data form fields that are not defined by the
+        specification.
+    @type: C{list}
+
+    @param formType: The type of the Data Form ('submit' or 'form').
+    @type formType: C{unicode}
+
+    @return: XEP-0004 Data Form object.
+    @rtype: L{Form<wokkel.data_form.Form>}
+    """
+    form = data_form.Form(formType, formNamespace=NS_MAM)
+
+    if formType == 'form':
+        for kwargs in MAMService._legacyFilters.values():
+            form.addField(data_form.Field(**kwargs))
+    elif formType == 'submit':
+        if start:
+            form.addField(data_form.Field(var='start', value=datetime2utc(start)))
+        if end:
+            form.addField(data_form.Field(var='end', value=datetime2utc(end)))
+        if with_jid:
+            form.addField(data_form.Field(fieldType='jid-single', var='with', value=with_jid.full()))
+
+    if extra_fields is not None:
+        for field in extra_fields:
+            form.addField(field)
+
+    return form
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_tmp/wokkel/pubsub.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,1717 @@
+# -*- coding: utf-8 -*-
+# -*- test-case-name: wokkel.test.test_pubsub -*-
+#
+# SàT adaptation for wokkel.pubsub
+# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
+# Copyright (c) 2003-2012 Ralph Meijer.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# --
+
+# This program is based on wokkel (https://wokkel.ik.nu/),
+# originaly written by Ralph Meijer
+# It is sublicensed under AGPL v3 (or any later version) as allowed by the original
+# license.
+
+# --
+
+# Here is a copy of the original license:
+
+# Copyright (c) 2003-2012 Ralph Meijer.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""
+XMPP publish-subscribe protocol.
+
+This protocol is specified in
+U{XEP-0060<http://xmpp.org/extensions/xep-0060.html>}.
+"""
+
+from zope.interface import implements
+
+from twisted.internet import defer
+from twisted.python import log
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+
+from wokkel import disco, data_form, generic, shim
+from wokkel.compat import IQ
+from wokkel.subprotocols import IQHandlerMixin, XMPPHandler
+from wokkel.iwokkel import IPubSubClient, IPubSubService, IPubSubResource
+
+# Iq get and set XPath queries
+IQ_GET = '/iq[@type="get"]'
+IQ_SET = '/iq[@type="set"]'
+
+# Publish-subscribe namespaces
+NS_PUBSUB = 'http://jabber.org/protocol/pubsub'
+NS_PUBSUB_EVENT = NS_PUBSUB + '#event'
+NS_PUBSUB_ERRORS = NS_PUBSUB + '#errors'
+NS_PUBSUB_OWNER = NS_PUBSUB + "#owner"
+NS_PUBSUB_NODE_CONFIG = NS_PUBSUB + "#node_config"
+NS_PUBSUB_META_DATA = NS_PUBSUB + "#meta-data"
+NS_PUBSUB_SUBSCRIBE_OPTIONS = NS_PUBSUB + "#subscribe_options"
+
+# XPath to match pubsub requests
+PUBSUB_REQUEST = '/iq[@type="get" or @type="set"]/' + \
+                    'pubsub[@xmlns="' + NS_PUBSUB + '" or ' + \
+                           '@xmlns="' + NS_PUBSUB_OWNER + '"]'
+
+BOOL_TRUE = ('1','true')
+BOOL_FALSE = ('0','false')
+
+class SubscriptionPending(Exception):
+    """
+    Raised when the requested subscription is pending acceptance.
+    """
+
+
+
+class SubscriptionUnconfigured(Exception):
+    """
+    Raised when the requested subscription needs to be configured before
+    becoming active.
+    """
+
+
+
+class PubSubError(error.StanzaError):
+    """
+    Exception with publish-subscribe specific condition.
+    """
+    def __init__(self, condition, pubsubCondition, feature=None, text=None):
+        appCondition = domish.Element((NS_PUBSUB_ERRORS, pubsubCondition))
+        if feature:
+            appCondition['feature'] = feature
+        error.StanzaError.__init__(self, condition,
+                                         text=text,
+                                         appCondition=appCondition)
+
+
+
+class BadRequest(error.StanzaError):
+    """
+    Bad request stanza error.
+    """
+    def __init__(self, pubsubCondition=None, text=None):
+        if pubsubCondition:
+            appCondition = domish.Element((NS_PUBSUB_ERRORS, pubsubCondition))
+        else:
+            appCondition = None
+        error.StanzaError.__init__(self, 'bad-request',
+                                         text=text,
+                                         appCondition=appCondition)
+
+
+
+class Unsupported(PubSubError):
+    def __init__(self, feature, text=None):
+        self.feature = feature
+        PubSubError.__init__(self, 'feature-not-implemented',
+                                   'unsupported',
+                                   feature,
+                                   text)
+
+    def __str__(self):
+        message = PubSubError.__str__(self)
+        message += ', feature %r' % self.feature
+        return message
+
+
+class Subscription(object):
+    """
+    A subscription to a node.
+
+    @ivar nodeIdentifier: The identifier of the node subscribed to.  The root
+        node is denoted by C{None}.
+    @type nodeIdentifier: C{unicode}
+
+    @ivar subscriber: The subscribing entity.
+    @type subscriber: L{jid.JID}
+
+    @ivar state: The subscription state. One of C{'subscribed'}, C{'pending'},
+                 C{'unconfigured'}, C{'none'}.
+    @type state: C{unicode}
+
+    @ivar options: Optional list of subscription options.
+    @type options: C{dict}
+
+    @ivar subscriptionIdentifier: Optional subscription identifier.
+    @type subscriptionIdentifier: C{unicode}
+    """
+
+    def __init__(self, nodeIdentifier, subscriber, state, options=None,
+                       subscriptionIdentifier=None):
+        self.nodeIdentifier = nodeIdentifier
+        self.subscriber = subscriber
+        self.state = state
+        self.options = options or {}
+        self.subscriptionIdentifier = subscriptionIdentifier
+
+
+    @staticmethod
+    def fromElement(element):
+        return Subscription(
+                element.getAttribute('node'),
+                jid.JID(element.getAttribute('jid')),
+                element.getAttribute('subscription'),
+                subscriptionIdentifier=element.getAttribute('subid'))
+
+
+    def toElement(self, defaultUri=None):
+        """
+        Return the DOM representation of this subscription.
+
+        @rtype: L{domish.Element}
+        """
+        element = domish.Element((defaultUri, 'subscription'))
+        if self.nodeIdentifier:
+            element['node'] = self.nodeIdentifier
+        element['jid'] = unicode(self.subscriber)
+        element['subscription'] = self.state
+        if self.subscriptionIdentifier:
+            element['subid'] = self.subscriptionIdentifier
+        return element
+
+
+
+class Item(domish.Element):
+    """
+    Publish subscribe item.
+
+    This behaves like an object providing L{domish.IElement}.
+
+    Item payload can be added using C{addChild} or C{addRawXml}, or using the
+    C{payload} keyword argument to C{__init__}.
+    """
+
+    def __init__(self, id=None, payload=None):
+        """
+        @param id: optional item identifier
+        @type id: C{unicode}
+        @param payload: optional item payload. Either as a domish element, or
+                        as serialized XML.
+        @type payload: object providing L{domish.IElement} or C{unicode}.
+        """
+
+        domish.Element.__init__(self, (None, 'item'))
+        if id is not None:
+            self['id'] = id
+        if payload is not None:
+            if isinstance(payload, basestring):
+                self.addRawXml(payload)
+            else:
+                self.addChild(payload)
+
+
+
+class PubSubRequest(generic.Stanza):
+    """
+    A publish-subscribe request.
+
+    The set of instance variables used depends on the type of request. If
+    a variable is not applicable or not passed in the request, its value is
+    C{None}.
+
+    @ivar verb: The type of publish-subscribe request. See C{_requestVerbMap}.
+    @type verb: C{str}.
+
+    @ivar affiliations: Affiliations to be modified.
+    @type affiliations: C{set}
+
+    @ivar items: The items to be published, as L{domish.Element}s.
+    @type items: C{list}
+
+    @ivar itemIdentifiers: Identifiers of the items to be retrieved or
+                           retracted.
+    @type itemIdentifiers: C{set}
+
+    @ivar maxItems: Maximum number of items to retrieve.
+    @type maxItems: C{int}.
+
+    @ivar nodeIdentifier: Identifier of the node the request is about.
+    @type nodeIdentifier: C{unicode}
+
+    @ivar nodeType: The type of node that should be created, or for which the
+                    configuration is retrieved. C{'leaf'} or C{'collection'}.
+    @type nodeType: C{str}
+
+    @ivar options: Configurations options for nodes, subscriptions and publish
+                   requests.
+    @type options: L{data_form.Form}
+
+    @ivar subscriber: The subscribing entity.
+    @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+    @ivar subscriptionIdentifier: Identifier for a specific subscription.
+    @type subscriptionIdentifier: C{unicode}
+
+    @ivar subscriptions: Subscriptions to be modified, as a set of
+        L{Subscription}.
+    @type subscriptions: C{set}
+
+    @ivar affiliations: Affiliations to be modified, as a dictionary of entity
+        (L{JID<twisted.words.protocols.jabber.jid.JID>} to affiliation
+        (C{unicode}).
+    @type affiliations: C{dict}
+    """
+
+    verb = None
+
+    items = None
+    itemIdentifiers = None
+    maxItems = None
+    nodeIdentifier = None
+    nodeType = None
+    options = None
+    subscriber = None
+    subscriptionIdentifier = None
+    subscriptions = None
+    affiliations = None
+    notify = None
+
+    # Map request iq type and subelement name to request verb
+    _requestVerbMap = {
+        ('set', NS_PUBSUB, 'publish'): 'publish',
+        ('set', NS_PUBSUB, 'subscribe'): 'subscribe',
+        ('set', NS_PUBSUB, 'unsubscribe'): 'unsubscribe',
+        ('get', NS_PUBSUB, 'options'): 'optionsGet',
+        ('set', NS_PUBSUB, 'options'): 'optionsSet',
+        ('get', NS_PUBSUB, 'subscriptions'): 'subscriptions',
+        ('get', NS_PUBSUB, 'affiliations'): 'affiliations',
+        ('set', NS_PUBSUB, 'create'): 'create',
+        ('get', NS_PUBSUB_OWNER, 'default'): 'default',
+        ('get', NS_PUBSUB_OWNER, 'configure'): 'configureGet',
+        ('set', NS_PUBSUB_OWNER, 'configure'): 'configureSet',
+        ('get', NS_PUBSUB, 'items'): 'items',
+        ('set', NS_PUBSUB, 'retract'): 'retract',
+        ('set', NS_PUBSUB_OWNER, 'purge'): 'purge',
+        ('set', NS_PUBSUB_OWNER, 'delete'): 'delete',
+        ('get', NS_PUBSUB_OWNER, 'affiliations'): 'affiliationsGet',
+        ('set', NS_PUBSUB_OWNER, 'affiliations'): 'affiliationsSet',
+        ('get', NS_PUBSUB_OWNER, 'subscriptions'): 'subscriptionsGet',
+        ('set', NS_PUBSUB_OWNER, 'subscriptions'): 'subscriptionsSet',
+    }
+
+    # Map request verb to request iq type and subelement name
+    _verbRequestMap = dict(((v, k) for k, v in _requestVerbMap.iteritems()))
+
+    # Map request verb to parameter handler names
+    _parameters = {
+        'publish': ['node', 'items'],
+        'subscribe': ['nodeOrEmpty', 'jid', 'optionsWithSubscribe'],
+        'unsubscribe': ['nodeOrEmpty', 'jid', 'subidOrNone'],
+        'optionsGet': ['nodeOrEmpty', 'jid', 'subidOrNone'],
+        'optionsSet': ['nodeOrEmpty', 'jid', 'options', 'subidOrNone'],
+        'subscriptions': ['nodeOrEmpty'],
+        'affiliations': ['nodeOrNone'],
+        'create': ['nodeOrNone', 'configureOrNone'],
+        'default': ['default'],
+        'configureGet': ['nodeOrEmpty'],
+        'configureSet': ['nodeOrEmpty', 'configureOrNone'],
+        'items': ['node', 'maxItems', 'itemIdentifiers', 'subidOrNone'],
+        'retract': ['node', 'notify', 'itemIdentifiers'],
+        'purge': ['node'],
+        'delete': ['node'],
+        'affiliationsGet': ['node'],
+        'affiliationsSet': ['node', 'affiliations'],
+        'subscriptionsGet': ['node'],
+        'subscriptionsSet': ['node', 'subscriptions'],
+    }
+
+    def __init__(self, verb=None):
+        self.verb = verb
+
+
+    def _parse_node(self, verbElement):
+        """
+        Parse the required node identifier out of the verbElement.
+        """
+        try:
+            self.nodeIdentifier = verbElement["node"]
+        except KeyError:
+            raise BadRequest('nodeid-required')
+
+
+    def _render_node(self, verbElement):
+        """
+        Render the required node identifier on the verbElement.
+        """
+        if not self.nodeIdentifier:
+            raise Exception("Node identifier is required")
+
+        verbElement['node'] = self.nodeIdentifier
+
+
+    def _parse_nodeOrEmpty(self, verbElement):
+        """
+        Parse the node identifier out of the verbElement. May be empty.
+        """
+        self.nodeIdentifier = verbElement.getAttribute("node", '')
+
+
+    def _render_nodeOrEmpty(self, verbElement):
+        """
+        Render the node identifier on the verbElement. May be empty.
+        """
+        if self.nodeIdentifier:
+            verbElement['node'] = self.nodeIdentifier
+
+
+    def _parse_nodeOrNone(self, verbElement):
+        """
+        Parse the optional node identifier out of the verbElement.
+        """
+        self.nodeIdentifier = verbElement.getAttribute("node")
+
+
+    def _render_nodeOrNone(self, verbElement):
+        """
+        Render the optional node identifier on the verbElement.
+        """
+        if self.nodeIdentifier:
+            verbElement['node'] = self.nodeIdentifier
+
+
+    def _parse_items(self, verbElement):
+        """
+        Parse items out of the verbElement for publish requests.
+        """
+        self.items = []
+        for element in verbElement.elements():
+            if element.uri == NS_PUBSUB and element.name == 'item':
+                self.items.append(element)
+
+
+    def _render_items(self, verbElement):
+        """
+        Render items into the verbElement for publish requests.
+        """
+        if self.items:
+            for item in self.items:
+                item.uri = NS_PUBSUB
+                verbElement.addChild(item)
+
+
+    def _parse_jid(self, verbElement):
+        """
+        Parse subscriber out of the verbElement for un-/subscribe requests.
+        """
+        try:
+            self.subscriber = jid.internJID(verbElement["jid"])
+        except KeyError:
+            raise BadRequest('jid-required')
+
+
+    def _render_jid(self, verbElement):
+        """
+        Render subscriber into the verbElement for un-/subscribe requests.
+        """
+        verbElement['jid'] = self.subscriber.full()
+
+
+    def _parse_default(self, verbElement):
+        """
+        Parse node type out of a request for the default node configuration.
+        """
+        form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG)
+        if form is not None and form.formType == 'submit':
+            values = form.getValues()
+            self.nodeType = values.get('pubsub#node_type', 'leaf')
+        else:
+            self.nodeType = 'leaf'
+
+
+    def _parse_configure(self, verbElement):
+        """
+        Parse options out of a request for setting the node configuration.
+        """
+        form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG)
+        if form is not None:
+            if form.formType in ('submit', 'cancel'):
+                self.options = form
+            else:
+                raise BadRequest(text=u"Unexpected form type '%s'" % form.formType)
+        else:
+            raise BadRequest(text="Missing configuration form")
+
+
+    def _parse_configureOrNone(self, verbElement):
+        """
+        Parse optional node configuration form in create request.
+        """
+        for element in verbElement.parent.elements():
+            if element.uri in (NS_PUBSUB, NS_PUBSUB_OWNER) and element.name == 'configure':
+                form = data_form.findForm(element, NS_PUBSUB_NODE_CONFIG)
+                if form is not None:
+                    if form.formType != 'submit':
+                        raise BadRequest(text=u"Unexpected form type '%s'" %
+                                              form.formType)
+                else:
+                    form = data_form.Form('submit',
+                                          formNamespace=NS_PUBSUB_NODE_CONFIG)
+                self.options = form
+
+
+    def _render_configureOrNone(self, verbElement):
+        """
+        Render optional node configuration form in create request.
+        """
+        if self.options is not None:
+            if verbElement.name == 'configure':
+                configure = verbElement
+            else:
+                configure = verbElement.parent.addElement('configure')
+            configure.addChild(self.options.toElement())
+
+
+    def _parse_itemIdentifiers(self, verbElement):
+        """
+        Parse item identifiers out of items and retract requests.
+        """
+        self.itemIdentifiers = []
+        for element in verbElement.elements():
+            if element.uri == NS_PUBSUB and element.name == 'item':
+                try:
+                    self.itemIdentifiers.append(element["id"])
+                except KeyError:
+                    raise BadRequest()
+
+
+    def _render_itemIdentifiers(self, verbElement):
+        """
+        Render item identifiers into items and retract requests.
+        """
+        if self.itemIdentifiers:
+            for itemIdentifier in self.itemIdentifiers:
+                item = verbElement.addElement('item')
+                item['id'] = itemIdentifier
+
+
+    def _parse_maxItems(self, verbElement):
+        """
+        Parse maximum items out of an items request.
+        """
+        value = verbElement.getAttribute('max_items')
+
+        if value:
+            try:
+                self.maxItems = int(value)
+            except ValueError:
+                raise BadRequest(text="Field max_items requires a positive " +
+                                      "integer value")
+
+
+    def _render_maxItems(self, verbElement):
+        """
+        Render maximum items into an items request.
+        """
+        if self.maxItems:
+            verbElement['max_items'] = unicode(self.maxItems)
+
+
+    def _parse_subidOrNone(self, verbElement):
+        """
+        Parse subscription identifier out of a request.
+        """
+        self.subscriptionIdentifier = verbElement.getAttribute("subid")
+
+
+    def _render_subidOrNone(self, verbElement):
+        """
+        Render subscription identifier into a request.
+        """
+        if self.subscriptionIdentifier:
+            verbElement['subid'] = self.subscriptionIdentifier
+
+
+    def _parse_options(self, verbElement):
+        """
+        Parse options form out of a subscription options request.
+        """
+        form = data_form.findForm(verbElement, NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        if form is not None:
+            if form.formType in ('submit', 'cancel'):
+                self.options = form
+            else:
+                raise BadRequest(text=u"Unexpected form type '%s'" % form.formType)
+        else:
+            raise BadRequest(text="Missing options form")
+
+
+    def _render_options(self, verbElement):
+        verbElement.addChild(self.options.toElement())
+
+
+    def _parse_optionsWithSubscribe(self, verbElement):
+        for element in verbElement.parent.elements():
+            if element.name == 'options' and element.uri == NS_PUBSUB:
+                form = data_form.findForm(element,
+                                          NS_PUBSUB_SUBSCRIBE_OPTIONS)
+                if form is not None:
+                    if form.formType != 'submit':
+                        raise BadRequest(text=u"Unexpected form type '%s'" %
+                                              form.formType)
+                else:
+                    form = data_form.Form('submit',
+                                          formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
+                self.options = form
+
+
+    def _render_optionsWithSubscribe(self, verbElement):
+        if self.options is not None:
+            optionsElement = verbElement.parent.addElement('options')
+            self._render_options(optionsElement)
+
+
+    def _parse_affiliations(self, verbElement):
+        self.affiliations = {}
+        for element in verbElement.elements():
+            if (element.uri == NS_PUBSUB_OWNER and
+                element.name == 'affiliation'):
+                try:
+                    entity = jid.internJID(element['jid']).userhostJID()
+                except KeyError:
+                    raise BadRequest(text='Missing jid attribute')
+
+                if entity in self.affiliations:
+                    raise BadRequest(text='Multiple affiliations for an entity')
+
+                try:
+                    affiliation = element['affiliation']
+                except KeyError:
+                    raise BadRequest(text='Missing affiliation attribute')
+
+                self.affiliations[entity] = affiliation
+
+
+    def _render_affiliations(self, verbElement):
+        for entity, affiliation in self.affiliations.iteritems():
+            affiliationElement = verbElement.addElement((NS_PUBSUB_OWNER, 'affiliation'))
+            affiliationElement['jid'] = entity.full()
+            affiliationElement['affiliation'] = affiliation
+
+
+    def _parse_subscriptions(self, verbElement):
+        self.subscriptions = set()
+        seen_entities = set()
+        for element in verbElement.elements():
+            if (element.uri == NS_PUBSUB_OWNER and
+                element.name == 'subscription'):
+                try:
+                    subscriber = jid.internJID(element['jid']).userhostJID()
+                except KeyError:
+                    raise BadRequest(text='Missing jid attribute')
+
+                if subscriber in seen_entities:
+                    raise BadRequest(text='Multiple subscriptions for an subscriber')
+                seen_entities.add(subscriber)
+
+                try:
+                    state = element['subscription']
+                except KeyError:
+                    # §8.8.2.1 says that value MUST NOT be changed
+                    # if subscription is missing
+                    continue
+
+                self.subscriptions.add(Subscription(self.nodeIdentifier,
+                                                    subscriber,
+                                                    state))
+
+
+    def _render_subscriptions(self, verbElement):
+        for subscription in self.subscriptions:
+            subscriptionElement = verbElement.addElement((NS_PUBSUB_OWNER, 'subscription'))
+            subscriptionElement['jid'] = subscription.subscriber.full()
+            subscriptionElement['subscription'] = subscription.state
+
+
+    def _parse_notify(self, verbElement):
+        value = verbElement.getAttribute('notify')
+
+        if value:
+            if value in BOOL_TRUE:
+                self.notify = True
+            elif value in BOOL_FALSE:
+                self.notify = False
+            else:
+                raise BadRequest(text="Field notify must be a boolean value")
+
+
+    def _render_notify(self, verbElement):
+        if self.notify is not None:
+            verbElement['notify'] = "true" if self.notify else "false"
+
+
+    def parseElement(self, element):
+        """
+        Parse the publish-subscribe verb and parameters out of a request.
+        """
+        generic.Stanza.parseElement(self, element)
+
+        verbs = []
+        verbElements = []
+        for child in element.pubsub.elements():
+            key = (self.stanzaType, child.uri, child.name)
+            try:
+                verb = self._requestVerbMap[key]
+            except KeyError:
+                continue
+
+            verbs.append(verb)
+            verbElements.append(child)
+
+        if not verbs:
+            raise NotImplementedError()
+
+        if len(verbs) > 1:
+            if 'optionsSet' in verbs and 'subscribe' in verbs:
+                self.verb = 'subscribe'
+                verbElement = verbElements[verbs.index('subscribe')]
+            else:
+                raise NotImplementedError()
+        else:
+            self.verb = verbs[0]
+            verbElement = verbElements[0]
+
+        for parameter in self._parameters[self.verb]:
+            getattr(self, '_parse_%s' % parameter)(verbElement)
+
+
+
+    def send(self, xs):
+        """
+        Send this request to its recipient.
+
+        This renders all of the relevant parameters for this specific
+        requests into an L{IQ}, and invoke its C{send} method.
+        This returns a deferred that fires upon reception of a response. See
+        L{IQ} for details.
+
+        @param xs: The XML stream to send the request on.
+        @type xs: L{twisted.words.protocols.jabber.xmlstream.XmlStream}
+        @rtype: L{defer.Deferred}.
+        """
+
+        try:
+            (self.stanzaType,
+             childURI,
+             childName) = self._verbRequestMap[self.verb]
+        except KeyError:
+            raise NotImplementedError()
+
+        iq = IQ(xs, self.stanzaType)
+        iq.addElement((childURI, 'pubsub'))
+        verbElement = iq.pubsub.addElement(childName)
+
+        if self.sender:
+            iq['from'] = self.sender.full()
+        if self.recipient:
+            iq['to'] = self.recipient.full()
+
+        for parameter in self._parameters[self.verb]:
+            getattr(self, '_render_%s' % parameter)(verbElement)
+
+        return iq.send()
+
+
+
+class PubSubEvent(object):
+    """
+    A publish subscribe event.
+
+    @param sender: The entity from which the notification was received.
+    @type sender: L{jid.JID}
+    @param recipient: The entity to which the notification was sent.
+    @type recipient: L{wokkel.pubsub.ItemsEvent}
+    @param nodeIdentifier: Identifier of the node the event pertains to.
+    @type nodeIdentifier: C{unicode}
+    @param headers: SHIM headers, see L{wokkel.shim.extractHeaders}.
+    @type headers: C{dict}
+    """
+
+    def __init__(self, sender, recipient, nodeIdentifier, headers):
+        self.sender = sender
+        self.recipient = recipient
+        self.nodeIdentifier = nodeIdentifier
+        self.headers = headers
+
+
+
+class ItemsEvent(PubSubEvent):
+    """
+    A publish-subscribe event that signifies new, updated and retracted items.
+
+    @param items: List of received items as domish elements.
+    @type items: C{list} of L{domish.Element}
+    """
+
+    def __init__(self, sender, recipient, nodeIdentifier, items, headers):
+        PubSubEvent.__init__(self, sender, recipient, nodeIdentifier, headers)
+        self.items = items
+
+
+
+class DeleteEvent(PubSubEvent):
+    """
+    A publish-subscribe event that signifies the deletion of a node.
+    """
+
+    redirectURI = None
+
+
+
+class PurgeEvent(PubSubEvent):
+    """
+    A publish-subscribe event that signifies the purging of a node.
+    """
+
+
+
+class PubSubClient(XMPPHandler):
+    """
+    Publish subscribe client protocol.
+    """
+    implements(IPubSubClient)
+
+    _request_class = PubSubRequest
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver('/message/event[@xmlns="%s"]' %
+                                   NS_PUBSUB_EVENT, self._onEvent)
+
+
+    def _onEvent(self, message):
+        if message.getAttribute('type') == 'error':
+            return
+
+        try:
+            sender = jid.JID(message["from"])
+            recipient = jid.JID(message["to"])
+        except KeyError:
+            return
+
+        actionElement = None
+        for element in message.event.elements():
+            if element.uri == NS_PUBSUB_EVENT:
+                actionElement = element
+
+        if not actionElement:
+            return
+
+        eventHandler = getattr(self, "_onEvent_%s" % actionElement.name, None)
+
+        if eventHandler:
+            headers = shim.extractHeaders(message)
+            eventHandler(sender, recipient, actionElement, headers)
+            message.handled = True
+
+
+    def _onEvent_items(self, sender, recipient, action, headers):
+        nodeIdentifier = action["node"]
+
+        items = [element for element in action.elements()
+                         if element.name in ('item', 'retract')]
+
+        event = ItemsEvent(sender, recipient, nodeIdentifier, items, headers)
+        self.itemsReceived(event)
+
+
+    def _onEvent_delete(self, sender, recipient, action, headers):
+        nodeIdentifier = action["node"]
+        event = DeleteEvent(sender, recipient, nodeIdentifier, headers)
+        if action.redirect:
+            event.redirectURI = action.redirect.getAttribute('uri')
+        self.deleteReceived(event)
+
+
+    def _onEvent_purge(self, sender, recipient, action, headers):
+        nodeIdentifier = action["node"]
+        event = PurgeEvent(sender, recipient, nodeIdentifier, headers)
+        self.purgeReceived(event)
+
+
+    def itemsReceived(self, event):
+        pass
+
+
+    def deleteReceived(self, event):
+        pass
+
+
+    def purgeReceived(self, event):
+        pass
+
+
+    def createNode(self, service, nodeIdentifier=None, options=None,
+                         sender=None):
+        """
+        Create a publish subscribe node.
+
+        @param service: The publish subscribe service to create the node at.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: Optional suggestion for the id of the node.
+        @type nodeIdentifier: C{unicode}
+        @param options: Optional node configuration options.
+        @type options: C{dict}
+        """
+        request = self._request_class('create')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.sender = sender
+
+        if options:
+            form = data_form.Form(formType='submit',
+                                  formNamespace=NS_PUBSUB_NODE_CONFIG)
+            form.makeFields(options)
+            request.options = form
+
+        def cb(iq):
+            try:
+                new_node = iq.pubsub.create["node"]
+            except AttributeError:
+                # the suggested node identifier was accepted
+                new_node = nodeIdentifier
+            return new_node
+
+        d = request.send(self.xmlstream)
+        d.addCallback(cb)
+        return d
+
+
+    def deleteNode(self, service, nodeIdentifier, sender=None):
+        """
+        Delete a publish subscribe node.
+
+        @param service: The publish subscribe service to delete the node from.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+        """
+        request = self._request_class('delete')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.sender = sender
+        return request.send(self.xmlstream)
+
+
+    def subscribe(self, service, nodeIdentifier, subscriber,
+                        options=None, sender=None):
+        """
+        Subscribe to a publish subscribe node.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param subscriber: The entity to subscribe to the node. This entity
+            will get notifications of new published items.
+        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param options: Subscription options.
+        @type options: C{dict}
+
+        @return: Deferred that fires with L{Subscription} or errbacks with
+            L{SubscriptionPending} or L{SubscriptionUnconfigured}.
+        @rtype: L{defer.Deferred}
+        """
+        request = self._request_class('subscribe')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.subscriber = subscriber
+        request.sender = sender
+
+        if options:
+            form = data_form.Form(formType='submit',
+                                  formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
+            form.makeFields(options)
+            request.options = form
+
+        def cb(iq):
+            subscription = Subscription.fromElement(iq.pubsub.subscription)
+
+            if subscription.state == 'pending':
+                raise SubscriptionPending()
+            elif subscription.state == 'unconfigured':
+                raise SubscriptionUnconfigured()
+            else:
+                # we assume subscription == 'subscribed'
+                # any other value would be invalid, but that should have
+                # yielded a stanza error.
+                return subscription
+
+        d = request.send(self.xmlstream)
+        d.addCallback(cb)
+        return d
+
+
+    def unsubscribe(self, service, nodeIdentifier, subscriber,
+                          subscriptionIdentifier=None, sender=None):
+        """
+        Unsubscribe from a publish subscribe node.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param subscriber: The entity to unsubscribe from the node.
+        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param subscriptionIdentifier: Optional subscription identifier.
+        @type subscriptionIdentifier: C{unicode}
+        """
+        request = self._request_class('unsubscribe')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.subscriber = subscriber
+        request.subscriptionIdentifier = subscriptionIdentifier
+        request.sender = sender
+        return request.send(self.xmlstream)
+
+
+    def publish(self, service, nodeIdentifier, items=None, sender=None):
+        """
+        Publish to a publish subscribe node.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+        @param items: Optional list of L{Item}s to publish.
+        @type items: C{list}
+        """
+        request = self._request_class('publish')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.items = items
+        request.sender = sender
+        return request.send(self.xmlstream)
+
+
+    def items(self, service, nodeIdentifier, maxItems=None, itemIdentifiers=None,
+              subscriptionIdentifier=None, sender=None):
+        """
+        Retrieve previously published items from a publish subscribe node.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param maxItems: Optional limit on the number of retrieved items.
+        @type maxItems: C{int}
+
+        @param itemIdentifiers: Identifiers of the items to be retrieved.
+        @type itemIdentifiers: C{set}
+
+        @param subscriptionIdentifier: Optional subscription identifier. In
+            case the node has been subscribed to multiple times, this narrows
+            the results to the specific subscription.
+        @type subscriptionIdentifier: C{unicode}
+        """
+        request = self._request_class('items')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        if maxItems:
+            request.maxItems = str(int(maxItems))
+        request.subscriptionIdentifier = subscriptionIdentifier
+        request.sender = sender
+        request.itemIdentifiers = itemIdentifiers
+
+        def cb(iq):
+            items = []
+            for element in iq.pubsub.items.elements():
+                if element.uri == NS_PUBSUB and element.name == 'item':
+                    items.append(element)
+            return items
+
+        d = request.send(self.xmlstream)
+        d.addCallback(cb)
+        return d
+
+    def retractItems(self, service, nodeIdentifier, itemIdentifiers, notify=None, sender=None):
+        """
+        Retract items from a publish subscribe node.
+
+        @param service: The publish subscribe service to delete the node from.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+        @param itemIdentifiers: Identifiers of the items to be retracted.
+        @type itemIdentifiers: C{set}
+        @param notify: True if notification is required
+        @type notify: C{unicode}
+        """
+        request = self._request_class('retract')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.itemIdentifiers = itemIdentifiers
+        request.notify = notify
+        request.sender = sender
+        return request.send(self.xmlstream)
+
+    def getOptions(self, service, nodeIdentifier, subscriber,
+                         subscriptionIdentifier=None, sender=None):
+        """
+        Get subscription options.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param subscriber: The entity subscribed to the node.
+        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param subscriptionIdentifier: Optional subscription identifier.
+        @type subscriptionIdentifier: C{unicode}
+
+        @rtype: L{data_form.Form}
+        """
+        request = self._request_class('optionsGet')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.subscriber = subscriber
+        request.subscriptionIdentifier = subscriptionIdentifier
+        request.sender = sender
+
+        def cb(iq):
+            form = data_form.findForm(iq.pubsub.options,
+                                      NS_PUBSUB_SUBSCRIBE_OPTIONS)
+            form.typeCheck()
+            return form
+
+        d = request.send(self.xmlstream)
+        d.addCallback(cb)
+        return d
+
+
+    def setOptions(self, service, nodeIdentifier, subscriber,
+                         options, subscriptionIdentifier=None, sender=None):
+        """
+        Set subscription options.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param subscriber: The entity subscribed to the node.
+        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param options: Subscription options.
+        @type options: C{dict}.
+
+        @param subscriptionIdentifier: Optional subscription identifier.
+        @type subscriptionIdentifier: C{unicode}
+        """
+        request = self._request_class('optionsSet')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.subscriber = subscriber
+        request.subscriptionIdentifier = subscriptionIdentifier
+        request.sender = sender
+
+        form = data_form.Form(formType='submit',
+                              formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        form.makeFields(options)
+        request.options = form
+
+        d = request.send(self.xmlstream)
+        return d
+
+
+
+class PubSubService(XMPPHandler, IQHandlerMixin):
+    """
+    Protocol implementation for a XMPP Publish Subscribe Service.
+
+    The word Service here is used as taken from the Publish Subscribe
+    specification. It is the party responsible for keeping nodes and their
+    subscriptions, and sending out notifications.
+
+    Methods from the L{IPubSubService} interface that are called as a result
+    of an XMPP request may raise exceptions. Alternatively the deferred
+    returned by these methods may have their errback called. These are handled
+    as follows:
+
+     - If the exception is an instance of L{error.StanzaError}, an error
+       response iq is returned.
+     - Any other exception is reported using L{log.msg}. An error response
+       with the condition C{internal-server-error} is returned.
+
+    The default implementation of said methods raises an L{Unsupported}
+    exception and are meant to be overridden.
+
+    @ivar discoIdentity: Service discovery identity as a dictionary with
+                         keys C{'category'}, C{'type'} and C{'name'}.
+    @ivar pubSubFeatures: List of supported publish-subscribe features for
+                          service discovery, as C{str}.
+    @type pubSubFeatures: C{list} or C{None}
+    """
+
+    implements(IPubSubService, disco.IDisco)
+
+    iqHandlers = {
+            '/*': '_onPubSubRequest',
+            }
+
+    _legacyHandlers = {
+        'publish': ('publish', ['sender', 'recipient',
+                                'nodeIdentifier', 'items']),
+        'subscribe': ('subscribe', ['sender', 'recipient',
+                                    'nodeIdentifier', 'subscriber']),
+        'unsubscribe': ('unsubscribe', ['sender', 'recipient',
+                                        'nodeIdentifier', 'subscriber']),
+        'subscriptions': ('subscriptions', ['sender', 'recipient']),
+        'affiliations': ('affiliations', ['sender', 'recipient']),
+        'create': ('create', ['sender', 'recipient', 'nodeIdentifier']),
+        'getConfigurationOptions': ('getConfigurationOptions', []),
+        'default': ('getDefaultConfiguration',
+                    ['sender', 'recipient', 'nodeType']),
+        'configureGet': ('getConfiguration', ['sender', 'recipient',
+                                              'nodeIdentifier']),
+        'configureSet': ('setConfiguration', ['sender', 'recipient',
+                                              'nodeIdentifier', 'options']),
+        'items': ('items', ['sender', 'recipient', 'nodeIdentifier',
+                            'maxItems', 'itemIdentifiers']),
+        'retract': ('retract', ['sender', 'recipient', 'nodeIdentifier',
+                                'itemIdentifiers']),
+        'purge': ('purge', ['sender', 'recipient', 'nodeIdentifier']),
+        'delete': ('delete', ['sender', 'recipient', 'nodeIdentifier']),
+    }
+
+    _request_class = PubSubRequest
+
+    hideNodes = False
+
+    def __init__(self, resource=None):
+        self.resource = resource
+        self.discoIdentity = {'category': 'pubsub',
+                              'type': 'service',
+                              'name': 'Generic Publish-Subscribe Service'}
+
+        self.pubSubFeatures = []
+
+
+    def connectionMade(self):
+        self.xmlstream.addObserver(PUBSUB_REQUEST, self.handleRequest)
+
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        def toInfo(nodeInfo):
+            if not nodeInfo:
+                return
+
+            (nodeType, metaData) = nodeInfo['type'], nodeInfo['meta-data']
+            info.append(disco.DiscoIdentity('pubsub', nodeType))
+            if metaData:
+                form = data_form.Form(formType="result",
+                                      formNamespace=NS_PUBSUB_META_DATA)
+                form.addField(
+                        data_form.Field(
+                            var='pubsub#node_type',
+                            value=nodeType,
+                            label='The type of node (collection or leaf)'
+                        )
+                )
+
+                for metaDatum in metaData:
+                    form.addField(data_form.Field.fromDict(metaDatum))
+
+                info.append(form)
+
+            return
+
+        info = []
+
+        request = self._request_class('discoInfo')
+
+        if self.resource is not None:
+            resource = self.resource.locateResource(request)
+            identity = resource.discoIdentity
+            features = resource.features
+            getInfo = resource.getInfo
+        else:
+            category = self.discoIdentity['category']
+            idType = self.discoIdentity['type']
+            name = self.discoIdentity['name']
+            identity = disco.DiscoIdentity(category, idType, name)
+            features = self.pubSubFeatures
+            getInfo = self.getNodeInfo
+
+        if not nodeIdentifier:
+            info.append(identity)
+            info.append(disco.DiscoFeature(disco.NS_DISCO_ITEMS))
+            info.extend([disco.DiscoFeature("%s#%s" % (NS_PUBSUB, feature))
+                         for feature in features])
+
+        d = defer.maybeDeferred(getInfo, requestor, target, nodeIdentifier or '')
+        d.addCallback(toInfo)
+        d.addErrback(log.err)
+        d.addCallback(lambda _: info)
+        return d
+
+
+    def _parseNodes(self, nodes, target):
+        """parse return values of resource.getNodes
+
+        basestring values are used as node
+        tuple are unpacked as node, name
+        """
+        items = []
+        for node in nodes:
+            if isinstance(node, basestring):
+                items.append(disco.DiscoItem(target, node))
+            else:
+                _node, name = node
+                items.append(disco.DiscoItem(target, _node, name))
+        return items
+
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        if self.hideNodes:
+            d = defer.succeed([])
+        elif self.resource is not None:
+            request = self._request_class('discoInfo')
+            resource = self.resource.locateResource(request)
+            d = resource.getNodes(requestor, target, nodeIdentifier)
+        elif nodeIdentifier:
+            d = self.getNodes(requestor, target)
+        else:
+            d = defer.succeed([])
+
+        d.addCallback(self._parseNodes, target)
+        return d
+
+
+    def _onPubSubRequest(self, iq):
+        request = self._request_class.fromElement(iq)
+        if self.resource is not None:
+            resource = self.resource.locateResource(request)
+        else:
+            resource = self
+
+        # Preprocess the request, knowing the handling resource
+        try:
+            preProcessor = getattr(self, '_preProcess_%s' % request.verb)
+        except AttributeError:
+            pass
+        else:
+            request = preProcessor(resource, request)
+            if request is None:
+                return defer.succeed(None)
+
+        # Process the request itself,
+        if resource is not self:
+            try:
+                handler = getattr(resource, request.verb)
+            except AttributeError:
+                text = "Request verb: %s" % request.verb
+                return defer.fail(Unsupported('', text))
+
+            d = handler(request)
+        else:
+            try:
+                handlerName, argNames = self._legacyHandlers[request.verb]
+            except KeyError:
+                text = "Request verb: %s" % request.verb
+                return defer.fail(Unsupported('', text))
+
+            handler = getattr(self, handlerName)
+            args = [getattr(request, arg) for arg in argNames]
+            d = handler(*args)
+
+        # If needed, translate the result into a response
+        try:
+            cb = getattr(self, '_toResponse_%s' % request.verb)
+        except AttributeError:
+            pass
+        else:
+            d.addCallback(cb, resource, request)
+
+        return d
+
+
+    def _toResponse_subscribe(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB, "pubsub"))
+        response.addChild(result.toElement(NS_PUBSUB))
+        return response
+
+
+    def _toResponse_subscriptions(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB, 'pubsub'))
+        subscriptions = response.addElement('subscriptions')
+        for subscription in result:
+            subscriptions.addChild(subscription.toElement(NS_PUBSUB))
+        return response
+
+
+    def _toResponse_affiliations(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB, 'pubsub'))
+        affiliations = response.addElement('affiliations')
+
+        for nodeIdentifier, affiliation in result:
+            item = affiliations.addElement('affiliation')
+            item['node'] = nodeIdentifier
+            item['affiliation'] = affiliation
+
+        return response
+
+
+    def _toResponse_create(self, result, resource, request):
+        if not request.nodeIdentifier or request.nodeIdentifier != result:
+            response = domish.Element((NS_PUBSUB, 'pubsub'))
+            create = response.addElement('create')
+            create['node'] = result
+            return response
+        else:
+            return None
+
+
+    def _formFromConfiguration(self, resource, values):
+        fieldDefs = resource.getConfigurationOptions()
+        form = data_form.Form(formType="form",
+                              formNamespace=NS_PUBSUB_NODE_CONFIG)
+        form.makeFields(values, fieldDefs)
+        return form
+
+
+    def _checkConfiguration(self, resource, form):
+        fieldDefs = resource.getConfigurationOptions()
+        form.typeCheck(fieldDefs, filterUnknown=True)
+
+
+    def _preProcess_create(self, resource, request):
+        if request.options:
+            self._checkConfiguration(resource, request.options)
+        return request
+
+
+    def _preProcess_default(self, resource, request):
+        if request.nodeType not in ('leaf', 'collection'):
+            raise error.StanzaError('not-acceptable')
+        else:
+            return request
+
+
+    def _toResponse_default(self, options, resource, request):
+        response = domish.Element((NS_PUBSUB_OWNER, "pubsub"))
+        default = response.addElement("default")
+        form = self._formFromConfiguration(resource, options)
+        default.addChild(form.toElement())
+        return response
+
+
+    def _toResponse_configureGet(self, options, resource, request):
+        response = domish.Element((NS_PUBSUB_OWNER, "pubsub"))
+        configure = response.addElement("configure")
+        form = self._formFromConfiguration(resource, options)
+        configure.addChild(form.toElement())
+
+        if request.nodeIdentifier:
+            configure["node"] = request.nodeIdentifier
+
+        return response
+
+
+    def _preProcess_configureSet(self, resource, request):
+        if request.options.formType == 'cancel':
+            return None
+        else:
+            self._checkConfiguration(resource, request.options)
+            return request
+
+
+    def _toResponse_items(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB, 'pubsub'))
+        items = response.addElement('items')
+        items["node"] = request.nodeIdentifier
+
+        for item in result:
+            item.uri = NS_PUBSUB
+            items.addChild(item)
+
+        return response
+
+
+    def _createNotification(self, eventType, service, nodeIdentifier,
+                                  subscriber, subscriptions=None):
+        headers = []
+
+        if subscriptions:
+            for subscription in subscriptions:
+                if nodeIdentifier != subscription.nodeIdentifier:
+                    headers.append(('Collection', subscription.nodeIdentifier))
+
+        message = domish.Element((None, "message"))
+        message["from"] = service.full()
+        message["to"] = subscriber.full()
+        event = message.addElement((NS_PUBSUB_EVENT, "event"))
+
+        element = event.addElement(eventType)
+        element["node"] = nodeIdentifier
+
+        if headers:
+            message.addChild(shim.Headers(headers))
+
+        return message
+
+
+    def _toResponse_affiliationsGet(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB_OWNER, 'pubsub'))
+        affiliations = response.addElement('affiliations')
+
+        if request.nodeIdentifier:
+            affiliations['node'] = request.nodeIdentifier
+
+        for entity, affiliation in result.iteritems():
+            item = affiliations.addElement('affiliation')
+            item['jid'] = entity.full()
+            item['affiliation'] = affiliation
+
+        return response
+
+
+    def _toResponse_subscriptionsGet(self, result, resource, request):
+        response = domish.Element((NS_PUBSUB, 'pubsub'))
+        subscriptions = response.addElement('subscriptions')
+        subscriptions['node'] = request.nodeIdentifier
+        for subscription in result:
+            subscription_element = subscription.toElement(NS_PUBSUB)
+            del subscription_element['node']
+            subscriptions.addChild(subscription_element)
+        return response
+
+
+    # public methods
+
+    def notifyPublish(self, service, nodeIdentifier, notifications):
+        for subscriber, subscriptions, items in notifications:
+            message = self._createNotification('items', service,
+                                               nodeIdentifier, subscriber,
+                                               subscriptions)
+            for item in items:
+                item.uri = NS_PUBSUB_EVENT
+                message.event.items.addChild(item)
+            self.send(message)
+
+
+    def notifyRetract(self, service, nodeIdentifier, notifications):
+        for subscriber, subscriptions, items in notifications:
+            message = self._createNotification('items', service,
+                                               nodeIdentifier, subscriber,
+                                               subscriptions)
+            for item in items:
+                retract = domish.Element((None, "retract"))
+                retract['id'] = item['id']
+                message.event.items.addChild(retract)
+            self.send(message)
+
+
+    def notifyDelete(self, service, nodeIdentifier, subscribers,
+                           redirectURI=None):
+        for subscriber in subscribers:
+            message = self._createNotification('delete', service,
+                                               nodeIdentifier,
+                                               subscriber)
+            if redirectURI:
+                redirect = message.event.delete.addElement('redirect')
+                redirect['uri'] = redirectURI
+            self.send(message)
+
+
+    def getNodeInfo(self, requestor, service, nodeIdentifier):
+        return None
+
+
+    def getNodes(self, requestor, service):
+        return []
+
+
+    def publish(self, requestor, service, nodeIdentifier, items):
+        raise Unsupported('publish')
+
+
+    def subscribe(self, requestor, service, nodeIdentifier, subscriber):
+        raise Unsupported('subscribe')
+
+
+    def unsubscribe(self, requestor, service, nodeIdentifier, subscriber):
+        raise Unsupported('subscribe')
+
+
+    def subscriptions(self, requestor, service):
+        raise Unsupported('retrieve-subscriptions')
+
+
+    def affiliations(self, requestor, service):
+        raise Unsupported('retrieve-affiliations')
+
+
+    def create(self, requestor, service, nodeIdentifier):
+        raise Unsupported('create-nodes')
+
+
+    def getConfigurationOptions(self):
+        return {}
+
+
+    def getDefaultConfiguration(self, requestor, service, nodeType):
+        raise Unsupported('retrieve-default')
+
+
+    def getConfiguration(self, requestor, service, nodeIdentifier):
+        raise Unsupported('config-node')
+
+
+    def setConfiguration(self, requestor, service, nodeIdentifier, options):
+        raise Unsupported('config-node')
+
+
+    def items(self, requestor, service, nodeIdentifier, maxItems,
+                    itemIdentifiers):
+        raise Unsupported('retrieve-items')
+
+
+    def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
+        raise Unsupported('retract-items')
+
+
+    def purge(self, requestor, service, nodeIdentifier):
+        raise Unsupported('purge-nodes')
+
+
+    def delete(self, requestor, service, nodeIdentifier):
+        raise Unsupported('delete-nodes')
+
+
+
+class PubSubResource(object):
+
+    implements(IPubSubResource)
+
+    features = []
+    discoIdentity = disco.DiscoIdentity('pubsub',
+                                        'service',
+                                        'Publish-Subscribe Service')
+
+
+    def locateResource(self, request):
+        return self
+
+
+    def getInfo(self, requestor, service, nodeIdentifier):
+        return defer.succeed(None)
+
+
+    def getNodes(self, requestor, service, nodeIdentifier):
+        return defer.succeed([])
+
+
+    def getConfigurationOptions(self):
+        return {}
+
+
+    def publish(self, request):
+        return defer.fail(Unsupported('publish'))
+
+
+    def subscribe(self, request):
+        return defer.fail(Unsupported('subscribe'))
+
+
+    def unsubscribe(self, request):
+        return defer.fail(Unsupported('subscribe'))
+
+
+    def subscriptions(self, request):
+        return defer.fail(Unsupported('retrieve-subscriptions'))
+
+
+    def affiliations(self, request):
+        return defer.fail(Unsupported('retrieve-affiliations'))
+
+
+    def create(self, request):
+        return defer.fail(Unsupported('create-nodes'))
+
+
+    def default(self, request):
+        return defer.fail(Unsupported('retrieve-default'))
+
+
+    def configureGet(self, request):
+        return defer.fail(Unsupported('config-node'))
+
+
+    def configureSet(self, request):
+        return defer.fail(Unsupported('config-node'))
+
+
+    def items(self, request):
+        return defer.fail(Unsupported('retrieve-items'))
+
+
+    def retract(self, request):
+        return defer.fail(Unsupported('retract-items'))
+
+
+    def purge(self, request):
+        return defer.fail(Unsupported('purge-nodes'))
+
+
+    def delete(self, request):
+        return defer.fail(Unsupported('delete-nodes'))
+
+
+    def affiliationsGet(self, request):
+        return defer.fail(Unsupported('retrieve-affiliations'))
+
+
+    def affiliationsSet(self, request):
+        return defer.fail(Unsupported('modify-affiliations'))
+
+
+    def subscriptionsGet(self, request):
+        return defer.fail(Unsupported('manage-subscriptions'))
+
+
+    def subscriptionsSet(self, request):
+        return defer.fail(Unsupported('manage-subscriptions'))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_tmp/wokkel/rsm.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,425 @@
+# -*- coding: utf-8 -*-
+# -*- test-case-name: wokkel.test.test_rsm -*-
+#
+# SàT Wokkel extension for Result Set Management (XEP-0059)
+# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+XMPP Result Set Management protocol.
+
+This protocol is specified in
+U{XEP-0059<http://xmpp.org/extensions/xep-0059.html>}.
+"""
+
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import error
+
+import pubsub
+import copy
+
+
+NS_RSM = 'http://jabber.org/protocol/rsm'
+
+
+class RSMError(error.StanzaError):
+    """
+    RSM error.
+    """
+    def __init__(self, text=None):
+        error.StanzaError.__init__(self, 'bad-request', text=text)
+
+
+class RSMNotFoundError(Exception):
+    """
+    An expected RSM element has not been found.
+    """
+
+
+class RSMRequest(object):
+    """
+    A Result Set Management request.
+
+    @ivar max_: limit on the number of retrieved items.
+    @itype max_: C{int} or C{unicode}
+
+    @ivar index: starting index of the requested page.
+    @itype index: C{int} or C{unicode} or C{None}
+
+    @ivar after: ID of the element immediately preceding the page.
+    @itype after: C{unicode}
+
+    @ivar before: ID of the element immediately following the page.
+    @itype before: C{unicode}
+    """
+
+    def __init__(self, max_=10, after=None, before=None, index=None):
+        self.max = int(max_)
+
+        if index is not None:
+            assert after is None and before is None
+            index = int(index)
+        self.index = index
+
+        if after is not None:
+            assert before is None
+            assert isinstance(after, basestring)
+        self.after = after
+
+        if before is not None:
+            assert isinstance(before, basestring)
+        self.before = before
+
+    def __str__(self):
+        return "RSM Request: max={0.max} after={0.after} before={0.before} index={0.index}".format(self)
+
+    @classmethod
+    def fromElement(cls, element):
+        """Parse the given request element.
+
+        @param element: request containing a set element, or set element itself.
+        @type element: L{domish.Element}
+
+        @return: RSMRequest instance.
+        @rtype: L{RSMRequest}
+        """
+
+        if element.name == 'set' and element.uri == NS_RSM:
+            set_elt = element
+        else:
+            try:
+                set_elt = element.elements(NS_RSM, 'set').next()
+            except StopIteration:
+                raise RSMNotFoundError()
+
+        try:
+            before_elt = set_elt.elements(NS_RSM, 'before').next()
+        except StopIteration:
+            before = None
+        else:
+            before = unicode(before_elt)
+
+        try:
+            after_elt = set_elt.elements(NS_RSM, 'after').next()
+        except StopIteration:
+            after = None
+        else:
+            after = unicode(after_elt)
+            if not after:
+                raise RSMError("<after/> element can't be empty in RSM request")
+
+        try:
+            max_elt = set_elt.elements(NS_RSM, 'max').next()
+        except StopIteration:
+            # FIXME: even if it doesn't make a lot of sense without it
+            #        <max/> element is not mandatory in XEP-0059
+            raise RSMError("RSM request is missing its 'max' element")
+        else:
+            try:
+                max_ = int(unicode(max_elt))
+            except ValueError:
+                raise RSMError("bad value for 'max' element")
+
+        try:
+            index_elt = set_elt.elements(NS_RSM, 'index').next()
+        except StopIteration:
+            index = None
+        else:
+            try:
+                index = int(unicode(index_elt))
+            except ValueError:
+                raise RSMError("bad value for 'index' element")
+
+        return RSMRequest(max_, after, before, index)
+
+    def toElement(self):
+        """
+        Return the DOM representation of this RSM request.
+
+        @rtype: L{domish.Element}
+        """
+        set_elt = domish.Element((NS_RSM, 'set'))
+        set_elt.addElement('max', content=unicode(self.max))
+
+        if self.index is not None:
+            set_elt.addElement('index', content=unicode(self.index))
+
+        if self.before is not None:
+            if self.before == '':  # request the last page
+                set_elt.addElement('before')
+            else:
+                set_elt.addElement('before', content=self.before)
+
+        if self.after is not None:
+            set_elt.addElement('after', content=self.after)
+
+        return set_elt
+
+    def render(self, element):
+        """Embed the DOM representation of this RSM request in the given element.
+
+        @param element: Element to contain the RSM request.
+        @type element: L{domish.Element}
+
+        @return: RSM request element.
+        @rtype: L{domish.Element}
+        """
+        set_elt = self.toElement()
+        element.addChild(set_elt)
+
+        return set_elt
+
+
+class RSMResponse(object):
+    """
+    A Result Set Management response.
+
+    @ivar first: ID of the first element of the returned page.
+    @itype first: C{unicode}
+
+    @ivar last: ID of the last element of the returned page.
+    @itype last: C{unicode}
+
+    @ivar index: starting index of the returned page.
+    @itype index: C{int}
+
+    @ivar count: total number of items.
+    @itype count: C{int}
+
+    """
+
+    def __init__(self, first=None, last=None, index=None, count=None):
+        if first is None:
+            assert last is None and index is None
+        if last is None:
+            assert first is None
+        self.first = first
+        self.last = last
+        if count is not None:
+            self.count = int(count)
+        else:
+            self.count = None
+        if index is not None:
+            self.index = int(index)
+        else:
+            self.index = None
+
+    def __str__(self):
+        return "RSM Request: first={0.first} last={0.last} index={0.index} count={0.count}".format(self)
+
+    @classmethod
+    def fromElement(cls, element):
+        """Parse the given response element.
+
+        @param element: response element.
+        @type element: L{domish.Element}
+
+        @return: RSMResponse instance.
+        @rtype: L{RSMResponse}
+        """
+        try:
+            set_elt = element.elements(NS_RSM, 'set').next()
+        except StopIteration:
+            raise RSMNotFoundError()
+
+        try:
+            first_elt = set_elt.elements(NS_RSM, 'first').next()
+        except StopIteration:
+            first = None
+            index = None
+        else:
+            first = unicode(first_elt)
+            try:
+                index = int(first_elt['index'])
+            except KeyError:
+                index = None
+            except ValueError:
+                raise RSMError("bad index in RSM response")
+
+        try:
+            last_elt = set_elt.elements(NS_RSM, 'last').next()
+        except StopIteration:
+            if first is not None:
+                raise RSMError("RSM response is missing its 'last' element")
+            else:
+                last = None
+        else:
+            if first is None:
+                raise RSMError("RSM response is missing its 'first' element")
+            last = unicode(last_elt)
+
+        try:
+            count_elt = set_elt.elements(NS_RSM, 'count').next()
+        except StopIteration:
+            count = None
+        else:
+            try:
+                count = int(unicode(count_elt))
+            except ValueError:
+                raise RSMError("invalid count in RSM response")
+
+        return RSMResponse(first, last, index, count)
+
+    def toElement(self):
+        """
+        Return the DOM representation of this RSM request.
+
+        @rtype: L{domish.Element}
+        """
+        set_elt = domish.Element((NS_RSM, 'set'))
+        if self.first is not None:
+            first_elt = set_elt.addElement('first', content=self.first)
+            if self.index is not None:
+                first_elt['index'] = unicode(self.index)
+
+            set_elt.addElement('last', content=self.last)
+
+        if self.count is not None:
+            set_elt.addElement('count', content=unicode(self.count))
+
+        return set_elt
+
+    def render(self, element):
+        """Embed the DOM representation of this RSM response in the given element.
+
+        @param element: Element to contain the RSM response.
+        @type element:  L{domish.Element}
+
+        @return: RSM request element.
+        @rtype: L{domish.Element}
+        """
+        set_elt = self.toElement()
+        element.addChild(set_elt)
+        return set_elt
+
+    def toDict(self):
+        """Return a dict representation of the object.
+
+        @return: a dict of strings.
+        @rtype: C{dict} binding C{unicode} to C{unicode}
+        """
+        result = {}
+        for attr in ('first', 'last', 'index', 'count'):
+            value = getattr(self, attr)
+            if value is not None:
+                result[attr] = unicode(value)
+        return result
+
+
+class PubSubRequest(pubsub.PubSubRequest):
+    """PubSubRequest extension to handle RSM.
+
+    @ivar rsm: RSM request instance.
+    @type rsm: L{RSMRequest}
+    """
+
+    rsm = None
+    _parameters = copy.deepcopy(pubsub.PubSubRequest._parameters)
+    _parameters['items'].append('rsm')
+
+    def _parse_rsm(self, verbElement):
+        try:
+            self.rsm = RSMRequest.fromElement(verbElement.parent)
+        except RSMNotFoundError:
+            self.rsm = None
+
+    def _render_rsm(self, verbElement):
+        if self.rsm:
+            self.rsm.render(verbElement.parent)
+
+
+class PubSubClient(pubsub.PubSubClient):
+    """PubSubClient extension to handle RSM."""
+
+    _request_class = PubSubRequest
+
+    def items(self, service, nodeIdentifier, maxItems=None, itemIdentifiers=None,
+              subscriptionIdentifier=None, sender=None, rsm_request=None):
+        """
+        Retrieve previously published items from a publish subscribe node.
+
+        @param service: The publish subscribe service that keeps the node.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+
+        @param nodeIdentifier: The identifier of the node.
+        @type nodeIdentifier: C{unicode}
+
+        @param maxItems: Optional limit on the number of retrieved items.
+        @type maxItems: C{int}
+
+        @param itemIdentifiers: Identifiers of the items to be retrieved.
+        @type itemIdentifiers: C{set}
+
+        @param subscriptionIdentifier: Optional subscription identifier. In
+            case the node has been subscribed to multiple times, this narrows
+            the results to the specific subscription.
+        @type subscriptionIdentifier: C{unicode}
+
+        @param ext_data: extension data.
+        @type ext_data: L{dict}
+
+        @return: a Deferred that fires a C{list} of C{tuple} of L{domish.Element}, L{RSMResponse}.
+        @rtype: L{defer.Deferred}
+        """
+        # XXX: we have to copy initial method instead of calling it,
+        #      as original cb remove all non item elements
+        request = self._request_class('items')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        if maxItems:
+            request.maxItems = str(int(maxItems))
+        request.subscriptionIdentifier = subscriptionIdentifier
+        request.sender = sender
+        request.itemIdentifiers = itemIdentifiers
+        request.rsm = rsm_request
+
+        def cb(iq):
+            items = []
+            pubsub_elt = iq.pubsub
+            if pubsub_elt.items:
+                for element in pubsub_elt.items.elements(pubsub.NS_PUBSUB, 'item'):
+                    items.append(element)
+
+            try:
+                rsm_response = RSMResponse.fromElement(pubsub_elt)
+            except RSMNotFoundError:
+                rsm_response = None
+            return (items, rsm_response)
+
+        d = request.send(self.xmlstream)
+        d.addCallback(cb)
+        return d
+
+
+class PubSubService(pubsub.PubSubService):
+    """PubSubService extension to handle RSM."""
+
+    _request_class = PubSubRequest
+
+    def _toResponse_items(self, elts, resource, request):
+        # default method only manage <item/> elements
+        # but we need to add RSM set element
+        rsm_elt = None
+        for idx, elt in enumerate(reversed(elts)):
+            if elt.name == "set" and elt.uri == NS_RSM:
+                rsm_elt = elts.pop(-1-idx)
+                break
+
+        response = pubsub.PubSubService._toResponse_items(self, elts,
+                                                          resource, request)
+        if rsm_elt is not None:
+            response.addChild(rsm_elt)
+
+        return response
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_tmp/wokkel/test/test_pubsub.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,4218 @@
+# Copyright (c) Ralph Meijer.
+# See LICENSE for details.
+
+"""
+Tests for L{wokkel.pubsub}
+"""
+
+from zope.interface import verify
+
+from twisted.trial import unittest
+from twisted.internet import defer
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import error
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.protocols.jabber.xmlstream import toResponse
+
+from wokkel import data_form, disco, iwokkel, shim
+from wokkel.generic import parseXml
+from wokkel.test.helpers import TestableRequestHandlerMixin, XmlStreamStub
+
+from sat.tmp.wokkel import pubsub
+
+NS_PUBSUB = 'http://jabber.org/protocol/pubsub'
+NS_PUBSUB_NODE_CONFIG = 'http://jabber.org/protocol/pubsub#node_config'
+NS_PUBSUB_ERRORS = 'http://jabber.org/protocol/pubsub#errors'
+NS_PUBSUB_EVENT = 'http://jabber.org/protocol/pubsub#event'
+NS_PUBSUB_OWNER = 'http://jabber.org/protocol/pubsub#owner'
+NS_PUBSUB_META_DATA = 'http://jabber.org/protocol/pubsub#meta-data'
+NS_PUBSUB_SUBSCRIBE_OPTIONS = 'http://jabber.org/protocol/pubsub#subscribe_options'
+
+def calledAsync(fn):
+    """
+    Function wrapper that fires a deferred upon calling the given function.
+    """
+    d = defer.Deferred()
+
+    def func(*args, **kwargs):
+        try:
+            result = fn(*args, **kwargs)
+        except:
+            d.errback()
+        else:
+            d.callback(result)
+
+    return d, func
+
+
+class SubscriptionTest(unittest.TestCase):
+    """
+    Tests for L{pubsub.Subscription}.
+    """
+
+    def test_fromElement(self):
+        """
+        fromElement parses a subscription from XML DOM.
+        """
+        xml = """
+        <subscription node='test' jid='user@example.org/Home'
+                      subscription='pending'/>
+        """
+        subscription = pubsub.Subscription.fromElement(parseXml(xml))
+        self.assertEqual('test', subscription.nodeIdentifier)
+        self.assertEqual(JID('user@example.org/Home'), subscription.subscriber)
+        self.assertEqual('pending', subscription.state)
+        self.assertIdentical(None, subscription.subscriptionIdentifier)
+
+
+    def test_fromElementWithSubscriptionIdentifier(self):
+        """
+        A subscription identifier in the subscription should be parsed, too.
+        """
+        xml = """
+        <subscription node='test' jid='user@example.org/Home' subid='1234'
+                      subscription='pending'/>
+        """
+        subscription = pubsub.Subscription.fromElement(parseXml(xml))
+        self.assertEqual('1234', subscription.subscriptionIdentifier)
+
+
+    def test_toElement(self):
+        """
+        Rendering a Subscription should yield the proper attributes.
+        """
+        subscription = pubsub.Subscription('test',
+                                           JID('user@example.org/Home'),
+                                           'pending')
+        element = subscription.toElement()
+        self.assertEqual('subscription', element.name)
+        self.assertEqual(None, element.uri)
+        self.assertEqual('test', element.getAttribute('node'))
+        self.assertEqual('user@example.org/Home', element.getAttribute('jid'))
+        self.assertEqual('pending', element.getAttribute('subscription'))
+        self.assertFalse(element.hasAttribute('subid'))
+
+
+    def test_toElementEmptyNodeIdentifier(self):
+        """
+        The empty node identifier should not yield a node attribute.
+        """
+        subscription = pubsub.Subscription('',
+                                           JID('user@example.org/Home'),
+                                           'pending')
+        element = subscription.toElement()
+        self.assertFalse(element.hasAttribute('node'))
+
+
+    def test_toElementWithSubscriptionIdentifier(self):
+        """
+        The subscription identifier, if set, is in the subid attribute.
+        """
+        subscription = pubsub.Subscription('test',
+                                           JID('user@example.org/Home'),
+                                           'pending',
+                                           subscriptionIdentifier='1234')
+        element = subscription.toElement()
+        self.assertEqual('1234', element.getAttribute('subid'))
+
+
+
+class PubSubClientTest(unittest.TestCase):
+    timeout = 2
+
+    def setUp(self):
+        self.stub = XmlStreamStub()
+        self.protocol = pubsub.PubSubClient()
+        self.protocol.xmlstream = self.stub.xmlstream
+        self.protocol.connectionInitialized()
+
+
+    def test_interface(self):
+        """
+        Do instances of L{pubsub.PubSubClient} provide L{iwokkel.IPubSubClient}?
+        """
+        verify.verifyObject(iwokkel.IPubSubClient, self.protocol)
+
+
+    def test_eventItems(self):
+        """
+        Test receiving an items event resulting in a call to itemsReceived.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        items = event.addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item1'
+        item2 = items.addElement('retract')
+        item2['id'] = 'item2'
+        item3 = items.addElement('item')
+        item3['id'] = 'item3'
+
+        def itemsReceived(event):
+            self.assertEquals(JID('user@example.org/home'), event.recipient)
+            self.assertEquals(JID('pubsub.example.org'), event.sender)
+            self.assertEquals('test', event.nodeIdentifier)
+            self.assertEquals([item1, item2, item3], event.items)
+
+        d, self.protocol.itemsReceived = calledAsync(itemsReceived)
+        self.stub.send(message)
+        return d
+
+
+    def test_eventItemsCollection(self):
+        """
+        Test receiving an items event resulting in a call to itemsReceived.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        items = event.addElement('items')
+        items['node'] = 'test'
+
+        headers = shim.Headers([('Collection', 'collection')])
+        message.addChild(headers)
+
+        def itemsReceived(event):
+            self.assertEquals(JID('user@example.org/home'), event.recipient)
+            self.assertEquals(JID('pubsub.example.org'), event.sender)
+            self.assertEquals('test', event.nodeIdentifier)
+            self.assertEquals({'Collection': ['collection']}, event.headers)
+
+        d, self.protocol.itemsReceived = calledAsync(itemsReceived)
+        self.stub.send(message)
+        return d
+
+
+    def test_eventItemsError(self):
+        """
+        An error message with embedded event should not be handled.
+
+        This test uses an items event, which should not result in itemsReceived
+        being called. In general message.handled should be False.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        message['type'] = 'error'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        items = event.addElement('items')
+        items['node'] = 'test'
+
+        class UnexpectedCall(Exception):
+            pass
+
+        def itemsReceived(event):
+            raise UnexpectedCall("Unexpected call to itemsReceived")
+
+        self.protocol.itemsReceived = itemsReceived
+        self.stub.send(message)
+        self.assertFalse(message.handled)
+
+
+    def test_eventDelete(self):
+        """
+        Test receiving a delete event resulting in a call to deleteReceived.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        delete = event.addElement('delete')
+        delete['node'] = 'test'
+
+        def deleteReceived(event):
+            self.assertEquals(JID('user@example.org/home'), event.recipient)
+            self.assertEquals(JID('pubsub.example.org'), event.sender)
+            self.assertEquals('test', event.nodeIdentifier)
+
+        d, self.protocol.deleteReceived = calledAsync(deleteReceived)
+        self.stub.send(message)
+        return d
+
+
+    def test_eventDeleteRedirect(self):
+        """
+        Test receiving a delete event with a redirect URI.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        delete = event.addElement('delete')
+        delete['node'] = 'test'
+        uri = 'xmpp:pubsub.example.org?;node=test2'
+        delete.addElement('redirect')['uri'] = uri
+
+        def deleteReceived(event):
+            self.assertEquals(JID('user@example.org/home'), event.recipient)
+            self.assertEquals(JID('pubsub.example.org'), event.sender)
+            self.assertEquals('test', event.nodeIdentifier)
+            self.assertEquals(uri, event.redirectURI)
+
+        d, self.protocol.deleteReceived = calledAsync(deleteReceived)
+        self.stub.send(message)
+        return d
+
+
+    def test_event_purge(self):
+        """
+        Test receiving a purge event resulting in a call to purgeReceived.
+        """
+        message = domish.Element((None, 'message'))
+        message['from'] = 'pubsub.example.org'
+        message['to'] = 'user@example.org/home'
+        event = message.addElement((NS_PUBSUB_EVENT, 'event'))
+        items = event.addElement('purge')
+        items['node'] = 'test'
+
+        def purgeReceived(event):
+            self.assertEquals(JID('user@example.org/home'), event.recipient)
+            self.assertEquals(JID('pubsub.example.org'), event.sender)
+            self.assertEquals('test', event.nodeIdentifier)
+
+        d, self.protocol.purgeReceived = calledAsync(purgeReceived)
+        self.stub.send(message)
+        return d
+
+
+    def test_createNode(self):
+        """
+        Test sending create request.
+        """
+
+        def cb(nodeIdentifier):
+            self.assertEquals('test', nodeIdentifier)
+
+        d = self.protocol.createNode(JID('pubsub.example.org'), 'test')
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'create', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_createNodeInstant(self):
+        """
+        Test sending create request resulting in an instant node.
+        """
+
+        def cb(nodeIdentifier):
+            self.assertEquals('test', nodeIdentifier)
+
+        d = self.protocol.createNode(JID('pubsub.example.org'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'create', NS_PUBSUB))
+        child = children[0]
+        self.assertFalse(child.hasAttribute('node'))
+
+        response = toResponse(iq, 'result')
+        command = response.addElement((NS_PUBSUB, 'pubsub'))
+        create = command.addElement('create')
+        create['node'] = 'test'
+        self.stub.send(response)
+        return d
+
+
+    def test_createNodeRenamed(self):
+        """
+        Test sending create request resulting in renamed node.
+        """
+
+        def cb(nodeIdentifier):
+            self.assertEquals('test2', nodeIdentifier)
+
+        d = self.protocol.createNode(JID('pubsub.example.org'), 'test')
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'create', NS_PUBSUB))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        response = toResponse(iq, 'result')
+        command = response.addElement((NS_PUBSUB, 'pubsub'))
+        create = command.addElement('create')
+        create['node'] = 'test2'
+        self.stub.send(response)
+        return d
+
+
+    def test_createNodeWithSender(self):
+        """
+        Test sending create request from a specific JID.
+        """
+
+        d = self.protocol.createNode(JID('pubsub.example.org'), 'test',
+                                     sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_createNodeWithConfig(self):
+        """
+        Test sending create request with configuration options
+        """
+
+        options = {
+            'pubsub#title': 'Princely Musings (Atom)',
+            'pubsub#deliver_payloads': True,
+            'pubsub#persist_items': '1',
+            'pubsub#max_items': '10',
+            'pubsub#access_model': 'open',
+            'pubsub#type': 'http://www.w3.org/2005/Atom',
+        }
+
+        d = self.protocol.createNode(JID('pubsub.example.org'), 'test',
+                                     sender=JID('user@example.org'),
+                                     options=options)
+
+        iq = self.stub.output[-1]
+
+        # check if there is exactly one configure element
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'configure', NS_PUBSUB))
+        self.assertEqual(1, len(children))
+
+        # check that it has a configuration form
+        form = data_form.findForm(children[0], NS_PUBSUB_NODE_CONFIG)
+        self.assertEqual('submit', form.formType)
+
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_deleteNode(self):
+        """
+        Test sending delete request.
+        """
+
+        d = self.protocol.deleteNode(JID('pubsub.example.org'), 'test')
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB_OWNER, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'delete', NS_PUBSUB_OWNER))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_deleteNodeWithSender(self):
+        """
+        Test sending delete request.
+        """
+
+        d = self.protocol.deleteNode(JID('pubsub.example.org'), 'test',
+                                     sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_publish(self):
+        """
+        Test sending publish request.
+        """
+
+        item = pubsub.Item()
+        d = self.protocol.publish(JID('pubsub.example.org'), 'test', [item])
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'publish', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        items = list(domish.generateElementsQNamed(child.children,
+                                                   'item', NS_PUBSUB))
+        self.assertEquals(1, len(items))
+        self.assertIdentical(item, items[0])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_publishNoItems(self):
+        """
+        Test sending publish request without items.
+        """
+
+        d = self.protocol.publish(JID('pubsub.example.org'), 'test')
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'publish', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_publishWithSender(self):
+        """
+        Test sending publish request from a specific JID.
+        """
+
+        item = pubsub.Item()
+        d = self.protocol.publish(JID('pubsub.example.org'), 'test', [item],
+                                  JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+        return d
+
+
+    def test_subscribe(self):
+        """
+        Test sending subscription request.
+        """
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'subscribe', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        self.assertEquals('user@example.org', child['jid'])
+
+        response = toResponse(iq, 'result')
+        pubsub = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = pubsub.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'subscribed'
+        self.stub.send(response)
+        return d
+
+
+    def test_subscribeReturnsSubscription(self):
+        """
+        A successful subscription should return a Subscription instance.
+        """
+        def cb(subscription):
+            self.assertEqual(JID('user@example.org'), subscription.subscriber)
+
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+
+        response = toResponse(iq, 'result')
+        pubsub = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = pubsub.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'subscribed'
+        self.stub.send(response)
+        return d
+
+
+    def test_subscribePending(self):
+        """
+        Test sending subscription request that results in a pending
+        subscription.
+        """
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        response = toResponse(iq, 'result')
+        command = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = command.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'pending'
+        self.stub.send(response)
+        self.assertFailure(d, pubsub.SubscriptionPending)
+        return d
+
+
+    def test_subscribeUnconfigured(self):
+        """
+        Test sending subscription request that results in an unconfigured
+        subscription.
+        """
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        response = toResponse(iq, 'result')
+        command = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = command.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'unconfigured'
+        self.stub.send(response)
+        self.assertFailure(d, pubsub.SubscriptionUnconfigured)
+        return d
+
+
+    def test_subscribeWithOptions(self):
+        options = {'pubsub#deliver': False}
+
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                    JID('user@example.org'),
+                                    options=options)
+        iq = self.stub.output[-1]
+
+        # Check options present
+        childNames = []
+        for element in iq.pubsub.elements():
+            if element.uri == NS_PUBSUB:
+                childNames.append(element.name)
+
+        self.assertEqual(['subscribe', 'options'], childNames)
+        form = data_form.findForm(iq.pubsub.options,
+                                  NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        self.assertEqual('submit', form.formType)
+        form.typeCheck({'pubsub#deliver': {'type': 'boolean'}})
+        self.assertEqual(options, form.getValues())
+
+        # Send response
+        response = toResponse(iq, 'result')
+        pubsub = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = pubsub.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'subscribed'
+        self.stub.send(response)
+
+        return d
+
+
+    def test_subscribeWithSender(self):
+        """
+        Test sending subscription request from a specific JID.
+        """
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'),
+                                      sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        response = toResponse(iq, 'result')
+        pubsub = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = pubsub.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'subscribed'
+        self.stub.send(response)
+        return d
+
+
+    def test_subscribeReturningSubscriptionIdentifier(self):
+        """
+        Test sending subscription request with subscription identifier.
+        """
+        def cb(subscription):
+            self.assertEqual('1234', subscription.subscriptionIdentifier)
+
+        d = self.protocol.subscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+
+        response = toResponse(iq, 'result')
+        pubsub = response.addElement((NS_PUBSUB, 'pubsub'))
+        subscription = pubsub.addElement('subscription')
+        subscription['node'] = 'test'
+        subscription['jid'] = 'user@example.org'
+        subscription['subscription'] = 'subscribed'
+        subscription['subid'] = '1234'
+        self.stub.send(response)
+        return d
+
+
+    def test_unsubscribe(self):
+        """
+        Test sending unsubscription request.
+        """
+        d = self.protocol.unsubscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'unsubscribe', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        self.assertEquals('user@example.org', child['jid'])
+
+        self.stub.send(toResponse(iq, 'result'))
+        return d
+
+
+    def test_unsubscribeWithSender(self):
+        """
+        Test sending unsubscription request from a specific JID.
+        """
+        d = self.protocol.unsubscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'),
+                                      sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+        self.stub.send(toResponse(iq, 'result'))
+        return d
+
+
+    def test_unsubscribeWithSubscriptionIdentifier(self):
+        """
+        Test sending unsubscription request with subscription identifier.
+        """
+        d = self.protocol.unsubscribe(JID('pubsub.example.org'), 'test',
+                                      JID('user@example.org'),
+                                      subscriptionIdentifier='1234')
+
+        iq = self.stub.output[-1]
+        child = iq.pubsub.unsubscribe
+        self.assertEquals('1234', child['subid'])
+
+        self.stub.send(toResponse(iq, 'result'))
+        return d
+
+
+    def test_items(self):
+        """
+        Test sending items request.
+        """
+        def cb(items):
+            self.assertEquals([], items)
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test')
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items')
+        items['node'] = 'test'
+
+        self.stub.send(response)
+
+        return d
+
+
+    def test_itemsMaxItems(self):
+        """
+        Test sending items request, with limit on the number of items.
+        """
+        def cb(items):
+            self.assertEquals(2, len(items))
+            self.assertEquals([item1, item2], items)
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test', maxItems=2)
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        self.assertEquals('2', child['max_items'])
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item1'
+        item2 = items.addElement('item')
+        item2['id'] = 'item2'
+
+        self.stub.send(response)
+
+        return d
+
+
+    def test_itemsWithItemIdentifiers(self):
+        """
+        Test sending items request with item identifiers.
+        """
+        def cb(items):
+            self.assertEquals(2, len(items))
+            self.assertEquals([item1, item2], items)
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                itemIdentifiers=['item1', 'item2'])
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        itemIdentifiers = [item.getAttribute('id') for item in
+                           domish.generateElementsQNamed(child.children, 'item',
+                                                         NS_PUBSUB)]
+        self.assertEquals(['item1', 'item2'], itemIdentifiers)
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item1'
+        item2 = items.addElement('item')
+        item2['id'] = 'item2'
+
+        self.stub.send(response)
+
+        return d
+
+
+    def test_itemsWithSubscriptionIdentifier(self):
+        """
+        Test sending items request with a subscription identifier.
+        """
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                               subscriptionIdentifier='1234')
+
+        iq = self.stub.output[-1]
+        child = iq.pubsub.items
+        self.assertEquals('1234', child['subid'])
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items')
+        items['node'] = 'test'
+
+        self.stub.send(response)
+        return d
+
+
+    def test_itemsWithSender(self):
+        """
+        Test sending items request from a specific JID.
+        """
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                               sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items')
+        items['node'] = 'test'
+
+        self.stub.send(response)
+        return d
+
+
+    def test_retractItems(self):
+        """
+        Test sending items retraction.
+        """
+        d = self.protocol.retractItems(JID('pubsub.example.org'), 'test',
+                                       itemIdentifiers=['item1', 'item2'])
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('set', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'retract', NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+        itemIdentifiers = [item.getAttribute('id') for item in
+                           domish.generateElementsQNamed(child.children, 'item',
+                                                         NS_PUBSUB)]
+        self.assertEquals(['item1', 'item2'], itemIdentifiers)
+
+        self.stub.send(toResponse(iq, 'result'))
+        return d
+
+
+    def test_retractItemsWithSender(self):
+        """
+        Test retracting items request from a specific JID.
+        """
+        d = self.protocol.retractItems(JID('pubsub.example.org'), 'test',
+                                       itemIdentifiers=['item1', 'item2'],
+                                       sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEquals('user@example.org', iq['from'])
+
+        self.stub.send(toResponse(iq, 'result'))
+        return d
+
+
+    def test_getOptions(self):
+        def cb(form):
+            self.assertEqual('form', form.formType)
+            self.assertEqual(NS_PUBSUB_SUBSCRIBE_OPTIONS, form.formNamespace)
+            field = form.fields['pubsub#deliver']
+            self.assertEqual('boolean', field.fieldType)
+            self.assertIdentical(True, field.value)
+            self.assertEqual('Enable delivery?', field.label)
+
+        d = self.protocol.getOptions(JID('pubsub.example.org'), 'test',
+                                     JID('user@example.org'),
+                                     sender=JID('user@example.org'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEqual('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEqual('get', iq.getAttribute('type'))
+        self.assertEqual('pubsub', iq.pubsub.name)
+        self.assertEqual(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'options', NS_PUBSUB))
+        self.assertEqual(1, len(children))
+        child = children[0]
+        self.assertEqual('test', child['node'])
+
+        self.assertEqual(0, len(child.children))
+
+        # Send response
+        form = data_form.Form('form', formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        form.addField(data_form.Field('boolean', var='pubsub#deliver',
+                                                 label='Enable delivery?',
+                                                 value=True))
+        response = toResponse(iq, 'result')
+        response.addElement((NS_PUBSUB, 'pubsub'))
+        response.pubsub.addElement('options')
+        response.pubsub.options.addChild(form.toElement())
+        self.stub.send(response)
+
+        return d
+
+
+    def test_getOptionsWithSubscriptionIdentifier(self):
+        """
+        Getting options with a subid should have the subid in the request.
+        """
+
+        d = self.protocol.getOptions(JID('pubsub.example.org'), 'test',
+                                     JID('user@example.org'),
+                                     sender=JID('user@example.org'),
+                                     subscriptionIdentifier='1234')
+
+        iq = self.stub.output[-1]
+        child = iq.pubsub.options
+        self.assertEqual('1234', child['subid'])
+
+        # Send response
+        form = data_form.Form('form', formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        form.addField(data_form.Field('boolean', var='pubsub#deliver',
+                                                 label='Enable delivery?',
+                                                 value=True))
+        response = toResponse(iq, 'result')
+        response.addElement((NS_PUBSUB, 'pubsub'))
+        response.pubsub.addElement('options')
+        response.pubsub.options.addChild(form.toElement())
+        self.stub.send(response)
+
+        return d
+
+
+    def test_setOptions(self):
+        """
+        setOptions should send out a options-set request.
+        """
+        options = {'pubsub#deliver': False}
+
+        d = self.protocol.setOptions(JID('pubsub.example.org'), 'test',
+                                     JID('user@example.org'),
+                                     options,
+                                     sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        self.assertEqual('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEqual('set', iq.getAttribute('type'))
+        self.assertEqual('pubsub', iq.pubsub.name)
+        self.assertEqual(NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'options', NS_PUBSUB))
+        self.assertEqual(1, len(children))
+        child = children[0]
+        self.assertEqual('test', child['node'])
+
+        form = data_form.findForm(child, NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        self.assertEqual('submit', form.formType)
+        form.typeCheck({'pubsub#deliver': {'type': 'boolean'}})
+        self.assertEqual(options, form.getValues())
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+
+        return d
+
+
+    def test_setOptionsWithSubscriptionIdentifier(self):
+        """
+        setOptions should send out a options-set request with subid.
+        """
+        options = {'pubsub#deliver': False}
+
+        d = self.protocol.setOptions(JID('pubsub.example.org'), 'test',
+                                     JID('user@example.org'),
+                                     options,
+                                     subscriptionIdentifier='1234',
+                                     sender=JID('user@example.org'))
+
+        iq = self.stub.output[-1]
+        child = iq.pubsub.options
+        self.assertEqual('1234', child['subid'])
+
+        form = data_form.findForm(child, NS_PUBSUB_SUBSCRIBE_OPTIONS)
+        self.assertEqual('submit', form.formType)
+        form.typeCheck({'pubsub#deliver': {'type': 'boolean'}})
+        self.assertEqual(options, form.getValues())
+
+        response = toResponse(iq, 'result')
+        self.stub.send(response)
+
+        return d
+
+
+class PubSubRequestTest(unittest.TestCase):
+
+    def test_fromElementUnknown(self):
+        """
+        An unknown verb raises NotImplementedError.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <non-existing-verb/>
+          </pubsub>
+        </iq>
+        """
+
+        self.assertRaises(NotImplementedError,
+                          pubsub.PubSubRequest.fromElement, parseXml(xml))
+
+
+    def test_fromElementKnownBadCombination(self):
+        """
+        Multiple verbs in an unknown configuration raises NotImplementedError.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+             <publish/>
+             <create/>
+          </pubsub>
+        </iq>
+        """
+
+        self.assertRaises(NotImplementedError,
+                          pubsub.PubSubRequest.fromElement, parseXml(xml))
+
+    def test_fromElementPublish(self):
+        """
+        Test parsing a publish request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('publish', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual([], request.items)
+
+
+    def test_fromElementPublishItems(self):
+        """
+        Test parsing a publish request with items.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish node='test'>
+              <item id="item1"/>
+              <item id="item2"/>
+            </publish>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual(2, len(request.items))
+        self.assertEqual(u'item1', request.items[0]["id"])
+        self.assertEqual(u'item2', request.items[1]["id"])
+
+
+    def test_fromElementPublishItemsOptions(self):
+        """
+        Test parsing a publish request with items and options.
+
+        Note that publishing options are not supported, but passing them
+        shouldn't affect processing of the publish request itself.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish node='test'>
+              <item id="item1"/>
+              <item id="item2"/>
+            </publish>
+            <publish-options/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual(2, len(request.items))
+        self.assertEqual(u'item1', request.items[0]["id"])
+        self.assertEqual(u'item2', request.items[1]["id"])
+
+    def test_fromElementPublishNoNode(self):
+        """
+        A publish request to the root node should raise an exception.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish/>
+          </pubsub>
+        </iq>
+        """
+
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual(NS_PUBSUB_ERRORS, err.appCondition.uri)
+        self.assertEqual('nodeid-required', err.appCondition.name)
+
+
+    def test_fromElementSubscribe(self):
+        """
+        Test parsing a subscription request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('subscribe', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual(JID('user@example.org/Home'), request.subscriber)
+
+
+    def test_fromElementSubscribeEmptyNode(self):
+        """
+        Test parsing a subscription request to the root node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('', request.nodeIdentifier)
+
+
+    def test_fromElementSubscribeNoJID(self):
+        """
+        Subscribe requests without a JID should raise a bad-request exception.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test'/>
+          </pubsub>
+        </iq>
+        """
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual(NS_PUBSUB_ERRORS, err.appCondition.uri)
+        self.assertEqual('jid-required', err.appCondition.name)
+
+
+    def test_fromElementSubscribeWithOptions(self):
+        """
+        Test parsing a subscription request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+            <options>
+              <x xmlns="jabber:x:data" type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver' type='boolean'
+                       label='Enable delivery?'>
+                  <value>1</value>
+                </field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('subscribe', request.verb)
+        request.options.typeCheck({'pubsub#deliver': {'type': 'boolean'}})
+        self.assertEqual({'pubsub#deliver': True}, request.options.getValues())
+
+
+    def test_fromElementSubscribeWithOptionsBadFormType(self):
+        """
+        The options form should have the right type.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+            <options>
+              <x xmlns="jabber:x:data" type='result'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver' type='boolean'
+                       label='Enable delivery?'>
+                  <value>1</value>
+                </field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual("Unexpected form type 'result'", err.text)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementSubscribeWithOptionsEmpty(self):
+        """
+        When no (suitable) form is found, the options are empty.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+            <options/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('subscribe', request.verb)
+        self.assertEqual({}, request.options.getValues())
+
+
+    def test_fromElementUnsubscribe(self):
+        """
+        Test parsing an unsubscription request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('unsubscribe', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual(JID('user@example.org/Home'), request.subscriber)
+
+
+    def test_fromElementUnsubscribeWithSubscriptionIdentifier(self):
+        """
+        Test parsing an unsubscription request with subscription identifier.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test' jid='user@example.org/Home'
+                         subid='1234'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('1234', request.subscriptionIdentifier)
+
+
+    def test_fromElementUnsubscribeNoJID(self):
+        """
+        Unsubscribe requests without a JID should raise a bad-request exception.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test'/>
+          </pubsub>
+        </iq>
+        """
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual(NS_PUBSUB_ERRORS, err.appCondition.uri)
+        self.assertEqual('jid-required', err.appCondition.name)
+
+
+    def test_fromElementOptionsGet(self):
+        """
+        Test parsing a request for getting subscription options.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('optionsGet', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual(JID('user@example.org/Home'), request.subscriber)
+
+
+    def test_fromElementOptionsGetWithSubscriptionIdentifier(self):
+        """
+        Test parsing a request for getting subscription options with subid.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'
+                     subid='1234'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('1234', request.subscriptionIdentifier)
+
+
+    def test_fromElementOptionsSet(self):
+        """
+        Test parsing a request for setting subscription options.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver'><value>1</value></field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('optionsSet', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual(JID('user@example.org/Home'), request.subscriber)
+        self.assertEqual({'pubsub#deliver': '1'}, request.options.getValues())
+
+
+    def test_fromElementOptionsSetWithSubscriptionIdentifier(self):
+        """
+        Test parsing a request for setting subscription options with subid.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'
+                     subid='1234'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver'><value>1</value></field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('1234', request.subscriptionIdentifier)
+
+
+    def test_fromElementOptionsSetCancel(self):
+        """
+        Test parsing a request for cancelling setting subscription options.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'>
+              <x xmlns='jabber:x:data' type='cancel'/>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('cancel', request.options.formType)
+
+
+    def test_fromElementOptionsSetBadFormType(self):
+        """
+        On a options set request unknown fields should be ignored.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'>
+              <x xmlns='jabber:x:data' type='result'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver'><value>1</value></field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual("Unexpected form type 'result'", err.text)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementOptionsSetNoForm(self):
+        """
+        On a options set request a form is required.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementSubscriptions(self):
+        """
+        Test parsing a request for all subscriptions.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('subscriptions', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+
+
+    def test_fromElementAffiliations(self):
+        """
+        Test parsing a request for all affiliations.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <affiliations/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('affiliations', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+
+
+    def test_fromElementCreate(self):
+        """
+        Test parsing a request to create a node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('create', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('mynode', request.nodeIdentifier)
+        self.assertIdentical(None, request.options)
+
+
+    def test_fromElementCreateInstant(self):
+        """
+        Test parsing a request to create an instant node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertIdentical(None, request.nodeIdentifier)
+
+
+    def test_fromElementCreateConfigureEmpty(self):
+        """
+        Test parsing a request to create a node with an empty configuration.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+            <configure/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual({}, request.options.getValues())
+        self.assertEqual(u'mynode', request.nodeIdentifier)
+
+
+    def test_fromElementCreateConfigureEmptyWrongOrder(self):
+        """
+        Test parsing a request to create a node and configure, wrong order.
+
+        The C{configure} element should come after the C{create} request,
+        but we should accept both orders.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <configure/>
+            <create node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual({}, request.options.getValues())
+        self.assertEqual(u'mynode', request.nodeIdentifier)
+
+
+    def test_fromElementCreateConfigure(self):
+        """
+        Test parsing a request to create a node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+            <configure>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#access_model'><value>open</value></field>
+                <field var='pubsub#persist_items'><value>0</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        values = request.options
+        self.assertIn('pubsub#access_model', values)
+        self.assertEqual(u'open', values['pubsub#access_model'])
+        self.assertIn('pubsub#persist_items', values)
+        self.assertEqual(u'0', values['pubsub#persist_items'])
+
+
+    def test_fromElementCreateConfigureBadFormType(self):
+        """
+        The form of a node creation request should have the right type.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+            <configure>
+              <x xmlns='jabber:x:data' type='result'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#access_model'><value>open</value></field>
+                <field var='pubsub#persist_items'><value>0</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual("Unexpected form type 'result'", err.text)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementDefault(self):
+        """
+        Parsing default node configuration request sets required attributes.
+
+        Besides C{verb}, C{sender} and C{recipient}, we expect C{nodeType}
+        to be set. If not passed it receives the default C{u'leaf'}.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <default/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEquals(u'default', request.verb)
+        self.assertEquals(JID('user@example.org'), request.sender)
+        self.assertEquals(JID('pubsub.example.org'), request.recipient)
+        self.assertEquals(u'leaf', request.nodeType)
+
+
+    def test_fromElementDefaultCollection(self):
+        """
+        Parsing default request for collection sets nodeType to collection.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <default>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#node_type'>
+                  <value>collection</value>
+                </field>
+              </x>
+            </default>
+
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEquals('collection', request.nodeType)
+
+
+    def test_fromElementConfigureGet(self):
+        """
+        Test parsing a node configuration get request.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('configureGet', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+
+
+    def test_fromElementConfigureSet(self):
+        """
+        On a node configuration set request the Data Form is parsed.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='pubsub#persist_items'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('configureSet', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual({'pubsub#deliver_payloads': '0',
+                          'pubsub#persist_items': '1'},
+                         request.options.getValues())
+
+
+    def test_fromElementConfigureSetCancel(self):
+        """
+        The node configuration is cancelled, so no options.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='cancel'/>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('cancel', request.options.formType)
+
+
+    def test_fromElementConfigureSetBadFormType(self):
+        """
+        The form of a node configuraton set request should have the right type.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='result'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='x-myfield'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual("Unexpected form type 'result'", err.text)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementConfigureSetNoForm(self):
+        """
+        On a node configuration set request a form is required.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'/>
+          </pubsub>
+        </iq>
+        """
+        err = self.assertRaises(error.StanzaError,
+                                pubsub.PubSubRequest.fromElement,
+                                parseXml(xml))
+        self.assertEqual('bad-request', err.condition)
+        self.assertEqual(None, err.appCondition)
+
+
+    def test_fromElementItems(self):
+        """
+        Test parsing an items request.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('items', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertIdentical(None, request.maxItems)
+        self.assertIdentical(None, request.subscriptionIdentifier)
+        self.assertEqual([], request.itemIdentifiers)
+
+
+    def test_fromElementItemsSubscriptionIdentifier(self):
+        """
+        Test parsing an items request with subscription identifier.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test' subid='1234'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('1234', request.subscriptionIdentifier)
+
+
+    def test_fromElementRetract(self):
+        """
+        Test parsing a retract request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <retract node='test'>
+              <item id='item1'/>
+              <item id='item2'/>
+            </retract>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('retract', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+        self.assertEqual(['item1', 'item2'], request.itemIdentifiers)
+
+
+    def test_fromElementPurge(self):
+        """
+        Test parsing a purge request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <purge node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('purge', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+
+
+    def test_fromElementDelete(self):
+        """
+        Test parsing a delete request.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <delete node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        request = pubsub.PubSubRequest.fromElement(parseXml(xml))
+        self.assertEqual('delete', request.verb)
+        self.assertEqual(JID('user@example.org'), request.sender)
+        self.assertEqual(JID('pubsub.example.org'), request.recipient)
+        self.assertEqual('test', request.nodeIdentifier)
+
+
+
+class PubSubServiceTest(unittest.TestCase, TestableRequestHandlerMixin):
+    """
+    Tests for L{pubsub.PubSubService}.
+    """
+
+    def setUp(self):
+        self.stub = XmlStreamStub()
+        self.resource = pubsub.PubSubResource()
+        self.service = pubsub.PubSubService(self.resource)
+        self.service.send = self.stub.xmlstream.send
+
+    def test_interface(self):
+        """
+        Do instances of L{pubsub.PubSubService} provide L{iwokkel.IPubSubService}?
+        """
+        verify.verifyObject(iwokkel.IPubSubService, self.service)
+
+
+    def test_interfaceIDisco(self):
+        """
+        Do instances of L{pubsub.PubSubService} provide L{iwokkel.IDisco}?
+        """
+        verify.verifyObject(iwokkel.IDisco, self.service)
+
+
+    def test_connectionMade(self):
+        """
+        Verify setup of observers in L{pubsub.connectionMade}.
+        """
+        requests = []
+
+        def handleRequest(iq):
+            requests.append(iq)
+
+        self.service.xmlstream = self.stub.xmlstream
+        self.service.handleRequest = handleRequest
+        self.service.connectionMade()
+
+        for namespace in (NS_PUBSUB, NS_PUBSUB_OWNER):
+            for stanzaType in ('get', 'set'):
+                iq = domish.Element((None, 'iq'))
+                iq['type'] = stanzaType
+                iq.addElement((namespace, 'pubsub'))
+                self.stub.xmlstream.dispatch(iq)
+
+        self.assertEqual(4, len(requests))
+
+
+    def test_getDiscoInfo(self):
+        """
+        Test getDiscoInfo calls getNodeInfo and returns some minimal info.
+        """
+        def cb(info):
+            discoInfo = disco.DiscoInfo()
+            for item in info:
+                discoInfo.append(item)
+            self.assertIn(('pubsub', 'service'), discoInfo.identities)
+            self.assertIn(disco.NS_DISCO_ITEMS, discoInfo.features)
+
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoInfoNodeType(self):
+        """
+        Test getDiscoInfo with node type.
+        """
+        def cb(info):
+            discoInfo = disco.DiscoInfo()
+            for item in info:
+                discoInfo.append(item)
+            self.assertIn(('pubsub', 'collection'), discoInfo.identities)
+
+        def getInfo(requestor, target, nodeIdentifier):
+            return defer.succeed({'type': 'collection',
+                                  'meta-data': {}})
+
+        self.resource.getInfo = getInfo
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoInfoMetaData(self):
+        """
+        Test getDiscoInfo with returned meta data.
+        """
+        def cb(info):
+            discoInfo = disco.DiscoInfo()
+            for item in info:
+                discoInfo.append(item)
+
+            self.assertIn(('pubsub', 'leaf'), discoInfo.identities)
+            self.assertIn(NS_PUBSUB_META_DATA, discoInfo.extensions)
+            form = discoInfo.extensions[NS_PUBSUB_META_DATA]
+            self.assertIn('pubsub#node_type', form.fields)
+
+        def getInfo(requestor, target, nodeIdentifier):
+            metaData = [{'var': 'pubsub#persist_items',
+                         'label': 'Persist items to storage',
+                         'value': True}]
+            return defer.succeed({'type': 'leaf', 'meta-data': metaData})
+
+        self.resource.getInfo = getInfo
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoInfoResourceFeatures(self):
+        """
+        Test getDiscoInfo with the resource features.
+        """
+        def cb(info):
+            discoInfo = disco.DiscoInfo()
+            for item in info:
+                discoInfo.append(item)
+            self.assertIn('http://jabber.org/protocol/pubsub#publish',
+                          discoInfo.features)
+
+        self.resource.features = ['publish']
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoInfoBadResponse(self):
+        """
+        If getInfo returns invalid response, it should be logged, then ignored.
+        """
+        def cb(info):
+            self.assertEquals([], info)
+            self.assertEqual(1, len(self.flushLoggedErrors(TypeError)))
+
+        def getInfo(requestor, target, nodeIdentifier):
+            return defer.succeed('bad response')
+
+        self.resource.getInfo = getInfo
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), 'test')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoInfoException(self):
+        """
+        If getInfo returns invalid response, it should be logged, then ignored.
+        """
+        def cb(info):
+            self.assertEquals([], info)
+            self.assertEqual(1, len(self.flushLoggedErrors(NotImplementedError)))
+
+        def getInfo(requestor, target, nodeIdentifier):
+            return defer.fail(NotImplementedError())
+
+        self.resource.getInfo = getInfo
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), 'test')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoItemsRoot(self):
+        """
+        Test getDiscoItems on the root node.
+        """
+        def getNodes(requestor, service, nodeIdentifier):
+            return defer.succeed(['node1', 'node2'])
+
+        def cb(items):
+            self.assertEqual(2, len(items))
+            item1, item2 = items
+
+            self.assertEqual(JID('pubsub.example.org'), item1.entity)
+            self.assertEqual('node1', item1.nodeIdentifier)
+
+            self.assertEqual(JID('pubsub.example.org'), item2.entity)
+            self.assertEqual('node2', item2.nodeIdentifier)
+
+        self.resource.getNodes = getNodes
+        d = self.service.getDiscoItems(JID('user@example.org/home'),
+                                       JID('pubsub.example.org'),
+                                       '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoItemsRootHideNodes(self):
+        """
+        Test getDiscoItems on the root node.
+        """
+        def getNodes(requestor, service, nodeIdentifier):
+            raise Exception("Unexpected call to getNodes")
+
+        def cb(items):
+            self.assertEqual([], items)
+
+        self.service.hideNodes = True
+        self.resource.getNodes = getNodes
+        d = self.service.getDiscoItems(JID('user@example.org/home'),
+                                       JID('pubsub.example.org'),
+                                       '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDiscoItemsNonRoot(self):
+        """
+        Test getDiscoItems on a non-root node.
+        """
+        def getNodes(requestor, service, nodeIdentifier):
+            return defer.succeed(['node1', 'node2'])
+
+        def cb(items):
+            self.assertEqual(2, len(items))
+
+        self.resource.getNodes = getNodes
+        d = self.service.getDiscoItems(JID('user@example.org/home'),
+                                       JID('pubsub.example.org'),
+                                       'test')
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_publish(self):
+        """
+        A publish request should result in L{PubSubService.publish} being
+        called.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def publish(request):
+            return defer.succeed(None)
+
+        self.resource.publish = publish
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_subscribe(self):
+        """
+        A successful subscription should return the current subscription.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def subscribe(request):
+            return defer.succeed(pubsub.Subscription(request.nodeIdentifier,
+                                                     request.subscriber,
+                                                     'subscribed'))
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB, element.uri)
+            subscription = element.subscription
+            self.assertEqual(NS_PUBSUB, subscription.uri)
+            self.assertEqual('test', subscription['node'])
+            self.assertEqual('user@example.org/Home', subscription['jid'])
+            self.assertEqual('subscribed', subscription['subscription'])
+
+        self.resource.subscribe = subscribe
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_subscribeEmptyNode(self):
+        """
+        A successful subscription on root node should return no node attribute.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def subscribe(request):
+            return defer.succeed(pubsub.Subscription(request.nodeIdentifier,
+                                                     request.subscriber,
+                                                     'subscribed'))
+
+        def cb(element):
+            self.assertFalse(element.subscription.hasAttribute('node'))
+
+        self.resource.subscribe = subscribe
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_subscribeSubscriptionIdentifier(self):
+        """
+        If a subscription returns a subid, this should be available.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def subscribe(request):
+            subscription = pubsub.Subscription(request.nodeIdentifier,
+                                               request.subscriber,
+                                               'subscribed',
+                                               subscriptionIdentifier='1234')
+            return defer.succeed(subscription)
+
+        def cb(element):
+            self.assertEqual('1234', element.subscription.getAttribute('subid'))
+
+        self.resource.subscribe = subscribe
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_unsubscribe(self):
+        """
+        A successful unsubscription should return an empty response.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def unsubscribe(request):
+            return defer.succeed(None)
+
+        def cb(element):
+            self.assertIdentical(None, element)
+
+        self.resource.unsubscribe = unsubscribe
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_unsubscribeSubscriptionIdentifier(self):
+        """
+        A successful unsubscription with subid should return an empty response.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test' jid='user@example.org/Home' subid='1234'/>
+          </pubsub>
+        </iq>
+        """
+
+        def unsubscribe(request):
+            self.assertEqual('1234', request.subscriptionIdentifier)
+            return defer.succeed(None)
+
+        def cb(element):
+            self.assertIdentical(None, element)
+
+        self.resource.unsubscribe = unsubscribe
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_optionsGet(self):
+        """
+        Getting subscription options is not supported.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_optionsSet(self):
+        """
+        Setting subscription options is not supported.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <options node='test' jid='user@example.org/Home'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#subscribe_options</value>
+                </field>
+                <field var='pubsub#deliver'><value>1</value></field>
+              </x>
+            </options>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_subscriptions(self):
+        """
+        A subscriptions request should result in
+        L{PubSubService.subscriptions} being called and the result prepared
+        for the response.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        def subscriptions(request):
+            subscription = pubsub.Subscription('test', JID('user@example.org'),
+                                               'subscribed')
+            return defer.succeed([subscription])
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB, element.uri)
+            self.assertEqual(NS_PUBSUB, element.subscriptions.uri)
+            children = list(element.subscriptions.elements())
+            self.assertEqual(1, len(children))
+            subscription = children[0]
+            self.assertEqual('subscription', subscription.name)
+            self.assertEqual(NS_PUBSUB, subscription.uri, NS_PUBSUB)
+            self.assertEqual('user@example.org', subscription['jid'])
+            self.assertEqual('test', subscription['node'])
+            self.assertEqual('subscribed', subscription['subscription'])
+
+        self.resource.subscriptions = subscriptions
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_subscriptionsWithSubscriptionIdentifier(self):
+        """
+        A subscriptions request response should include subids, if set.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        def subscriptions(request):
+            subscription = pubsub.Subscription('test', JID('user@example.org'),
+                                               'subscribed',
+                                               subscriptionIdentifier='1234')
+            return defer.succeed([subscription])
+
+        def cb(element):
+            subscription = element.subscriptions.subscription
+            self.assertEqual('1234', subscription['subid'])
+
+        self.resource.subscriptions = subscriptions
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliations(self):
+        """
+        A subscriptions request should result in
+        L{PubSubService.affiliations} being called and the result prepared
+        for the response.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <affiliations/>
+          </pubsub>
+        </iq>
+        """
+
+        def affiliations(request):
+            affiliation = ('test', 'owner')
+            return defer.succeed([affiliation])
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB, element.uri)
+            self.assertEqual(NS_PUBSUB, element.affiliations.uri)
+            children = list(element.affiliations.elements())
+            self.assertEqual(1, len(children))
+            affiliation = children[0]
+            self.assertEqual('affiliation', affiliation.name)
+            self.assertEqual(NS_PUBSUB, affiliation.uri)
+            self.assertEqual('test', affiliation['node'])
+            self.assertEqual('owner', affiliation['affiliation'])
+
+        self.resource.affiliations = affiliations
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_create(self):
+        """
+        Replies to create node requests don't return the created node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        def create(request):
+            return defer.succeed(request.nodeIdentifier)
+
+        def cb(element):
+            self.assertIdentical(None, element)
+
+        self.resource.create = create
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_createChanged(self):
+        """
+        Replies to create node requests return the created node if changed.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        def create(request):
+            return defer.succeed(u'myrenamednode')
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB, element.uri)
+            self.assertEqual(NS_PUBSUB, element.create.uri)
+            self.assertEqual(u'myrenamednode',
+                             element.create.getAttribute('node'))
+
+        self.resource.create = create
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_createInstant(self):
+        """
+        Replies to create instant node requests return the created node.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create/>
+          </pubsub>
+        </iq>
+        """
+
+        def create(request):
+            return defer.succeed(u'random')
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB, element.uri)
+            self.assertEqual(NS_PUBSUB, element.create.uri)
+            self.assertEqual(u'random', element.create.getAttribute('node'))
+
+        self.resource.create = create
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_createWithConfig(self):
+        """
+        On a node create with configuration request the Data Form is parsed and
+        L{PubSubResource.create} is called with the passed options.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+            <configure>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='pubsub#persist_items'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            return {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"}
+                }
+
+        def create(request):
+            self.assertEqual({'pubsub#deliver_payloads': False,
+                              'pubsub#persist_items': True},
+                             request.options.getValues())
+            return defer.succeed(None)
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.create = create
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_default(self):
+        """
+        A default request returns default options filtered by available fields.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <default/>
+          </pubsub>
+        </iq>
+        """
+        fieldDefs = {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"}
+                }
+
+        def getConfigurationOptions():
+            return fieldDefs
+
+        def default(request):
+            return defer.succeed({'pubsub#persist_items': 'false',
+                                  'x-myfield': '1'})
+
+        def cb(element):
+            self.assertEquals('pubsub', element.name)
+            self.assertEquals(NS_PUBSUB_OWNER, element.uri)
+            self.assertEquals(NS_PUBSUB_OWNER, element.default.uri)
+            form = data_form.Form.fromElement(element.default.x)
+            self.assertEquals(NS_PUBSUB_NODE_CONFIG, form.formNamespace)
+            form.typeCheck(fieldDefs)
+            self.assertIn('pubsub#persist_items', form.fields)
+            self.assertFalse(form.fields['pubsub#persist_items'].value)
+            self.assertNotIn('x-myfield', form.fields)
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.default = default
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_defaultUnknownNodeType(self):
+        """
+        Unknown node types yield non-acceptable.
+
+        Both C{getConfigurationOptions} and C{default} must not be called.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <default>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#node_type'>
+                  <value>unknown</value>
+                </field>
+              </x>
+            </default>
+
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            self.fail("Unexpected call to getConfigurationOptions")
+
+        def default(request):
+            self.fail("Unexpected call to default")
+
+        def cb(result):
+            self.assertEquals('not-acceptable', result.condition)
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.default = default
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_configureGet(self):
+        """
+        On a node configuration get
+        requestL{PubSubResource.configureGet} is called and results in a
+        data form with the configuration.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            return {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"},
+                "pubsub#owner":
+                    {"type": "jid-single",
+                     "label": "Owner of the node"}
+                }
+
+        def configureGet(request):
+            return defer.succeed({'pubsub#deliver_payloads': '0',
+                                  'pubsub#persist_items': '1',
+                                  'pubsub#owner': JID('user@example.org'),
+                                  'x-myfield': 'a'})
+
+        def cb(element):
+            self.assertEqual('pubsub', element.name)
+            self.assertEqual(NS_PUBSUB_OWNER, element.uri)
+            self.assertEqual(NS_PUBSUB_OWNER, element.configure.uri)
+            form = data_form.Form.fromElement(element.configure.x)
+            self.assertEqual(NS_PUBSUB_NODE_CONFIG, form.formNamespace)
+            fields = form.fields
+
+            self.assertIn('pubsub#deliver_payloads', fields)
+            field = fields['pubsub#deliver_payloads']
+            self.assertEqual('boolean', field.fieldType)
+            field.typeCheck()
+            self.assertEqual(False, field.value)
+
+            self.assertIn('pubsub#persist_items', fields)
+            field = fields['pubsub#persist_items']
+            self.assertEqual('boolean', field.fieldType)
+            field.typeCheck()
+            self.assertEqual(True, field.value)
+
+            self.assertIn('pubsub#owner', fields)
+            field = fields['pubsub#owner']
+            self.assertEqual('jid-single', field.fieldType)
+            field.typeCheck()
+            self.assertEqual(JID('user@example.org'), field.value)
+
+            self.assertNotIn('x-myfield', fields)
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.configureGet = configureGet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_configureSet(self):
+        """
+        On a node configuration set request the Data Form is parsed and
+        L{PubSubResource.configureSet} is called with the passed options.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='pubsub#persist_items'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            return {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"}
+                }
+
+        def configureSet(request):
+            self.assertEqual({'pubsub#deliver_payloads': False,
+                              'pubsub#persist_items': True},
+                             request.options.getValues())
+            return defer.succeed(None)
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.configureSet = configureSet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_configureSetCancel(self):
+        """
+        The node configuration is cancelled,
+        L{PubSubResource.configureSet} not called.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='cancel'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def configureSet(request):
+            self.fail("Unexpected call to setConfiguration")
+
+        self.resource.configureSet = configureSet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_configureSetIgnoreUnknown(self):
+        """
+        On a node configuration set request unknown fields should be ignored.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='x-myfield'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            return {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"}
+                }
+
+        def configureSet(request):
+            self.assertEquals(['pubsub#deliver_payloads'],
+                              request.options.keys())
+
+        self.resource.getConfigurationOptions = getConfigurationOptions
+        self.resource.configureSet = configureSet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_configureSetBadFormType(self):
+        """
+        On a node configuration set request unknown fields should be ignored.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='result'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='x-myfield'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('bad-request', result.condition)
+            self.assertEqual("Unexpected form type 'result'", result.text)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_items(self):
+        """
+        On a items request, return all items for the given node.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def items(request):
+            return defer.succeed([pubsub.Item('current')])
+
+        def cb(element):
+            self.assertEqual(NS_PUBSUB, element.uri)
+            self.assertEqual(NS_PUBSUB, element.items.uri)
+            self.assertEqual(1, len(element.items.children))
+            item = element.items.children[-1]
+            self.assertTrue(domish.IElement.providedBy(item))
+            self.assertEqual('item', item.name)
+            self.assertEqual(NS_PUBSUB, item.uri)
+            self.assertEqual('current', item['id'])
+
+        self.resource.items = items
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_retract(self):
+        """
+        A retract request should result in L{PubSubResource.retract}
+        being called.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <retract node='test'>
+              <item id='item1'/>
+              <item id='item2'/>
+            </retract>
+          </pubsub>
+        </iq>
+        """
+
+        def retract(request):
+            return defer.succeed(None)
+
+        self.resource.retract = retract
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_purge(self):
+        """
+        A purge request should result in L{PubSubResource.purge} being
+        called.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <purge node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def purge(request):
+            return defer.succeed(None)
+
+        self.resource.purge = purge
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_on_delete(self):
+        """
+        A delete request should result in L{PubSubResource.delete} being
+        called.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <delete node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def delete(request):
+            return defer.succeed(None)
+
+        self.resource.delete = delete
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        return self.handleRequest(xml)
+
+
+    def test_notifyPublish(self):
+        """
+        Publish notifications are sent to the subscribers.
+        """
+        subscriber = JID('user@example.org')
+        subscriptions = [pubsub.Subscription('test', subscriber, 'subscribed')]
+        items = [pubsub.Item('current')]
+        notifications = [(subscriber, subscriptions, items)]
+        self.service.notifyPublish(JID('pubsub.example.org'), 'test',
+                                   notifications)
+        message = self.stub.output[-1]
+
+        self.assertEquals('message', message.name)
+        self.assertIdentical(None, message.uri)
+        self.assertEquals('user@example.org', message['to'])
+        self.assertEquals('pubsub.example.org', message['from'])
+        self.assertTrue(message.event)
+        self.assertEquals(NS_PUBSUB_EVENT, message.event.uri)
+        self.assertTrue(message.event.items)
+        self.assertEquals(NS_PUBSUB_EVENT, message.event.items.uri)
+        self.assertTrue(message.event.items.hasAttribute('node'))
+        self.assertEquals('test', message.event.items['node'])
+        itemElements = list(domish.generateElementsQNamed(
+            message.event.items.children, 'item', NS_PUBSUB_EVENT))
+        self.assertEquals(1, len(itemElements))
+        self.assertEquals('current', itemElements[0].getAttribute('id'))
+
+
+    def test_notifyPublishCollection(self):
+        """
+        Publish notifications are sent to the subscribers of collections.
+
+        The node the item was published to is on the C{items} element, while
+        the subscribed-to node is in the C{'Collections'} SHIM header.
+        """
+        subscriber = JID('user@example.org')
+        subscriptions = [pubsub.Subscription('', subscriber, 'subscribed')]
+        items = [pubsub.Item('current')]
+        notifications = [(subscriber, subscriptions, items)]
+        self.service.notifyPublish(JID('pubsub.example.org'), 'test',
+                                   notifications)
+        message = self.stub.output[-1]
+
+        self.assertTrue(message.event.items.hasAttribute('node'))
+        self.assertEquals('test', message.event.items['node'])
+        headers = shim.extractHeaders(message)
+        self.assertIn('Collection', headers)
+        self.assertIn('', headers['Collection'])
+
+
+    def test_notifyDelete(self):
+        """
+        Subscribers should be sent a delete notification.
+        """
+        subscriptions = [JID('user@example.org')]
+        self.service.notifyDelete(JID('pubsub.example.org'), 'test',
+                                  subscriptions)
+        message = self.stub.output[-1]
+
+        self.assertEquals('message', message.name)
+        self.assertIdentical(None, message.uri)
+        self.assertEquals('user@example.org', message['to'])
+        self.assertEquals('pubsub.example.org', message['from'])
+        self.assertTrue(message.event)
+        self.assertEqual(NS_PUBSUB_EVENT, message.event.uri)
+        self.assertTrue(message.event.delete)
+        self.assertEqual(NS_PUBSUB_EVENT, message.event.delete.uri)
+        self.assertTrue(message.event.delete.hasAttribute('node'))
+        self.assertEqual('test', message.event.delete['node'])
+
+
+    def test_notifyDeleteRedirect(self):
+        """
+        Subscribers should be sent a delete notification with redirect.
+        """
+        redirectURI = 'xmpp:pubsub.example.org?;node=test2'
+        subscriptions = [JID('user@example.org')]
+        self.service.notifyDelete(JID('pubsub.example.org'), 'test',
+                                  subscriptions, redirectURI)
+        message = self.stub.output[-1]
+
+        self.assertEquals('message', message.name)
+        self.assertIdentical(None, message.uri)
+        self.assertEquals('user@example.org', message['to'])
+        self.assertEquals('pubsub.example.org', message['from'])
+        self.assertTrue(message.event)
+        self.assertEqual(NS_PUBSUB_EVENT, message.event.uri)
+        self.assertTrue(message.event.delete)
+        self.assertEqual(NS_PUBSUB_EVENT, message.event.delete.uri)
+        self.assertTrue(message.event.delete.hasAttribute('node'))
+        self.assertEqual('test', message.event.delete['node'])
+        self.assertTrue(message.event.delete.redirect)
+        self.assertEqual(NS_PUBSUB_EVENT, message.event.delete.redirect.uri)
+        self.assertTrue(message.event.delete.redirect.hasAttribute('uri'))
+        self.assertEqual(redirectURI, message.event.delete.redirect['uri'])
+
+
+    def test_on_subscriptionsGet(self):
+        """
+        Getting subscription options is not supported.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('manage-subscriptions',
+                              result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_subscriptionsSet(self):
+        """
+        Setting subscription options is not supported.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('manage-subscriptions',
+                              result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliationsGet(self):
+        """
+        Getting node affiliations should have.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def affiliationsGet(request):
+            self.assertEquals('test', request.nodeIdentifier)
+            return defer.succeed({JID('user@example.org'): 'owner'})
+
+        def cb(element):
+            self.assertEquals(u'pubsub', element.name)
+            self.assertEquals(NS_PUBSUB_OWNER, element.uri)
+            self.assertEquals(NS_PUBSUB_OWNER, element.affiliations.uri)
+            self.assertEquals(u'test', element.affiliations[u'node'])
+            children = list(element.affiliations.elements())
+            self.assertEquals(1, len(children))
+            affiliation = children[0]
+            self.assertEquals(u'affiliation', affiliation.name)
+            self.assertEquals(NS_PUBSUB_OWNER, affiliation.uri)
+            self.assertEquals(u'user@example.org', affiliation[u'jid'])
+            self.assertEquals(u'owner', affiliation[u'affiliation'])
+
+        self.resource.affiliationsGet = affiliationsGet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliationsGetEmptyNode(self):
+        """
+        Getting node affiliations without node should assume empty node.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations/>
+          </pubsub>
+        </iq>
+        """
+
+        def affiliationsGet(request):
+            self.assertEqual('', request.nodeIdentifier)
+            return defer.succeed({})
+
+        def cb(element):
+            self.assertFalse(element.affiliations.hasAttribute(u'node'))
+
+        self.resource.affiliationsGet = affiliationsGet
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliationsSet(self):
+        """
+        Setting node affiliations has the affiliations to be modified.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'>
+              <affiliation jid='other@example.org' affiliation='publisher'/>
+            </affiliations>
+          </pubsub>
+        </iq>
+        """
+
+        def affiliationsSet(request):
+            self.assertEquals(u'test', request.nodeIdentifier)
+            otherJID = JID(u'other@example.org')
+            self.assertIn(otherJID, request.affiliations)
+            self.assertEquals(u'publisher', request.affiliations[otherJID])
+
+        self.resource.affiliationsSet = affiliationsSet
+        return self.handleRequest(xml)
+
+
+    def test_on_affiliationsSetBareJID(self):
+        """
+        Affiliations are always on the bare JID.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'>
+              <affiliation jid='other@example.org/Home'
+                           affiliation='publisher'/>
+            </affiliations>
+          </pubsub>
+        </iq>
+        """
+
+        def affiliationsSet(request):
+            otherJID = JID(u'other@example.org')
+            self.assertIn(otherJID, request.affiliations)
+
+        self.resource.affiliationsSet = affiliationsSet
+        return self.handleRequest(xml)
+
+
+    def test_on_affiliationsSetMultipleForSameEntity(self):
+        """
+        Setting node affiliations can only have one item per entity.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'>
+              <affiliation jid='other@example.org' affiliation='publisher'/>
+              <affiliation jid='other@example.org' affiliation='owner'/>
+            </affiliations>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('bad-request', result.condition)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliationsSetMissingJID(self):
+        """
+        Setting node affiliations must include a JID per affiliation.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'>
+              <affiliation affiliation='publisher'/>
+            </affiliations>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('bad-request', result.condition)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_on_affiliationsSetMissingAffiliation(self):
+        """
+        Setting node affiliations must include an affiliation.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'>
+              <affiliation jid='other@example.org'/>
+            </affiliations>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('bad-request', result.condition)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+
+class PubSubServiceWithoutResourceTest(unittest.TestCase, TestableRequestHandlerMixin):
+
+    def setUp(self):
+        self.stub = XmlStreamStub()
+        self.service = pubsub.PubSubService()
+        self.service.send = self.stub.xmlstream.send
+
+
+    def test_getDiscoInfo(self):
+        """
+        Test getDiscoInfo calls getNodeInfo and returns some minimal info.
+        """
+        def cb(info):
+            discoInfo = disco.DiscoInfo()
+            for item in info:
+                discoInfo.append(item)
+            self.assertIn(('pubsub', 'service'), discoInfo.identities)
+            self.assertIn(disco.NS_DISCO_ITEMS, discoInfo.features)
+
+        d = self.service.getDiscoInfo(JID('user@example.org/home'),
+                                      JID('pubsub.example.org'), '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_publish(self):
+        """
+        Non-overridden L{PubSubService.publish} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <publish node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('publish', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_subscribe(self):
+        """
+        Non-overridden L{PubSubService.subscribe} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('subscribe', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_unsubscribe(self):
+        """
+        Non-overridden L{PubSubService.unsubscribe} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <unsubscribe node='test' jid='user@example.org/Home'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('subscribe', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_subscriptions(self):
+        """
+        Non-overridden L{PubSubService.subscriptions} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <subscriptions/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-subscriptions',
+                              result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_affiliations(self):
+        """
+        Non-overridden L{PubSubService.affiliations} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <affiliations/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-affiliations',
+                              result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_create(self):
+        """
+        Non-overridden L{PubSubService.create} yields unsupported error.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <create node='mynode'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('create-nodes', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_getDefaultConfiguration(self):
+        """
+        Non-overridden L{PubSubService.getDefaultConfiguration} yields
+        unsupported error.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <default/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-default', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_getConfiguration(self):
+        """
+        Non-overridden L{PubSubService.getConfiguration} yields unsupported
+        error.
+        """
+
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('config-node', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_setConfiguration(self):
+        """
+        Non-overridden L{PubSubService.setConfiguration} yields unsupported
+        error.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='pubsub#persist_items'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('config-node', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_setConfigurationOptionsDict(self):
+        """
+        Options should be passed as a dictionary, not a form.
+        """
+
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <configure node='test'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'>
+                  <value>http://jabber.org/protocol/pubsub#node_config</value>
+                </field>
+                <field var='pubsub#deliver_payloads'><value>0</value></field>
+                <field var='pubsub#persist_items'><value>1</value></field>
+              </x>
+            </configure>
+          </pubsub>
+        </iq>
+        """
+
+        def getConfigurationOptions():
+            return {
+                "pubsub#persist_items":
+                    {"type": "boolean",
+                     "label": "Persist items to storage"},
+                "pubsub#deliver_payloads":
+                    {"type": "boolean",
+                     "label": "Deliver payloads with event notifications"}
+                }
+
+        def setConfiguration(requestor, service, nodeIdentifier, options):
+            self.assertIn('pubsub#deliver_payloads', options)
+            self.assertFalse(options['pubsub#deliver_payloads'])
+            self.assertIn('pubsub#persist_items', options)
+            self.assertTrue(options['pubsub#persist_items'])
+
+        self.service.getConfigurationOptions = getConfigurationOptions
+        self.service.setConfiguration = setConfiguration
+        return self.handleRequest(xml)
+
+
+    def test_items(self):
+        """
+        Non-overridden L{PubSubService.items} yields unsupported error.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-items', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_retract(self):
+        """
+        Non-overridden L{PubSubService.retract} yields unsupported error.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <retract node='test'>
+              <item id='item1'/>
+              <item id='item2'/>
+            </retract>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retract-items', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_purge(self):
+        """
+        Non-overridden L{PubSubService.purge} yields unsupported error.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <purge node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('purge-nodes', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_delete(self):
+        """
+        Non-overridden L{PubSubService.delete} yields unsupported error.
+        """
+        xml = """
+        <iq type='set' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <delete node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('delete-nodes', result.appCondition['feature'])
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_unknown(self):
+        """
+        Unknown verb yields unsupported error.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+            <affiliations node='test'/>
+          </pubsub>
+        </iq>
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+
+        d = self.handleRequest(xml)
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+
+class PubSubResourceTest(unittest.TestCase):
+
+    def setUp(self):
+        self.resource = pubsub.PubSubResource()
+
+
+    def test_interface(self):
+        """
+        Do instances of L{pubsub.PubSubResource} provide L{iwokkel.IPubSubResource}?
+        """
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+
+
+    def test_getNodes(self):
+        """
+        Default getNodes returns an empty list.
+        """
+        def cb(nodes):
+            self.assertEquals([], nodes)
+
+        d = self.resource.getNodes(JID('user@example.org/home'),
+                                   JID('pubsub.example.org'),
+                                   '')
+        d.addCallback(cb)
+        return d
+
+
+    def test_publish(self):
+        """
+        Non-overridden L{PubSubResource.publish} yields unsupported
+        error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('publish', result.appCondition['feature'])
+
+        d = self.resource.publish(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_subscribe(self):
+        """
+        Non-overridden subscriptions yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('subscribe', result.appCondition['feature'])
+
+        d = self.resource.subscribe(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_unsubscribe(self):
+        """
+        Non-overridden unsubscribe yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('subscribe', result.appCondition['feature'])
+
+        d = self.resource.unsubscribe(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_subscriptions(self):
+        """
+        Non-overridden subscriptions yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-subscriptions',
+                              result.appCondition['feature'])
+
+        d = self.resource.subscriptions(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_affiliations(self):
+        """
+        Non-overridden affiliations yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-affiliations',
+                              result.appCondition['feature'])
+
+        d = self.resource.affiliations(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_create(self):
+        """
+        Non-overridden create yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('create-nodes', result.appCondition['feature'])
+
+        d = self.resource.create(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_default(self):
+        """
+        Non-overridden default yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-default',
+                              result.appCondition['feature'])
+
+        d = self.resource.default(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_configureGet(self):
+        """
+        Non-overridden configureGet yields unsupported
+        error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('config-node', result.appCondition['feature'])
+
+        d = self.resource.configureGet(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_configureSet(self):
+        """
+        Non-overridden configureSet yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('config-node', result.appCondition['feature'])
+
+        d = self.resource.configureSet(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_items(self):
+        """
+        Non-overridden items yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retrieve-items', result.appCondition['feature'])
+
+        d = self.resource.items(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_retract(self):
+        """
+        Non-overridden retract yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('retract-items', result.appCondition['feature'])
+
+        d = self.resource.retract(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_purge(self):
+        """
+        Non-overridden purge yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('purge-nodes', result.appCondition['feature'])
+
+        d = self.resource.purge(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_delete(self):
+        """
+        Non-overridden delete yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('delete-nodes', result.appCondition['feature'])
+
+        d = self.resource.delete(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_affiliationsGet(self):
+        """
+        Non-overridden owner affiliations get yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('modify-affiliations',
+                              result.appCondition['feature'])
+
+        d = self.resource.affiliationsGet(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
+
+
+    def test_affiliationsSet(self):
+        """
+        Non-overridden owner affiliations set yields unsupported error.
+        """
+
+        def cb(result):
+            self.assertEquals('feature-not-implemented', result.condition)
+            self.assertEquals('unsupported', result.appCondition.name)
+            self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri)
+            self.assertEquals('modify-affiliations',
+                              result.appCondition['feature'])
+
+        d = self.resource.affiliationsSet(pubsub.PubSubRequest())
+        self.assertFailure(d, error.StanzaError)
+        d.addCallback(cb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_tmp/wokkel/test/test_rsm.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,662 @@
+# Copyright (c) Adrien Cossa.
+# See LICENSE for details.
+
+"""
+Tests for L{wokkel.rsm}.
+"""
+
+from zope.interface import verify
+
+from twisted.trial import unittest
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.protocols.jabber.xmlstream import toResponse
+from twisted.internet import defer
+
+from wokkel.generic import parseXml
+from wokkel import iwokkel
+from wokkel.test.helpers import XmlStreamStub, TestableRequestHandlerMixin
+
+from sat.tmp.wokkel import pubsub
+from sat.tmp.wokkel.rsm import NS_RSM, RSMRequest, RSMResponse, PubSubClient, PubSubService
+
+import uuid
+
+RSMResponse.__eq__ = lambda self, other: self.first == other.first and\
+    self.last == other.last and\
+    self.index == other.index and\
+    self.count == other.count
+
+class RSMRequestTest(unittest.TestCase):
+    """
+    Tests for L{rsm.RSMRequest}.
+    """
+
+    def test___init__(self):
+        """
+        Fail to initialize a RSMRequest with wrong attribute values.
+        """
+        self.assertRaises(AssertionError, RSMRequest, index=371, after=u'test')
+        self.assertRaises(AssertionError, RSMRequest, index=371, before=u'test')
+        self.assertRaises(AssertionError, RSMRequest, before=117)
+        self.assertRaises(AssertionError, RSMRequest, after=312)
+        self.assertRaises(AssertionError, RSMRequest, after=u'117', before=u'312')
+
+    def test_parse(self):
+        """
+        Parse a request element asking for the first page.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>1</max>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(1, request.max)
+        self.assertIdentical(None, request.index)
+        self.assertIdentical(None, request.after)
+        self.assertIdentical(None, request.before)
+
+    def test_parseSecondPage(self):
+        """
+        Parse a request element asking for a next page.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>3</max>
+            <after>peterpan@neverland.lit</after>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(3, request.max)
+        self.assertIdentical(None, request.index)
+        self.assertEqual(u'peterpan@neverland.lit', request.after)
+        self.assertIdentical(None, request.before)
+
+    def test_parsePreviousPage(self):
+        """
+        Parse a request element asking for a previous page.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>5</max>
+            <before>peterpan@pixyland.org</before>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(5, request.max)
+        self.assertIdentical(None, request.index)
+        self.assertIdentical(None, request.after)
+        self.assertEqual(u'peterpan@pixyland.org', request.before)
+
+    def test_parseLastPage(self):
+        """
+        Parse a request element asking for the last page.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>7</max>
+            <before/>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(7, request.max)
+        self.assertIdentical(None, request.index)
+        self.assertIdentical(None, request.after)
+        self.assertEqual('', request.before)
+
+    def test_parseOutOfOrderPage(self):
+        """
+        Parse a request element asking for a page out of order.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>9</max>
+            <index>371</index>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(9, request.max)
+        self.assertEqual(371, request.index)
+        self.assertIdentical(None, request.after)
+        self.assertIdentical(None, request.before)
+
+    def test_parseItemCount(self):
+        """
+        Parse a request element asking for the items count.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <nick>Pete</nick>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>0</max>
+          </set>
+        </query>
+        """
+        request = RSMRequest.fromElement(parseXml(xml))
+        self.assertEqual(0, request.max)
+        self.assertIdentical(None, request.index)
+        self.assertIdentical(None, request.after)
+        self.assertIdentical(None, request.before)
+
+    def test_render(self):
+        """
+        Embed a page request in the element.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        element.addElement('items')['max_items'] = u'10'
+        RSMRequest(1).render(element)
+
+        self.assertEqual(u'10', element.items['max_items'])  # not changed
+
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'1', ''.join(element.set.max.children))
+        self.assertIdentical(None, element.set.after)
+        self.assertIdentical(None, element.set.before)
+        self.assertIdentical(None, element.set.index)
+
+    def test_renderPubSub(self):
+        """
+        Embed a page request in the pubsub element.
+        """
+        element = domish.Element((pubsub.NS_PUBSUB, 'pubsub'))
+        element.addElement('items')['max_items'] = u'10'
+        RSMRequest(3).render(element)
+
+        self.assertEqual(u'10', element.items['max_items'])  # not changed
+
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'3', ''.join(element.set.max.children))
+        self.assertIdentical(None, element.set.after)
+        self.assertIdentical(None, element.set.before)
+        self.assertIdentical(None, element.set.index)
+
+    def test_renderItems(self):
+        """
+        Embed a page request in the element, specify items.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        RSMRequest(5, index=127).render(element)
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'5', ''.join(element.set.max.children))
+        self.assertIdentical(None, element.set.after)
+        self.assertIdentical(None, element.set.before)
+        self.assertEqual(u'127', ''.join(element.set.index.children))
+
+    def test_renderAfter(self):
+        """
+        Embed a page request in the element, specify after.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        RSMRequest(5, after=u'test').render(element)
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'5', ''.join(element.set.max.children))
+        self.assertEqual(u'test', ''.join(element.set.after.children))
+        self.assertIdentical(None, element.set.before)
+        self.assertIdentical(None, element.set.index)
+
+    def test_renderBefore(self):
+        """
+        Embed a page request in the element, specify before.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        RSMRequest(5, before=u'test').render(element)
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'5', ''.join(element.set.max.children))
+        self.assertIdentical(None, element.set.after)
+        self.assertEqual(u'test', ''.join(element.set.before.children))
+        self.assertIdentical(None, element.set.index)
+
+
+class RSMResponseTest(unittest.TestCase):
+    """
+    Tests for L{rsm.RSMResponse}.
+    """
+
+    def test___init__(self):
+        """
+        Fail to initialize a RSMResponse with wrong attribute values.
+        """
+        self.assertRaises(AssertionError, RSMResponse, index=127, first=u'127')
+        self.assertRaises(AssertionError, RSMResponse, index=127, last=u'351')
+
+    def test_parse(self):
+        """
+        Parse a response element returning a page.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <first index='20'>stpeter@jabber.org</first>
+            <last>peterpan@neverland.lit</last>
+            <count>800</count>
+          </set>
+        </query>
+        """
+        response = RSMResponse.fromElement(parseXml(xml))
+        self.assertEqual(800, response.count)
+        self.assertEqual(20, response.index)
+        self.assertEqual(u'stpeter@jabber.org', response.first)
+        self.assertEqual(u'peterpan@neverland.lit', response.last)
+
+    def test_parseEmptySet(self):
+        """
+        Parse a response element returning an empty set.
+        """
+        xml = """
+        <query xmlns='jabber:iq:search'>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <count>800</count>
+          </set>
+        </query>
+        """
+        response = RSMResponse.fromElement(parseXml(xml))
+        self.assertEqual(800, response.count)
+        self.assertIdentical(None, response.first)
+        self.assertIdentical(None, response.last)
+        self.assertIdentical(None, response.index)
+
+    def test_render(self):
+        """
+        Embed a page response in the element.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        RSMResponse(u'stpeter@jabber.org', u'peterpan@neverland.lit', 20, 800).render(element)
+
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'800', ''.join(element.set.count.children))
+        self.assertEqual(u'stpeter@jabber.org',
+                         ''.join(element.set.first.children))
+        self.assertEqual(u'peterpan@neverland.lit',
+                         ''.join(element.set.last.children))
+        self.assertEqual(u'20', element.set.first['index'])
+
+    def test_renderEmptySet(self):
+        """
+        Embed a page response in the element, for empty set.
+        """
+        element = domish.Element(('jabber:iq:search', 'query'))
+        RSMResponse(count=800).render(element)
+
+        self.assertEqual(NS_RSM, element.set.uri)
+        self.assertEqual(u'800', ''.join(element.set.count.children))
+        self.assertIdentical(None, element.set.first)
+        self.assertIdentical(None, element.set.last)
+
+
+class PubSubClientTest(unittest.TestCase):
+    """
+    Tests for L{rsm.PubSubClient}.
+    """
+    timeout = 2
+
+    def setUp(self):
+        self.stub = XmlStreamStub()
+        self.protocol = PubSubClient()
+        self.protocol.xmlstream = self.stub.xmlstream
+        self.protocol.connectionInitialized()
+
+    def test_items(self):
+        """
+        Test sending items request to get the first page.
+        """
+        def cb(response):
+            items, rsm = response
+            self.assertEquals(2, len(items))
+            self.assertEquals([item1, item2], items)
+            self.assertEquals(rsm, RSMResponse('item1', 'item2', 0, 800))
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                rsm_request=RSMRequest(2))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(pubsub.NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', pubsub.NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        set_elts = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'set', NS_RSM))
+        self.assertEquals(1, len(set_elts))
+        set_elt = set_elts[0]
+        self.assertEquals(u'2', ''.join(set_elt.max.children))
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((pubsub.NS_PUBSUB,
+                                     'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item1'
+        item2 = items.addElement('item')
+        item2['id'] = 'item2'
+        RSMResponse(u'item1', u'item2', 0, 800).render(response.pubsub)
+        self.stub.send(response)
+
+        return d
+
+    def test_itemsAfter(self):
+        """
+        Test sending items request to get the next page.
+        """
+        def cb(response):
+            items, rsm = response
+            self.assertEquals(2, len(items))
+            self.assertEquals([item1, item2], items)
+            self.assertEquals(rsm, RSMResponse('item3', 'item4', 2, 800))
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                rsm_request=RSMRequest(2, after=u'item2'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(pubsub.NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', pubsub.NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        set_elts = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'set', NS_RSM))
+        self.assertEquals(1, len(set_elts))
+        set_elt = set_elts[0]
+        self.assertEquals(u'2', ''.join(set_elt.max.children))
+        self.assertEquals(u'item2', ''.join(set_elt.after.children))
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((pubsub.NS_PUBSUB,
+                                     'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item3'
+        item2 = items.addElement('item')
+        item2['id'] = 'item4'
+        RSMResponse(u'item3', u'item4', 2, 800).render(response.pubsub)
+        self.stub.send(response)
+
+        return d
+
+    def test_itemsBefore(self):
+        """
+        Test sending items request to get the previous page.
+        """
+        def cb(response):
+            items, rsm = response
+            self.assertEquals(2, len(items))
+            self.assertEquals([item1, item2], items)
+            self.assertEquals(rsm, RSMResponse('item1', 'item2', 0, 800))
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                rsm_request=RSMRequest(2, before=u'item3'))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(pubsub.NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', pubsub.NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        set_elts = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'set', NS_RSM))
+        self.assertEquals(1, len(set_elts))
+        set_elt = set_elts[0]
+        self.assertEquals(u'2', ''.join(set_elt.max.children))
+        self.assertEquals(u'item3', ''.join(set_elt.before.children))
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((pubsub.NS_PUBSUB,
+                                     'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item1'
+        item2 = items.addElement('item')
+        item2['id'] = 'item2'
+        RSMResponse(u'item1', u'item2', 0, 800).render(response.pubsub)
+        self.stub.send(response)
+
+        return d
+
+    def test_itemsIndex(self):
+        """
+        Test sending items request to get a page out of order.
+        """
+        def cb(response):
+            items, rsm = response
+            self.assertEquals(3, len(items))
+            self.assertEquals([item1, item2, item3], items)
+            self.assertEquals(rsm, RSMResponse('item4', 'item6', 3, 800))
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                rsm_request=RSMRequest(3, index=3))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(pubsub.NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', pubsub.NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        set_elts = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'set', NS_RSM))
+        self.assertEquals(1, len(set_elts))
+        set_elt = set_elts[0]
+        self.assertEquals(u'3', ''.join(set_elt.max.children))
+        self.assertEquals(u'3', ''.join(set_elt.index.children))
+
+        response = toResponse(iq, 'result')
+        items = response.addElement((pubsub.NS_PUBSUB,
+                                     'pubsub')).addElement('items')
+        items['node'] = 'test'
+        item1 = items.addElement('item')
+        item1['id'] = 'item4'
+        item2 = items.addElement('item')
+        item2['id'] = 'item5'
+        item3 = items.addElement('item')
+        item3['id'] = 'item6'
+        RSMResponse(u'item4', u'item6', 3, 800).render(response.pubsub)
+        self.stub.send(response)
+
+        return d
+
+    def test_itemsCount(self):
+        """
+        Test sending items request to count them.
+        """
+        def cb(response):
+            items, rsm = response
+            self.assertEquals(0, len(items))
+            self.assertEquals(rsm, RSMResponse(count=800))
+
+        d = self.protocol.items(JID('pubsub.example.org'), 'test',
+                                rsm_request=RSMRequest(0))
+        d.addCallback(cb)
+
+        iq = self.stub.output[-1]
+        self.assertEquals('pubsub.example.org', iq.getAttribute('to'))
+        self.assertEquals('get', iq.getAttribute('type'))
+        self.assertEquals('pubsub', iq.pubsub.name)
+        self.assertEquals(pubsub.NS_PUBSUB, iq.pubsub.uri)
+        children = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'items', pubsub.NS_PUBSUB))
+        self.assertEquals(1, len(children))
+        child = children[0]
+        self.assertEquals('test', child['node'])
+
+        set_elts = list(domish.generateElementsQNamed(iq.pubsub.children,
+                                                      'set', NS_RSM))
+        self.assertEquals(1, len(set_elts))
+        set_elt = set_elts[0]
+        self.assertEquals(u'0', ''.join(set_elt.max.children))
+
+        response = toResponse(iq, 'result')
+        response.addElement((pubsub.NS_PUBSUB, 'pubsub'))
+        RSMResponse(count=800).render(response.pubsub)
+        self.stub.send(response)
+
+        return d
+
+
+class PubSubServiceTest(unittest.TestCase, TestableRequestHandlerMixin):
+
+    def setUp(self):
+        self.stub = XmlStreamStub()
+        self.resource = pubsub.PubSubResource()
+        self.service = PubSubService(self.resource)
+        self.service.send = self.stub.xmlstream.send
+
+    def test_on_items(self):
+        """
+        On a items request, return the first item for the given node.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>1</max>
+          </set>
+        </iq>
+        """
+
+        def items(request):
+            rsm = RSMResponse(u'item', u'item', 0, 800).toElement()
+            return defer.succeed([pubsub.Item('current'), rsm])
+
+        def cb(element):
+            self.assertEqual(pubsub.NS_PUBSUB, element.uri)
+            self.assertEqual(pubsub.NS_PUBSUB, element.items.uri)
+            self.assertEqual(1, len(element.items.children))
+            item = element.items.children[-1]
+            self.assertTrue(domish.IElement.providedBy(item))
+            self.assertEqual('item', item.name)
+            self.assertEqual(pubsub.NS_PUBSUB, item.uri)
+            self.assertEqual('current', item['id'])
+            self.assertEqual(NS_RSM, element.set.uri)
+            self.assertEqual('800', ''.join(element.set.count.children))
+            self.assertEqual('0', element.set.first['index'])
+            self.assertEqual('item', ''.join(element.set.first.children))
+            self.assertEqual('item', ''.join(element.set.last.children))
+
+        self.resource.items = items
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+    def test_on_itemsIndex(self):
+        """
+        On a items request, return some items out of order for the given node.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>2</max>
+            <index>3</index>
+          </set>
+        </iq>
+        """
+
+        def items(request):
+            rsm = RSMResponse(u'i1', u'i2', 3, 800).toElement()
+            return defer.succeed([pubsub.Item('i1'), pubsub.Item('i2'), rsm])
+
+        def cb(element):
+            self.assertEqual(pubsub.NS_PUBSUB, element.uri)
+            self.assertEqual(pubsub.NS_PUBSUB, element.items.uri)
+            self.assertEqual(2, len(element.items.children))
+            item = element.items.children[0]
+            self.assertTrue(domish.IElement.providedBy(item))
+            self.assertEqual('item', item.name)
+            self.assertEqual(pubsub.NS_PUBSUB, item.uri)
+            self.assertEqual('i1', item['id'])
+            item = element.items.children[1]
+            self.assertTrue(domish.IElement.providedBy(item))
+            self.assertEqual('item', item.name)
+            self.assertEqual(pubsub.NS_PUBSUB, item.uri)
+            self.assertEqual('i2', item['id'])
+            self.assertEqual(NS_RSM, element.set.uri)
+            self.assertEqual('800', ''.join(element.set.count.children))
+            self.assertEqual('3', element.set.first['index'])
+            self.assertEqual('i1', ''.join(element.set.first.children))
+            self.assertEqual('i2', ''.join(element.set.last.children))
+
+        self.resource.items = items
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
+
+    def test_on_itemsCount(self):
+        """
+        On a items request, return the items count.
+        """
+        xml = """
+        <iq type='get' to='pubsub.example.org'
+                       from='user@example.org'>
+          <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+            <items node='test'/>
+          </pubsub>
+          <set xmlns='http://jabber.org/protocol/rsm'>
+            <max>0</max>
+          </set>
+        </iq>
+        """
+
+        def items(request):
+            rsm = RSMResponse(count=800).toElement()
+            return defer.succeed([rsm])
+
+        def cb(element):
+            self.assertEqual(pubsub.NS_PUBSUB, element.uri)
+            self.assertEqual(pubsub.NS_PUBSUB, element.items.uri)
+            self.assertEqual(0, len(element.items.children))
+            self.assertEqual(NS_RSM, element.set.uri)
+            self.assertEqual('800', ''.join(element.set.count.children))
+            self.assertEqual(None, element.set.first)
+            self.assertEqual(None, element.set.last)
+
+        self.resource.items = items
+        verify.verifyObject(iwokkel.IPubSubResource, self.resource)
+        d = self.handleRequest(xml)
+        d.addCallback(cb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Thu Nov 02 22:50:59 2017 +0100
@@ -0,0 +1,54 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# SàT tmp: repository to store temporarily patches to third party software
+# until they are merged upstream
+# Copyright (C) 2017  Arnaud Joset (info@agayon.be)
+# Copyright (C) 2009-2017  Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+from setuptools import setup, find_packages
+
+
+includefiles = ['COPYING','README']
+include_modules = []
+
+base = None
+NAME = 'sat_tmp'
+is_wheel = 'bdist_wheel' in sys.argv
+
+excluded = []
+
+def create_package_list(base_package):
+    return ([base_package] + [base_package + '.' + pkg for pkg in find_packages(base_package)])
+
+setup_info = dict(
+    name=NAME,
+    version='0.7',
+    author='Association « Salut à Toi »',
+    author_email='contact@salut-a-toi.org',
+    url='https://salut-a-toi.org',
+    classifiers=['Development Status :: 3 - Alpha',
+                 'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
+                 'Operating System :: POSIX :: Linux',
+                 'Topic :: Communications :: Chat'],
+    install_requires=['wokkel >= 0.7.1'],
+    packages=create_package_list('sat_tmp'),
+    zip_safe=True,
+)
+
+setup(**setup_info)
--- a/wokkel/mam.py	Wed Nov 01 22:34:51 2017 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,620 +0,0 @@
-# -*- coding: utf-8 -*-
-# -*- test-case-name: wokkel.test.test_mam -*-
-#
-# SàT Wokkel extension for Message Archive Management (XEP-0313)
-# Copyright (C) 2015 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""
-XMPP Message Archive Management protocol.
-
-This protocol is specified in
-U{XEP-0313<http://xmpp.org/extensions/xep-0313.html>}.
-"""
-
-from dateutil import tz
-
-from zope.interface import implements
-from zope.interface import Interface
-
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error
-from twisted.internet import defer
-from twisted.python import log
-
-from wokkel import subprotocols
-from wokkel import disco
-from wokkel import data_form
-from wokkel import delay
-
-import rsm
-
-NS_MAM = 'urn:xmpp:mam:1'
-NS_FORWARD = 'urn:xmpp:forward:0'
-
-FIELDS_REQUEST = "/iq[@type='get']/query[@xmlns='%s']" % NS_MAM
-ARCHIVE_REQUEST = "/iq[@type='set']/query[@xmlns='%s']" % NS_MAM
-PREFS_GET_REQUEST = "/iq[@type='get']/prefs[@xmlns='%s']" % NS_MAM
-PREFS_SET_REQUEST = "/iq[@type='set']/prefs[@xmlns='%s']" % NS_MAM
-
-# TODO: add the tests!
-
-
-class MAMError(error.StanzaError):
-    """
-    MAM error.
-    """
-    def __init__(self, text=None):
-        error.StanzaError.__init__(self, 'bad-request', text=text)
-
-
-class Unsupported(MAMError):
-    def __init__(self, feature, text=None):
-        self.feature = feature
-        MAMError.__init__(self, 'feature-not-implemented',
-                                'unsupported',
-                                feature,
-                                text)
-
-    def __str__(self):
-        message = MAMError.__str__(self)
-        message += ', feature %r' % self.feature
-        return message
-
-
-class MAMRequest(object):
-    """
-    A Message Archive Management <query/> request.
-
-    @ivar form: Data Form specifing the filters.
-    @itype form: L{data_form.Form}
-
-    @ivar rsm: RSM request instance.
-    @itype rsm: L{rsm.RSMRequest}
-
-    @ivar node: pubsub node id if querying a pubsub node, else None.
-    @itype node: C{unicode}
-
-    @ivar query_id: id to use to track the query
-    @itype query_id: C{unicode}
-    """
-    # FIXME: should be based on generic.Stanza
-
-    def __init__(self, form=None, rsm_=None, node=None, query_id=None, sender=None, recipient=None):
-        if form is not None:
-            assert form.formType == 'submit'
-            assert form.formNamespace == NS_MAM
-        self.form = form
-        self.rsm = rsm_
-        self.node = node
-        self.query_id = query_id
-        self.sender = sender
-        self.recipient = recipient
-
-    @classmethod
-    def fromElement(cls, iq):
-        """Parse the DOM representation of a MAM <query/> request.
-
-        @param iq: <iq/> element containing a MAM <query/>.
-        @type iq: L{Element<twisted.words.xish.domish.Element>}
-
-        @return: MAMRequest instance.
-        @rtype: L{MAMRequest}
-        """
-        sender = jid.JID(iq.getAttribute('from'))
-        recipient = jid.JID(iq.getAttribute('to'))
-        try:
-            query = iq.elements(NS_MAM, 'query').next()
-        except StopIteration:
-            raise MAMError("Can't find MAM <query/> in element")
-        form = data_form.findForm(query, NS_MAM)
-        try:
-            rsm_request = rsm.RSMRequest.fromElement(query)
-        except rsm.RSMNotFoundError:
-            rsm_request = None
-        node = query.getAttribute('node')
-        query_id = query.getAttribute('queryid')
-        return MAMRequest(form, rsm_request, node, query_id, sender, recipient)
-
-    def toElement(self):
-        """
-        Return the DOM representation of this RSM <query/> request.
-
-        @rtype: L{Element<twisted.words.xish.domish.Element>}
-        """
-        mam_elt = domish.Element((NS_MAM, 'query'))
-        if self.node is not None:
-            mam_elt['node'] = self.node
-        if self.query_id is not None:
-            mam_elt['queryid'] = self.query_id
-        if self.form is not None:
-            mam_elt.addChild(self.form.toElement())
-        if self.rsm is not None:
-            mam_elt.addChild(self.rsm.toElement())
-
-        return mam_elt
-
-    def render(self, parent):
-        """Embed the DOM representation of this MAM request in the given element.
-
-        @param parent: parent IQ element.
-        @type parent: L{Element<twisted.words.xish.domish.Element>}
-
-        @return: MAM request element.
-        @rtype: L{Element<twisted.words.xish.domish.Element>}
-        """
-        assert parent.name == 'iq'
-        mam_elt = self.toElement()
-        parent.addChild(mam_elt)
-        return mam_elt
-
-
-class MAMPrefs(object):
-    """
-    A Message Archive Management <prefs/> request.
-
-    @param default: A value in ('always', 'never', 'roster').
-    @type : C{unicode} or C{None}
-
-    @param always (list): A list of JID instances.
-    @type always: C{list}
-
-    @param never (list): A list of JID instances.
-    @type never: C{list}
-    """
-
-    def __init__(self, default=None, always=None, never=None):
-        if default is not None:
-            # default must be defined in response, but can be empty in request (see http://xmpp.org/extensions/xep-0313.html#config)
-            assert default in ('always', 'never', 'roster')
-        self.default = default
-        if always is not None:
-            assert isinstance(always, list)
-        else:
-            always = []
-        self.always = always
-        if never is not None:
-            assert isinstance(never, list)
-        else:
-            never = []
-        self.never = never
-
-    @classmethod
-    def fromElement(cls, prefs_elt):
-        """Parse the DOM representation of a MAM <prefs/> request.
-
-        @param prefs_elt: MAM <prefs/> request element.
-        @type prefs_elt: L{Element<twisted.words.xish.domish.Element>}
-
-        @return: MAMPrefs instance.
-        @rtype: L{MAMPrefs}
-        """
-        if prefs_elt.uri != NS_MAM or prefs_elt.name != 'prefs':
-            raise MAMError('Element provided is not a MAM <prefs/> request')
-        try:
-            default = prefs_elt['default']
-        except KeyError:
-            # FIXME: return proper error here
-            raise MAMError('Element provided is not a valid MAM <prefs/> request')
-
-        prefs = {}
-        for attr in ('always', 'never'):
-            prefs[attr] = []
-            try:
-                pref = prefs_elt.elements(NS_MAM, attr).next()
-            except StopIteration:
-                # FIXME: return proper error here
-                raise MAMError('Element provided is not a valid MAM <prefs/> request')
-            else:
-                for jid_s in pref.elements(NS_MAM, 'jid'):
-                    prefs[attr].append(jid.JID(jid_s))
-        return MAMPrefs(default, **prefs)
-
-    def toElement(self):
-        """
-        Return the DOM representation of this RSM <prefs/>request.
-
-        @rtype: L{Element<twisted.words.xish.domish.Element>}
-        """
-        mam_elt = domish.Element((NS_MAM, 'prefs'))
-        if self.default:
-            mam_elt['default'] = self.default
-        for attr in ('always', 'never'):
-            attr_elt = mam_elt.addElement(attr)
-            jids = getattr(self, attr)
-            for jid_ in jids:
-                attr_elt.addElement('jid', content=jid_.full())
-        return mam_elt
-
-    def render(self, parent):
-        """Embed the DOM representation of this MAM request in the given element.
-
-        @param parent: parent IQ element.
-        @type parent: L{Element<twisted.words.xish.domish.Element>}
-
-        @return: MAM request element.
-        @rtype: L{Element<twisted.words.xish.domish.Element>}
-        """
-        assert parent.name == 'iq'
-        mam_elt = self.toElement()
-        parent.addChild(mam_elt)
-        return mam_elt
-
-
-class MAMClient(subprotocols.XMPPHandler):
-    """
-    MAM client.
-
-    This handler implements the protocol for sending out MAM requests.
-    """
-
-    def queryArchive(self, mam_query, service=None, sender=None):
-        """Query a user, MUC or pubsub archive.
-
-        @param mam_query: query to use
-        @type form: L{MAMRequest}
-
-        @param service: Entity offering the MAM service (None for user server).
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param sender: Optional sender address.
-        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: A deferred that fires upon receiving a response.
-        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
-        """
-        iq = xmlstream.IQ(self.xmlstream, 'set')
-        mam_query.render(iq)
-        if sender is not None:
-            iq['from'] = unicode(sender)
-        return iq.send(to=service.full() if service else None)
-
-    def queryFields(self, service=None, sender=None):
-        """Ask the server about supported fields.
-
-        @param service: Entity offering the MAM service (None for user archives).
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param sender: Optional sender address.
-        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: data Form with the fields, or None if not found
-        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
-        """
-        # http://xmpp.org/extensions/xep-0313.html#query-form
-        iq = xmlstream.IQ(self.xmlstream, 'get')
-        MAMRequest().render(iq)
-        if sender is not None:
-            iq['from'] = unicode(sender)
-        d = iq.send(to=service.full() if service else None)
-        d.addCallback(lambda iq_result: iq_result.elements(NS_MAM, 'query').next())
-        d.addCallback(data_form.findForm, NS_MAM)
-        return d
-
-    def queryPrefs(self, service=None, sender=None):
-        """Retrieve the current user preferences.
-
-        @param service: Entity offering the MAM service (None for user archives).
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param sender: Optional sender address.
-        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: A deferred that fires upon receiving a response.
-        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
-        """
-        # http://xmpp.org/extensions/xep-0313.html#prefs
-        iq = xmlstream.IQ(self.xmlstream, 'get')
-        MAMPrefs().render(iq)
-        if sender is not None:
-            iq['from'] = unicode(sender)
-        return iq.send(to=service.full() if service else None)
-
-    def setPrefs(self, service=None, default='roster', always=None, never=None, sender=None):
-        """Set new user preferences.
-
-        @param service: Entity offering the MAM service (None for user archives).
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param default: A value in ('always', 'never', 'roster').
-        @type : C{unicode}
-
-        @param always (list): A list of JID instances.
-        @type always: C{list}
-
-        @param never (list): A list of JID instances.
-        @type never: C{list}
-
-        @param sender: Optional sender address.
-        @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: A deferred that fires upon receiving a response.
-        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
-        """
-        # http://xmpp.org/extensions/xep-0313.html#prefs
-        assert default is not None
-        iq = xmlstream.IQ(self.xmlstream, 'set')
-        MAMPrefs(default, always, never).render(iq)
-        if sender is not None:
-            iq['from'] = unicode(sender)
-        return iq.send(to=service.full() if service else None)
-
-
-class IMAMResource(Interface):
-
-    def onArchiveRequest(self, mam):
-        """
-
-        @param mam: The MAM <query/> request.
-        @type mam: L{MAMQueryReques<wokkel.mam.MAMRequest>}
-
-        @return: The RSM answer.
-        @rtype: L{RSMResponse<wokkel.rsm.RSMResponse>}
-        """
-
-    def onPrefsGetRequest(self, requestor):
-        """
-
-        @param requestor: JID of the requestor.
-        @type requestor: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: The current settings.
-        @rtype: L{wokkel.mam.MAMPrefs}
-        """
-
-    def onPrefsSetRequest(self, prefs, requestor):
-        """
-
-        @param prefs: The new settings to set.
-        @type prefs: L{wokkel.mam.MAMPrefs}
-
-        @param requestor: JID of the requestor.
-        @type requestor: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @return: The new current settings.
-        @rtype: L{wokkel.mam.MAMPrefs}
-        """
-
-class IMAMService(Interface):
-    """
-    Interface for XMPP MAM service.
-    """
-
-    def addFilter(self, field):
-        """
-        Add a new filter for querying MAM archive.
-
-        @param field: data form field of the filter
-        @type field: L{Form<wokkel.data_form.Field>}
-        """
-
-
-class MAMService(subprotocols.XMPPHandler, subprotocols.IQHandlerMixin):
-    """
-    Protocol implementation for a MAM service.
-
-    This handler waits for XMPP Ping requests and sends a response.
-    """
-    implements(IMAMService, disco.IDisco)
-
-    _request_class = MAMRequest
-
-    iqHandlers = {FIELDS_REQUEST: '_onFieldsRequest',
-                  ARCHIVE_REQUEST: '_onArchiveRequest',
-                  PREFS_GET_REQUEST: '_onPrefsGetRequest',
-                  PREFS_SET_REQUEST: '_onPrefsSetRequest'
-                  }
-
-    _legacyFilters = {'start': {'fieldType': 'text-single',
-                                'var': 'start',
-                                'label': 'Starting time',
-                                'desc': 'Starting time a the result period.',
-                                },
-                      'end': {'fieldType': 'text-single',
-                              'var': 'end',
-                              'label': 'Ending time',
-                              'desc': 'Ending time of the result period.',
-                              },
-                      'with': {'fieldType': 'jid-single',
-                               'var': 'with',
-                               'label': 'Entity',
-                               'desc': 'Entity against which to match message.',
-                               },
-                      }
-
-    def __init__(self, resource):
-        """
-        @param resource: instance implementing IMAMResource
-        @type resource: L{object}
-        """
-        self.resource = resource
-        self.extra_fields = {}
-
-    def connectionInitialized(self):
-        """
-        Called when the XML stream has been initialized.
-
-        This sets up an observer for incoming ping requests.
-        """
-        self.xmlstream.addObserver(FIELDS_REQUEST, self.handleRequest)
-        self.xmlstream.addObserver(ARCHIVE_REQUEST, self.handleRequest)
-        self.xmlstream.addObserver(PREFS_GET_REQUEST, self.handleRequest)
-        self.xmlstream.addObserver(PREFS_SET_REQUEST, self.handleRequest)
-
-    def addFilter(self, field):
-        """
-        Add a new filter for querying MAM archive.
-
-        @param field: data form field of the filter
-        @type field: L{Form<wokkel.data_form.Field>}
-        """
-        self.extra_fields[field.var] = field
-
-    def _onFieldsRequest(self, iq):
-        """
-        Called when a fields request has been received.
-
-        This immediately replies with a result response.
-        """
-        iq.handled = True
-        query = domish.Element((NS_MAM, 'query'))
-        query.addChild(buildForm(extra_fields=self.extra_fields).toElement(), formType='form')
-        return query
-
-    def _onArchiveRequest(self, iq):
-        """
-        Called when a message archive request has been received.
-
-        This replies with the list of archived message and the <iq> result
-        @return: A tuple with list of message data (id, element, data) and RSM element
-        @rtype: C{tuple}
-        """
-        iq.handled = True
-        mam_ = self._request_class.fromElement(iq)
-
-        # remove unsupported filters
-        unsupported_fields = []
-        if mam_.form:
-            for key, field in mam_.form.fields.iteritems():
-                if key not in self._legacyFilters and key not in self.extra_fields:
-                    log.msg('Ignored unsupported MAM filter: %s' % field)
-                    unsupported_fields.append(key)
-        for key in unsupported_fields:
-            del mam_.form.fields[key]
-
-        def forwardMessage(id_, elt, date):
-            msg = domish.Element((None, 'message'))
-            msg['to'] = iq['from']
-            result = msg.addElement((NS_MAM, 'result'))
-            if mam_.query_id is not None:
-                result['queryid'] = mam_.query_id
-            result['id'] = id_
-            forward = result.addElement((NS_FORWARD, 'forwarded'))
-            forward.addChild(delay.Delay(date).toElement())
-            forward.addChild(elt)
-            self.xmlstream.send(msg)
-
-        def cb(result):
-            msg_data, rsm_elt = result
-            for data in msg_data:
-                forwardMessage(*data)
-
-            fin_elt = domish.Element((NS_MAM, 'fin'))
-
-            if rsm_elt is not None:
-                fin_elt.addChild(rsm_elt)
-            return fin_elt
-
-        d = defer.maybeDeferred(self.resource.onArchiveRequest, mam_)
-        d.addCallback(cb)
-        return d
-
-    def _onPrefsGetRequest(self, iq):
-        """
-        Called when a prefs get request has been received.
-
-        This immediately replies with a result response.
-        """
-        iq.handled = True
-        requestor = jid.JID(iq['from'])
-
-        def cb(prefs):
-            return prefs.toElement()
-
-        d = self.resource.onPrefsGetRequest(requestor).addCallback(cb)
-        return d
-
-    def _onPrefsSetRequest(self, iq):
-        """
-        Called when a prefs get request has been received.
-
-        This immediately replies with a result response.
-        """
-        iq.handled = True
-
-        prefs = MAMPrefs.fromElement(iq.prefs)
-        requestor = jid.JID(iq['from'])
-
-        def cb(prefs):
-            return prefs.toElement()
-
-        d = self.resource.onPrefsSetRequest(prefs, requestor).addCallback(cb)
-        return d
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
-        if nodeIdentifier:
-            return []
-        return [disco.DiscoFeature(NS_MAM)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
-        return []
-
-
-def datetime2utc(datetime_obj):
-    """Convert a datetime to a XEP-0082 compliant UTC datetime.
-
-    @param datetime_obj: Offset-aware timestamp to convert.
-    @type datetime_obj: L{datetime<datetime.datetime>}
-
-    @return: The datetime converted to UTC.
-    @rtype: C{unicode}
-    """
-    stampFormat = '%Y-%m-%dT%H:%M:%SZ'
-    return datetime_obj.astimezone(tz.tzutc()).strftime(stampFormat)
-
-
-def buildForm(start=None, end=None, with_jid=None, extra_fields=None, formType='submit'):
-    """Prepare a Data Form for MAM.
-
-    @param start: Offset-aware timestamp to filter out older messages.
-    @type start: L{datetime<datetime.datetime>}
-
-    @param end: Offset-aware timestamp to filter out later messages.
-    @type end: L{datetime<datetime.datetime>}
-
-    @param with_jid: JID against which to match messages.
-    @type with_jid: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-    @param extra_fields: list of extra data form fields that are not defined by the
-        specification.
-    @type: C{list}
-
-    @param formType: The type of the Data Form ('submit' or 'form').
-    @type formType: C{unicode}
-
-    @return: XEP-0004 Data Form object.
-    @rtype: L{Form<wokkel.data_form.Form>}
-    """
-    form = data_form.Form(formType, formNamespace=NS_MAM)
-
-    if formType == 'form':
-        for kwargs in MAMService._legacyFilters.values():
-            form.addField(data_form.Field(**kwargs))
-    elif formType == 'submit':
-        if start:
-            form.addField(data_form.Field(var='start', value=datetime2utc(start)))
-        if end:
-            form.addField(data_form.Field(var='end', value=datetime2utc(end)))
-        if with_jid:
-            form.addField(data_form.Field(fieldType='jid-single', var='with', value=with_jid.full()))
-
-    if extra_fields is not None:
-        for field in extra_fields:
-            form.addField(field)
-
-    return form
--- a/wokkel/pubsub.py	Wed Nov 01 22:34:51 2017 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1717 +0,0 @@
-# -*- coding: utf-8 -*-
-# -*- test-case-name: wokkel.test.test_pubsub -*-
-#
-# SàT adaptation for wokkel.pubsub
-# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
-# Copyright (c) 2003-2012 Ralph Meijer.
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-# --
-
-# This program is based on wokkel (https://wokkel.ik.nu/),
-# originaly written by Ralph Meijer
-# It is sublicensed under AGPL v3 (or any later version) as allowed by the original
-# license.
-
-# --
-
-# Here is a copy of the original license:
-
-# Copyright (c) 2003-2012 Ralph Meijer.
-#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-"""
-XMPP publish-subscribe protocol.
-
-This protocol is specified in
-U{XEP-0060<http://xmpp.org/extensions/xep-0060.html>}.
-"""
-
-from zope.interface import implements
-
-from twisted.internet import defer
-from twisted.python import log
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-
-from wokkel import disco, data_form, generic, shim
-from wokkel.compat import IQ
-from wokkel.subprotocols import IQHandlerMixin, XMPPHandler
-from wokkel.iwokkel import IPubSubClient, IPubSubService, IPubSubResource
-
-# Iq get and set XPath queries
-IQ_GET = '/iq[@type="get"]'
-IQ_SET = '/iq[@type="set"]'
-
-# Publish-subscribe namespaces
-NS_PUBSUB = 'http://jabber.org/protocol/pubsub'
-NS_PUBSUB_EVENT = NS_PUBSUB + '#event'
-NS_PUBSUB_ERRORS = NS_PUBSUB + '#errors'
-NS_PUBSUB_OWNER = NS_PUBSUB + "#owner"
-NS_PUBSUB_NODE_CONFIG = NS_PUBSUB + "#node_config"
-NS_PUBSUB_META_DATA = NS_PUBSUB + "#meta-data"
-NS_PUBSUB_SUBSCRIBE_OPTIONS = NS_PUBSUB + "#subscribe_options"
-
-# XPath to match pubsub requests
-PUBSUB_REQUEST = '/iq[@type="get" or @type="set"]/' + \
-                    'pubsub[@xmlns="' + NS_PUBSUB + '" or ' + \
-                           '@xmlns="' + NS_PUBSUB_OWNER + '"]'
-
-BOOL_TRUE = ('1','true')
-BOOL_FALSE = ('0','false')
-
-class SubscriptionPending(Exception):
-    """
-    Raised when the requested subscription is pending acceptance.
-    """
-
-
-
-class SubscriptionUnconfigured(Exception):
-    """
-    Raised when the requested subscription needs to be configured before
-    becoming active.
-    """
-
-
-
-class PubSubError(error.StanzaError):
-    """
-    Exception with publish-subscribe specific condition.
-    """
-    def __init__(self, condition, pubsubCondition, feature=None, text=None):
-        appCondition = domish.Element((NS_PUBSUB_ERRORS, pubsubCondition))
-        if feature:
-            appCondition['feature'] = feature
-        error.StanzaError.__init__(self, condition,
-                                         text=text,
-                                         appCondition=appCondition)
-
-
-
-class BadRequest(error.StanzaError):
-    """
-    Bad request stanza error.
-    """
-    def __init__(self, pubsubCondition=None, text=None):
-        if pubsubCondition:
-            appCondition = domish.Element((NS_PUBSUB_ERRORS, pubsubCondition))
-        else:
-            appCondition = None
-        error.StanzaError.__init__(self, 'bad-request',
-                                         text=text,
-                                         appCondition=appCondition)
-
-
-
-class Unsupported(PubSubError):
-    def __init__(self, feature, text=None):
-        self.feature = feature
-        PubSubError.__init__(self, 'feature-not-implemented',
-                                   'unsupported',
-                                   feature,
-                                   text)
-
-    def __str__(self):
-        message = PubSubError.__str__(self)
-        message += ', feature %r' % self.feature
-        return message
-
-
-class Subscription(object):
-    """
-    A subscription to a node.
-
-    @ivar nodeIdentifier: The identifier of the node subscribed to.  The root
-        node is denoted by C{None}.
-    @type nodeIdentifier: C{unicode}
-
-    @ivar subscriber: The subscribing entity.
-    @type subscriber: L{jid.JID}
-
-    @ivar state: The subscription state. One of C{'subscribed'}, C{'pending'},
-                 C{'unconfigured'}, C{'none'}.
-    @type state: C{unicode}
-
-    @ivar options: Optional list of subscription options.
-    @type options: C{dict}
-
-    @ivar subscriptionIdentifier: Optional subscription identifier.
-    @type subscriptionIdentifier: C{unicode}
-    """
-
-    def __init__(self, nodeIdentifier, subscriber, state, options=None,
-                       subscriptionIdentifier=None):
-        self.nodeIdentifier = nodeIdentifier
-        self.subscriber = subscriber
-        self.state = state
-        self.options = options or {}
-        self.subscriptionIdentifier = subscriptionIdentifier
-
-
-    @staticmethod
-    def fromElement(element):
-        return Subscription(
-                element.getAttribute('node'),
-                jid.JID(element.getAttribute('jid')),
-                element.getAttribute('subscription'),
-                subscriptionIdentifier=element.getAttribute('subid'))
-
-
-    def toElement(self, defaultUri=None):
-        """
-        Return the DOM representation of this subscription.
-
-        @rtype: L{domish.Element}
-        """
-        element = domish.Element((defaultUri, 'subscription'))
-        if self.nodeIdentifier:
-            element['node'] = self.nodeIdentifier
-        element['jid'] = unicode(self.subscriber)
-        element['subscription'] = self.state
-        if self.subscriptionIdentifier:
-            element['subid'] = self.subscriptionIdentifier
-        return element
-
-
-
-class Item(domish.Element):
-    """
-    Publish subscribe item.
-
-    This behaves like an object providing L{domish.IElement}.
-
-    Item payload can be added using C{addChild} or C{addRawXml}, or using the
-    C{payload} keyword argument to C{__init__}.
-    """
-
-    def __init__(self, id=None, payload=None):
-        """
-        @param id: optional item identifier
-        @type id: C{unicode}
-        @param payload: optional item payload. Either as a domish element, or
-                        as serialized XML.
-        @type payload: object providing L{domish.IElement} or C{unicode}.
-        """
-
-        domish.Element.__init__(self, (None, 'item'))
-        if id is not None:
-            self['id'] = id
-        if payload is not None:
-            if isinstance(payload, basestring):
-                self.addRawXml(payload)
-            else:
-                self.addChild(payload)
-
-
-
-class PubSubRequest(generic.Stanza):
-    """
-    A publish-subscribe request.
-
-    The set of instance variables used depends on the type of request. If
-    a variable is not applicable or not passed in the request, its value is
-    C{None}.
-
-    @ivar verb: The type of publish-subscribe request. See C{_requestVerbMap}.
-    @type verb: C{str}.
-
-    @ivar affiliations: Affiliations to be modified.
-    @type affiliations: C{set}
-
-    @ivar items: The items to be published, as L{domish.Element}s.
-    @type items: C{list}
-
-    @ivar itemIdentifiers: Identifiers of the items to be retrieved or
-                           retracted.
-    @type itemIdentifiers: C{set}
-
-    @ivar maxItems: Maximum number of items to retrieve.
-    @type maxItems: C{int}.
-
-    @ivar nodeIdentifier: Identifier of the node the request is about.
-    @type nodeIdentifier: C{unicode}
-
-    @ivar nodeType: The type of node that should be created, or for which the
-                    configuration is retrieved. C{'leaf'} or C{'collection'}.
-    @type nodeType: C{str}
-
-    @ivar options: Configurations options for nodes, subscriptions and publish
-                   requests.
-    @type options: L{data_form.Form}
-
-    @ivar subscriber: The subscribing entity.
-    @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-    @ivar subscriptionIdentifier: Identifier for a specific subscription.
-    @type subscriptionIdentifier: C{unicode}
-
-    @ivar subscriptions: Subscriptions to be modified, as a set of
-        L{Subscription}.
-    @type subscriptions: C{set}
-
-    @ivar affiliations: Affiliations to be modified, as a dictionary of entity
-        (L{JID<twisted.words.protocols.jabber.jid.JID>} to affiliation
-        (C{unicode}).
-    @type affiliations: C{dict}
-    """
-
-    verb = None
-
-    items = None
-    itemIdentifiers = None
-    maxItems = None
-    nodeIdentifier = None
-    nodeType = None
-    options = None
-    subscriber = None
-    subscriptionIdentifier = None
-    subscriptions = None
-    affiliations = None
-    notify = None
-
-    # Map request iq type and subelement name to request verb
-    _requestVerbMap = {
-        ('set', NS_PUBSUB, 'publish'): 'publish',
-        ('set', NS_PUBSUB, 'subscribe'): 'subscribe',
-        ('set', NS_PUBSUB, 'unsubscribe'): 'unsubscribe',
-        ('get', NS_PUBSUB, 'options'): 'optionsGet',
-        ('set', NS_PUBSUB, 'options'): 'optionsSet',
-        ('get', NS_PUBSUB, 'subscriptions'): 'subscriptions',
-        ('get', NS_PUBSUB, 'affiliations'): 'affiliations',
-        ('set', NS_PUBSUB, 'create'): 'create',
-        ('get', NS_PUBSUB_OWNER, 'default'): 'default',
-        ('get', NS_PUBSUB_OWNER, 'configure'): 'configureGet',
-        ('set', NS_PUBSUB_OWNER, 'configure'): 'configureSet',
-        ('get', NS_PUBSUB, 'items'): 'items',
-        ('set', NS_PUBSUB, 'retract'): 'retract',
-        ('set', NS_PUBSUB_OWNER, 'purge'): 'purge',
-        ('set', NS_PUBSUB_OWNER, 'delete'): 'delete',
-        ('get', NS_PUBSUB_OWNER, 'affiliations'): 'affiliationsGet',
-        ('set', NS_PUBSUB_OWNER, 'affiliations'): 'affiliationsSet',
-        ('get', NS_PUBSUB_OWNER, 'subscriptions'): 'subscriptionsGet',
-        ('set', NS_PUBSUB_OWNER, 'subscriptions'): 'subscriptionsSet',
-    }
-
-    # Map request verb to request iq type and subelement name
-    _verbRequestMap = dict(((v, k) for k, v in _requestVerbMap.iteritems()))
-
-    # Map request verb to parameter handler names
-    _parameters = {
-        'publish': ['node', 'items'],
-        'subscribe': ['nodeOrEmpty', 'jid', 'optionsWithSubscribe'],
-        'unsubscribe': ['nodeOrEmpty', 'jid', 'subidOrNone'],
-        'optionsGet': ['nodeOrEmpty', 'jid', 'subidOrNone'],
-        'optionsSet': ['nodeOrEmpty', 'jid', 'options', 'subidOrNone'],
-        'subscriptions': ['nodeOrEmpty'],
-        'affiliations': ['nodeOrNone'],
-        'create': ['nodeOrNone', 'configureOrNone'],
-        'default': ['default'],
-        'configureGet': ['nodeOrEmpty'],
-        'configureSet': ['nodeOrEmpty', 'configureOrNone'],
-        'items': ['node', 'maxItems', 'itemIdentifiers', 'subidOrNone'],
-        'retract': ['node', 'notify', 'itemIdentifiers'],
-        'purge': ['node'],
-        'delete': ['node'],
-        'affiliationsGet': ['node'],
-        'affiliationsSet': ['node', 'affiliations'],
-        'subscriptionsGet': ['node'],
-        'subscriptionsSet': ['node', 'subscriptions'],
-    }
-
-    def __init__(self, verb=None):
-        self.verb = verb
-
-
-    def _parse_node(self, verbElement):
-        """
-        Parse the required node identifier out of the verbElement.
-        """
-        try:
-            self.nodeIdentifier = verbElement["node"]
-        except KeyError:
-            raise BadRequest('nodeid-required')
-
-
-    def _render_node(self, verbElement):
-        """
-        Render the required node identifier on the verbElement.
-        """
-        if not self.nodeIdentifier:
-            raise Exception("Node identifier is required")
-
-        verbElement['node'] = self.nodeIdentifier
-
-
-    def _parse_nodeOrEmpty(self, verbElement):
-        """
-        Parse the node identifier out of the verbElement. May be empty.
-        """
-        self.nodeIdentifier = verbElement.getAttribute("node", '')
-
-
-    def _render_nodeOrEmpty(self, verbElement):
-        """
-        Render the node identifier on the verbElement. May be empty.
-        """
-        if self.nodeIdentifier:
-            verbElement['node'] = self.nodeIdentifier
-
-
-    def _parse_nodeOrNone(self, verbElement):
-        """
-        Parse the optional node identifier out of the verbElement.
-        """
-        self.nodeIdentifier = verbElement.getAttribute("node")
-
-
-    def _render_nodeOrNone(self, verbElement):
-        """
-        Render the optional node identifier on the verbElement.
-        """
-        if self.nodeIdentifier:
-            verbElement['node'] = self.nodeIdentifier
-
-
-    def _parse_items(self, verbElement):
-        """
-        Parse items out of the verbElement for publish requests.
-        """
-        self.items = []
-        for element in verbElement.elements():
-            if element.uri == NS_PUBSUB and element.name == 'item':
-                self.items.append(element)
-
-
-    def _render_items(self, verbElement):
-        """
-        Render items into the verbElement for publish requests.
-        """
-        if self.items:
-            for item in self.items:
-                item.uri = NS_PUBSUB
-                verbElement.addChild(item)
-
-
-    def _parse_jid(self, verbElement):
-        """
-        Parse subscriber out of the verbElement for un-/subscribe requests.
-        """
-        try:
-            self.subscriber = jid.internJID(verbElement["jid"])
-        except KeyError:
-            raise BadRequest('jid-required')
-
-
-    def _render_jid(self, verbElement):
-        """
-        Render subscriber into the verbElement for un-/subscribe requests.
-        """
-        verbElement['jid'] = self.subscriber.full()
-
-
-    def _parse_default(self, verbElement):
-        """
-        Parse node type out of a request for the default node configuration.
-        """
-        form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG)
-        if form is not None and form.formType == 'submit':
-            values = form.getValues()
-            self.nodeType = values.get('pubsub#node_type', 'leaf')
-        else:
-            self.nodeType = 'leaf'
-
-
-    def _parse_configure(self, verbElement):
-        """
-        Parse options out of a request for setting the node configuration.
-        """
-        form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG)
-        if form is not None:
-            if form.formType in ('submit', 'cancel'):
-                self.options = form
-            else:
-                raise BadRequest(text=u"Unexpected form type '%s'" % form.formType)
-        else:
-            raise BadRequest(text="Missing configuration form")
-
-
-    def _parse_configureOrNone(self, verbElement):
-        """
-        Parse optional node configuration form in create request.
-        """
-        for element in verbElement.parent.elements():
-            if element.uri in (NS_PUBSUB, NS_PUBSUB_OWNER) and element.name == 'configure':
-                form = data_form.findForm(element, NS_PUBSUB_NODE_CONFIG)
-                if form is not None:
-                    if form.formType != 'submit':
-                        raise BadRequest(text=u"Unexpected form type '%s'" %
-                                              form.formType)
-                else:
-                    form = data_form.Form('submit',
-                                          formNamespace=NS_PUBSUB_NODE_CONFIG)
-                self.options = form
-
-
-    def _render_configureOrNone(self, verbElement):
-        """
-        Render optional node configuration form in create request.
-        """
-        if self.options is not None:
-            if verbElement.name == 'configure':
-                configure = verbElement
-            else:
-                configure = verbElement.parent.addElement('configure')
-            configure.addChild(self.options.toElement())
-
-
-    def _parse_itemIdentifiers(self, verbElement):
-        """
-        Parse item identifiers out of items and retract requests.
-        """
-        self.itemIdentifiers = []
-        for element in verbElement.elements():
-            if element.uri == NS_PUBSUB and element.name == 'item':
-                try:
-                    self.itemIdentifiers.append(element["id"])
-                except KeyError:
-                    raise BadRequest()
-
-
-    def _render_itemIdentifiers(self, verbElement):
-        """
-        Render item identifiers into items and retract requests.
-        """
-        if self.itemIdentifiers:
-            for itemIdentifier in self.itemIdentifiers:
-                item = verbElement.addElement('item')
-                item['id'] = itemIdentifier
-
-
-    def _parse_maxItems(self, verbElement):
-        """
-        Parse maximum items out of an items request.
-        """
-        value = verbElement.getAttribute('max_items')
-
-        if value:
-            try:
-                self.maxItems = int(value)
-            except ValueError:
-                raise BadRequest(text="Field max_items requires a positive " +
-                                      "integer value")
-
-
-    def _render_maxItems(self, verbElement):
-        """
-        Render maximum items into an items request.
-        """
-        if self.maxItems:
-            verbElement['max_items'] = unicode(self.maxItems)
-
-
-    def _parse_subidOrNone(self, verbElement):
-        """
-        Parse subscription identifier out of a request.
-        """
-        self.subscriptionIdentifier = verbElement.getAttribute("subid")
-
-
-    def _render_subidOrNone(self, verbElement):
-        """
-        Render subscription identifier into a request.
-        """
-        if self.subscriptionIdentifier:
-            verbElement['subid'] = self.subscriptionIdentifier
-
-
-    def _parse_options(self, verbElement):
-        """
-        Parse options form out of a subscription options request.
-        """
-        form = data_form.findForm(verbElement, NS_PUBSUB_SUBSCRIBE_OPTIONS)
-        if form is not None:
-            if form.formType in ('submit', 'cancel'):
-                self.options = form
-            else:
-                raise BadRequest(text=u"Unexpected form type '%s'" % form.formType)
-        else:
-            raise BadRequest(text="Missing options form")
-
-
-    def _render_options(self, verbElement):
-        verbElement.addChild(self.options.toElement())
-
-
-    def _parse_optionsWithSubscribe(self, verbElement):
-        for element in verbElement.parent.elements():
-            if element.name == 'options' and element.uri == NS_PUBSUB:
-                form = data_form.findForm(element,
-                                          NS_PUBSUB_SUBSCRIBE_OPTIONS)
-                if form is not None:
-                    if form.formType != 'submit':
-                        raise BadRequest(text=u"Unexpected form type '%s'" %
-                                              form.formType)
-                else:
-                    form = data_form.Form('submit',
-                                          formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
-                self.options = form
-
-
-    def _render_optionsWithSubscribe(self, verbElement):
-        if self.options is not None:
-            optionsElement = verbElement.parent.addElement('options')
-            self._render_options(optionsElement)
-
-
-    def _parse_affiliations(self, verbElement):
-        self.affiliations = {}
-        for element in verbElement.elements():
-            if (element.uri == NS_PUBSUB_OWNER and
-                element.name == 'affiliation'):
-                try:
-                    entity = jid.internJID(element['jid']).userhostJID()
-                except KeyError:
-                    raise BadRequest(text='Missing jid attribute')
-
-                if entity in self.affiliations:
-                    raise BadRequest(text='Multiple affiliations for an entity')
-
-                try:
-                    affiliation = element['affiliation']
-                except KeyError:
-                    raise BadRequest(text='Missing affiliation attribute')
-
-                self.affiliations[entity] = affiliation
-
-
-    def _render_affiliations(self, verbElement):
-        for entity, affiliation in self.affiliations.iteritems():
-            affiliationElement = verbElement.addElement((NS_PUBSUB_OWNER, 'affiliation'))
-            affiliationElement['jid'] = entity.full()
-            affiliationElement['affiliation'] = affiliation
-
-
-    def _parse_subscriptions(self, verbElement):
-        self.subscriptions = set()
-        seen_entities = set()
-        for element in verbElement.elements():
-            if (element.uri == NS_PUBSUB_OWNER and
-                element.name == 'subscription'):
-                try:
-                    subscriber = jid.internJID(element['jid']).userhostJID()
-                except KeyError:
-                    raise BadRequest(text='Missing jid attribute')
-
-                if subscriber in seen_entities:
-                    raise BadRequest(text='Multiple subscriptions for an subscriber')
-                seen_entities.add(subscriber)
-
-                try:
-                    state = element['subscription']
-                except KeyError:
-                    # §8.8.2.1 says that value MUST NOT be changed
-                    # if subscription is missing
-                    continue
-
-                self.subscriptions.add(Subscription(self.nodeIdentifier,
-                                                    subscriber,
-                                                    state))
-
-
-    def _render_subscriptions(self, verbElement):
-        for subscription in self.subscriptions:
-            subscriptionElement = verbElement.addElement((NS_PUBSUB_OWNER, 'subscription'))
-            subscriptionElement['jid'] = subscription.subscriber.full()
-            subscriptionElement['subscription'] = subscription.state
-
-
-    def _parse_notify(self, verbElement):
-        value = verbElement.getAttribute('notify')
-
-        if value:
-            if value in BOOL_TRUE:
-                self.notify = True
-            elif value in BOOL_FALSE:
-                self.notify = False
-            else:
-                raise BadRequest(text="Field notify must be a boolean value")
-
-
-    def _render_notify(self, verbElement):
-        if self.notify is not None:
-            verbElement['notify'] = "true" if self.notify else "false"
-
-
-    def parseElement(self, element):
-        """
-        Parse the publish-subscribe verb and parameters out of a request.
-        """
-        generic.Stanza.parseElement(self, element)
-
-        verbs = []
-        verbElements = []
-        for child in element.pubsub.elements():
-            key = (self.stanzaType, child.uri, child.name)
-            try:
-                verb = self._requestVerbMap[key]
-            except KeyError:
-                continue
-
-            verbs.append(verb)
-            verbElements.append(child)
-
-        if not verbs:
-            raise NotImplementedError()
-
-        if len(verbs) > 1:
-            if 'optionsSet' in verbs and 'subscribe' in verbs:
-                self.verb = 'subscribe'
-                verbElement = verbElements[verbs.index('subscribe')]
-            else:
-                raise NotImplementedError()
-        else:
-            self.verb = verbs[0]
-            verbElement = verbElements[0]
-
-        for parameter in self._parameters[self.verb]:
-            getattr(self, '_parse_%s' % parameter)(verbElement)
-
-
-
-    def send(self, xs):
-        """
-        Send this request to its recipient.
-
-        This renders all of the relevant parameters for this specific
-        requests into an L{IQ}, and invoke its C{send} method.
-        This returns a deferred that fires upon reception of a response. See
-        L{IQ} for details.
-
-        @param xs: The XML stream to send the request on.
-        @type xs: L{twisted.words.protocols.jabber.xmlstream.XmlStream}
-        @rtype: L{defer.Deferred}.
-        """
-
-        try:
-            (self.stanzaType,
-             childURI,
-             childName) = self._verbRequestMap[self.verb]
-        except KeyError:
-            raise NotImplementedError()
-
-        iq = IQ(xs, self.stanzaType)
-        iq.addElement((childURI, 'pubsub'))
-        verbElement = iq.pubsub.addElement(childName)
-
-        if self.sender:
-            iq['from'] = self.sender.full()
-        if self.recipient:
-            iq['to'] = self.recipient.full()
-
-        for parameter in self._parameters[self.verb]:
-            getattr(self, '_render_%s' % parameter)(verbElement)
-
-        return iq.send()
-
-
-
-class PubSubEvent(object):
-    """
-    A publish subscribe event.
-
-    @param sender: The entity from which the notification was received.
-    @type sender: L{jid.JID}
-    @param recipient: The entity to which the notification was sent.
-    @type recipient: L{wokkel.pubsub.ItemsEvent}
-    @param nodeIdentifier: Identifier of the node the event pertains to.
-    @type nodeIdentifier: C{unicode}
-    @param headers: SHIM headers, see L{wokkel.shim.extractHeaders}.
-    @type headers: C{dict}
-    """
-
-    def __init__(self, sender, recipient, nodeIdentifier, headers):
-        self.sender = sender
-        self.recipient = recipient
-        self.nodeIdentifier = nodeIdentifier
-        self.headers = headers
-
-
-
-class ItemsEvent(PubSubEvent):
-    """
-    A publish-subscribe event that signifies new, updated and retracted items.
-
-    @param items: List of received items as domish elements.
-    @type items: C{list} of L{domish.Element}
-    """
-
-    def __init__(self, sender, recipient, nodeIdentifier, items, headers):
-        PubSubEvent.__init__(self, sender, recipient, nodeIdentifier, headers)
-        self.items = items
-
-
-
-class DeleteEvent(PubSubEvent):
-    """
-    A publish-subscribe event that signifies the deletion of a node.
-    """
-
-    redirectURI = None
-
-
-
-class PurgeEvent(PubSubEvent):
-    """
-    A publish-subscribe event that signifies the purging of a node.
-    """
-
-
-
-class PubSubClient(XMPPHandler):
-    """
-    Publish subscribe client protocol.
-    """
-    implements(IPubSubClient)
-
-    _request_class = PubSubRequest
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver('/message/event[@xmlns="%s"]' %
-                                   NS_PUBSUB_EVENT, self._onEvent)
-
-
-    def _onEvent(self, message):
-        if message.getAttribute('type') == 'error':
-            return
-
-        try:
-            sender = jid.JID(message["from"])
-            recipient = jid.JID(message["to"])
-        except KeyError:
-            return
-
-        actionElement = None
-        for element in message.event.elements():
-            if element.uri == NS_PUBSUB_EVENT:
-                actionElement = element
-
-        if not actionElement:
-            return
-
-        eventHandler = getattr(self, "_onEvent_%s" % actionElement.name, None)
-
-        if eventHandler:
-            headers = shim.extractHeaders(message)
-            eventHandler(sender, recipient, actionElement, headers)
-            message.handled = True
-
-
-    def _onEvent_items(self, sender, recipient, action, headers):
-        nodeIdentifier = action["node"]
-
-        items = [element for element in action.elements()
-                         if element.name in ('item', 'retract')]
-
-        event = ItemsEvent(sender, recipient, nodeIdentifier, items, headers)
-        self.itemsReceived(event)
-
-
-    def _onEvent_delete(self, sender, recipient, action, headers):
-        nodeIdentifier = action["node"]
-        event = DeleteEvent(sender, recipient, nodeIdentifier, headers)
-        if action.redirect:
-            event.redirectURI = action.redirect.getAttribute('uri')
-        self.deleteReceived(event)
-
-
-    def _onEvent_purge(self, sender, recipient, action, headers):
-        nodeIdentifier = action["node"]
-        event = PurgeEvent(sender, recipient, nodeIdentifier, headers)
-        self.purgeReceived(event)
-
-
-    def itemsReceived(self, event):
-        pass
-
-
-    def deleteReceived(self, event):
-        pass
-
-
-    def purgeReceived(self, event):
-        pass
-
-
-    def createNode(self, service, nodeIdentifier=None, options=None,
-                         sender=None):
-        """
-        Create a publish subscribe node.
-
-        @param service: The publish subscribe service to create the node at.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: Optional suggestion for the id of the node.
-        @type nodeIdentifier: C{unicode}
-        @param options: Optional node configuration options.
-        @type options: C{dict}
-        """
-        request = self._request_class('create')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.sender = sender
-
-        if options:
-            form = data_form.Form(formType='submit',
-                                  formNamespace=NS_PUBSUB_NODE_CONFIG)
-            form.makeFields(options)
-            request.options = form
-
-        def cb(iq):
-            try:
-                new_node = iq.pubsub.create["node"]
-            except AttributeError:
-                # the suggested node identifier was accepted
-                new_node = nodeIdentifier
-            return new_node
-
-        d = request.send(self.xmlstream)
-        d.addCallback(cb)
-        return d
-
-
-    def deleteNode(self, service, nodeIdentifier, sender=None):
-        """
-        Delete a publish subscribe node.
-
-        @param service: The publish subscribe service to delete the node from.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-        """
-        request = self._request_class('delete')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.sender = sender
-        return request.send(self.xmlstream)
-
-
-    def subscribe(self, service, nodeIdentifier, subscriber,
-                        options=None, sender=None):
-        """
-        Subscribe to a publish subscribe node.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-
-        @param subscriber: The entity to subscribe to the node. This entity
-            will get notifications of new published items.
-        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param options: Subscription options.
-        @type options: C{dict}
-
-        @return: Deferred that fires with L{Subscription} or errbacks with
-            L{SubscriptionPending} or L{SubscriptionUnconfigured}.
-        @rtype: L{defer.Deferred}
-        """
-        request = self._request_class('subscribe')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.subscriber = subscriber
-        request.sender = sender
-
-        if options:
-            form = data_form.Form(formType='submit',
-                                  formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
-            form.makeFields(options)
-            request.options = form
-
-        def cb(iq):
-            subscription = Subscription.fromElement(iq.pubsub.subscription)
-
-            if subscription.state == 'pending':
-                raise SubscriptionPending()
-            elif subscription.state == 'unconfigured':
-                raise SubscriptionUnconfigured()
-            else:
-                # we assume subscription == 'subscribed'
-                # any other value would be invalid, but that should have
-                # yielded a stanza error.
-                return subscription
-
-        d = request.send(self.xmlstream)
-        d.addCallback(cb)
-        return d
-
-
-    def unsubscribe(self, service, nodeIdentifier, subscriber,
-                          subscriptionIdentifier=None, sender=None):
-        """
-        Unsubscribe from a publish subscribe node.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-
-        @param subscriber: The entity to unsubscribe from the node.
-        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param subscriptionIdentifier: Optional subscription identifier.
-        @type subscriptionIdentifier: C{unicode}
-        """
-        request = self._request_class('unsubscribe')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.subscriber = subscriber
-        request.subscriptionIdentifier = subscriptionIdentifier
-        request.sender = sender
-        return request.send(self.xmlstream)
-
-
-    def publish(self, service, nodeIdentifier, items=None, sender=None):
-        """
-        Publish to a publish subscribe node.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-        @param items: Optional list of L{Item}s to publish.
-        @type items: C{list}
-        """
-        request = self._request_class('publish')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.items = items
-        request.sender = sender
-        return request.send(self.xmlstream)
-
-
-    def items(self, service, nodeIdentifier, maxItems=None, itemIdentifiers=None,
-              subscriptionIdentifier=None, sender=None):
-        """
-        Retrieve previously published items from a publish subscribe node.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-
-        @param maxItems: Optional limit on the number of retrieved items.
-        @type maxItems: C{int}
-
-        @param itemIdentifiers: Identifiers of the items to be retrieved.
-        @type itemIdentifiers: C{set}
-
-        @param subscriptionIdentifier: Optional subscription identifier. In
-            case the node has been subscribed to multiple times, this narrows
-            the results to the specific subscription.
-        @type subscriptionIdentifier: C{unicode}
-        """
-        request = self._request_class('items')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        if maxItems:
-            request.maxItems = str(int(maxItems))
-        request.subscriptionIdentifier = subscriptionIdentifier
-        request.sender = sender
-        request.itemIdentifiers = itemIdentifiers
-
-        def cb(iq):
-            items = []
-            for element in iq.pubsub.items.elements():
-                if element.uri == NS_PUBSUB and element.name == 'item':
-                    items.append(element)
-            return items
-
-        d = request.send(self.xmlstream)
-        d.addCallback(cb)
-        return d
-
-    def retractItems(self, service, nodeIdentifier, itemIdentifiers, notify=None, sender=None):
-        """
-        Retract items from a publish subscribe node.
-
-        @param service: The publish subscribe service to delete the node from.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-        @param itemIdentifiers: Identifiers of the items to be retracted.
-        @type itemIdentifiers: C{set}
-        @param notify: True if notification is required
-        @type notify: C{unicode}
-        """
-        request = self._request_class('retract')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.itemIdentifiers = itemIdentifiers
-        request.notify = notify
-        request.sender = sender
-        return request.send(self.xmlstream)
-
-    def getOptions(self, service, nodeIdentifier, subscriber,
-                         subscriptionIdentifier=None, sender=None):
-        """
-        Get subscription options.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-
-        @param subscriber: The entity subscribed to the node.
-        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param subscriptionIdentifier: Optional subscription identifier.
-        @type subscriptionIdentifier: C{unicode}
-
-        @rtype: L{data_form.Form}
-        """
-        request = self._request_class('optionsGet')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.subscriber = subscriber
-        request.subscriptionIdentifier = subscriptionIdentifier
-        request.sender = sender
-
-        def cb(iq):
-            form = data_form.findForm(iq.pubsub.options,
-                                      NS_PUBSUB_SUBSCRIBE_OPTIONS)
-            form.typeCheck()
-            return form
-
-        d = request.send(self.xmlstream)
-        d.addCallback(cb)
-        return d
-
-
-    def setOptions(self, service, nodeIdentifier, subscriber,
-                         options, subscriptionIdentifier=None, sender=None):
-        """
-        Set subscription options.
-
-        @param service: The publish subscribe service that keeps the node.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param nodeIdentifier: The identifier of the node.
-        @type nodeIdentifier: C{unicode}
-
-        @param subscriber: The entity subscribed to the node.
-        @type subscriber: L{JID<twisted.words.protocols.jabber.jid.JID>}
-
-        @param options: Subscription options.
-        @type options: C{dict}.
-
-        @param subscriptionIdentifier: Optional subscription identifier.
-        @type subscriptionIdentifier: C{unicode}
-        """
-        request = self._request_class('optionsSet')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.subscriber = subscriber
-        request.subscriptionIdentifier = subscriptionIdentifier
-        request.sender = sender
-
-        form = data_form.Form(formType='submit',
-                              formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS)
-        form.makeFields(options)
-        request.options = form
-
-        d = request.send(self.xmlstream)
-        return d
-
-
-
-class PubSubService(XMPPHandler, IQHandlerMixin):
-    """
-    Protocol implementation for a XMPP Publish Subscribe Service.
-
-    The word Service here is used as taken from the Publish Subscribe
-    specification. It is the party responsible for keeping nodes and their
-    subscriptions, and sending out notifications.
-
-    Methods from the L{IPubSubService} interface that are called as a result
-    of an XMPP request may raise exceptions. Alternatively the deferred
-    returned by these methods may have their errback called. These are handled
-    as follows:
-
-     - If the exception is an instance of L{error.StanzaError}, an error
-       response iq is returned.
-     - Any other exception is reported using L{log.msg}. An error response
-       with the condition C{internal-server-error} is returned.
-
-    The default implementation of said methods raises an L{Unsupported}
-    exception and are meant to be overridden.
-
-    @ivar discoIdentity: Service discovery identity as a dictionary with
-                         keys C{'category'}, C{'type'} and C{'name'}.
-    @ivar pubSubFeatures: List of supported publish-subscribe features for
-                          service discovery, as C{str}.
-    @type pubSubFeatures: C{list} or C{None}
-    """
-
-    implements(IPubSubService, disco.IDisco)
-
-    iqHandlers = {
-            '/*': '_onPubSubRequest',
-            }
-
-    _legacyHandlers = {
-        'publish': ('publish', ['sender', 'recipient',
-                                'nodeIdentifier', 'items']),
-        'subscribe': ('subscribe', ['sender', 'recipient',
-                                    'nodeIdentifier', 'subscriber']),
-        'unsubscribe': ('unsubscribe', ['sender', 'recipient',
-                                        'nodeIdentifier', 'subscriber']),
-        'subscriptions': ('subscriptions', ['sender', 'recipient']),
-        'affiliations': ('affiliations', ['sender', 'recipient']),
-        'create': ('create', ['sender', 'recipient', 'nodeIdentifier']),
-        'getConfigurationOptions': ('getConfigurationOptions', []),
-        'default': ('getDefaultConfiguration',
-                    ['sender', 'recipient', 'nodeType']),
-        'configureGet': ('getConfiguration', ['sender', 'recipient',
-                                              'nodeIdentifier']),
-        'configureSet': ('setConfiguration', ['sender', 'recipient',
-                                              'nodeIdentifier', 'options']),
-        'items': ('items', ['sender', 'recipient', 'nodeIdentifier',
-                            'maxItems', 'itemIdentifiers']),
-        'retract': ('retract', ['sender', 'recipient', 'nodeIdentifier',
-                                'itemIdentifiers']),
-        'purge': ('purge', ['sender', 'recipient', 'nodeIdentifier']),
-        'delete': ('delete', ['sender', 'recipient', 'nodeIdentifier']),
-    }
-
-    _request_class = PubSubRequest
-
-    hideNodes = False
-
-    def __init__(self, resource=None):
-        self.resource = resource
-        self.discoIdentity = {'category': 'pubsub',
-                              'type': 'service',
-                              'name': 'Generic Publish-Subscribe Service'}
-
-        self.pubSubFeatures = []
-
-
-    def connectionMade(self):
-        self.xmlstream.addObserver(PUBSUB_REQUEST, self.handleRequest)
-
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
-        def toInfo(nodeInfo):
-            if not nodeInfo:
-                return
-
-            (nodeType, metaData) = nodeInfo['type'], nodeInfo['meta-data']
-            info.append(disco.DiscoIdentity('pubsub', nodeType))
-            if metaData:
-                form = data_form.Form(formType="result",
-                                      formNamespace=NS_PUBSUB_META_DATA)
-                form.addField(
-                        data_form.Field(
-                            var='pubsub#node_type',
-                            value=nodeType,
-                            label='The type of node (collection or leaf)'
-                        )
-                )
-
-                for metaDatum in metaData:
-                    form.addField(data_form.Field.fromDict(metaDatum))
-
-                info.append(form)
-
-            return
-
-        info = []
-
-        request = self._request_class('discoInfo')
-
-        if self.resource is not None:
-            resource = self.resource.locateResource(request)
-            identity = resource.discoIdentity
-            features = resource.features
-            getInfo = resource.getInfo
-        else:
-            category = self.discoIdentity['category']
-            idType = self.discoIdentity['type']
-            name = self.discoIdentity['name']
-            identity = disco.DiscoIdentity(category, idType, name)
-            features = self.pubSubFeatures
-            getInfo = self.getNodeInfo
-
-        if not nodeIdentifier:
-            info.append(identity)
-            info.append(disco.DiscoFeature(disco.NS_DISCO_ITEMS))
-            info.extend([disco.DiscoFeature("%s#%s" % (NS_PUBSUB, feature))
-                         for feature in features])
-
-        d = defer.maybeDeferred(getInfo, requestor, target, nodeIdentifier or '')
-        d.addCallback(toInfo)
-        d.addErrback(log.err)
-        d.addCallback(lambda _: info)
-        return d
-
-
-    def _parseNodes(self, nodes, target):
-        """parse return values of resource.getNodes
-
-        basestring values are used as node
-        tuple are unpacked as node, name
-        """
-        items = []
-        for node in nodes:
-            if isinstance(node, basestring):
-                items.append(disco.DiscoItem(target, node))
-            else:
-                _node, name = node
-                items.append(disco.DiscoItem(target, _node, name))
-        return items
-
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
-        if self.hideNodes:
-            d = defer.succeed([])
-        elif self.resource is not None:
-            request = self._request_class('discoInfo')
-            resource = self.resource.locateResource(request)
-            d = resource.getNodes(requestor, target, nodeIdentifier)
-        elif nodeIdentifier:
-            d = self.getNodes(requestor, target)
-        else:
-            d = defer.succeed([])
-
-        d.addCallback(self._parseNodes, target)
-        return d
-
-
-    def _onPubSubRequest(self, iq):
-        request = self._request_class.fromElement(iq)
-        if self.resource is not None:
-            resource = self.resource.locateResource(request)
-        else:
-            resource = self
-
-        # Preprocess the request, knowing the handling resource
-        try:
-            preProcessor = getattr(self, '_preProcess_%s' % request.verb)
-        except AttributeError:
-            pass
-        else:
-            request = preProcessor(resource, request)
-            if request is None:
-                return defer.succeed(None)
-
-        # Process the request itself,
-        if resource is not self:
-            try:
-                handler = getattr(resource, request.verb)
-            except AttributeError:
-                text = "Request verb: %s" % request.verb
-                return defer.fail(Unsupported('', text))
-
-            d = handler(request)
-        else:
-            try:
-                handlerName, argNames = self._legacyHandlers[request.verb]
-            except KeyError:
-                text = "Request verb: %s" % request.verb
-                return defer.fail(Unsupported('', text))
-
-            handler = getattr(self, handlerName)
-            args = [getattr(request, arg) for arg in argNames]
-            d = handler(*args)
-
-        # If needed, translate the result into a response
-        try:
-            cb = getattr(self, '_toResponse_%s' % request.verb)
-        except AttributeError:
-            pass
-        else:
-            d.addCallback(cb, resource, request)
-
-        return d
-
-
-    def _toResponse_subscribe(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB, "pubsub"))
-        response.addChild(result.toElement(NS_PUBSUB))
-        return response
-
-
-    def _toResponse_subscriptions(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB, 'pubsub'))
-        subscriptions = response.addElement('subscriptions')
-        for subscription in result:
-            subscriptions.addChild(subscription.toElement(NS_PUBSUB))
-        return response
-
-
-    def _toResponse_affiliations(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB, 'pubsub'))
-        affiliations = response.addElement('affiliations')
-
-        for nodeIdentifier, affiliation in result:
-            item = affiliations.addElement('affiliation')
-            item['node'] = nodeIdentifier
-            item['affiliation'] = affiliation
-
-        return response
-
-
-    def _toResponse_create(self, result, resource, request):
-        if not request.nodeIdentifier or request.nodeIdentifier != result:
-            response = domish.Element((NS_PUBSUB, 'pubsub'))
-            create = response.addElement('create')
-            create['node'] = result
-            return response
-        else:
-            return None
-
-
-    def _formFromConfiguration(self, resource, values):
-        fieldDefs = resource.getConfigurationOptions()
-        form = data_form.Form(formType="form",
-                              formNamespace=NS_PUBSUB_NODE_CONFIG)
-        form.makeFields(values, fieldDefs)
-        return form
-
-
-    def _checkConfiguration(self, resource, form):
-        fieldDefs = resource.getConfigurationOptions()
-        form.typeCheck(fieldDefs, filterUnknown=True)
-
-
-    def _preProcess_create(self, resource, request):
-        if request.options:
-            self._checkConfiguration(resource, request.options)
-        return request
-
-
-    def _preProcess_default(self, resource, request):
-        if request.nodeType not in ('leaf', 'collection'):
-            raise error.StanzaError('not-acceptable')
-        else:
-            return request
-
-
-    def _toResponse_default(self, options, resource, request):
-        response = domish.Element((NS_PUBSUB_OWNER, "pubsub"))
-        default = response.addElement("default")
-        form = self._formFromConfiguration(resource, options)
-        default.addChild(form.toElement())
-        return response
-
-
-    def _toResponse_configureGet(self, options, resource, request):
-        response = domish.Element((NS_PUBSUB_OWNER, "pubsub"))
-        configure = response.addElement("configure")
-        form = self._formFromConfiguration(resource, options)
-        configure.addChild(form.toElement())
-
-        if request.nodeIdentifier:
-            configure["node"] = request.nodeIdentifier
-
-        return response
-
-
-    def _preProcess_configureSet(self, resource, request):
-        if request.options.formType == 'cancel':
-            return None
-        else:
-            self._checkConfiguration(resource, request.options)
-            return request
-
-
-    def _toResponse_items(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB, 'pubsub'))
-        items = response.addElement('items')
-        items["node"] = request.nodeIdentifier
-
-        for item in result:
-            item.uri = NS_PUBSUB
-            items.addChild(item)
-
-        return response
-
-
-    def _createNotification(self, eventType, service, nodeIdentifier,
-                                  subscriber, subscriptions=None):
-        headers = []
-
-        if subscriptions:
-            for subscription in subscriptions:
-                if nodeIdentifier != subscription.nodeIdentifier:
-                    headers.append(('Collection', subscription.nodeIdentifier))
-
-        message = domish.Element((None, "message"))
-        message["from"] = service.full()
-        message["to"] = subscriber.full()
-        event = message.addElement((NS_PUBSUB_EVENT, "event"))
-
-        element = event.addElement(eventType)
-        element["node"] = nodeIdentifier
-
-        if headers:
-            message.addChild(shim.Headers(headers))
-
-        return message
-
-
-    def _toResponse_affiliationsGet(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB_OWNER, 'pubsub'))
-        affiliations = response.addElement('affiliations')
-
-        if request.nodeIdentifier:
-            affiliations['node'] = request.nodeIdentifier
-
-        for entity, affiliation in result.iteritems():
-            item = affiliations.addElement('affiliation')
-            item['jid'] = entity.full()
-            item['affiliation'] = affiliation
-
-        return response
-
-
-    def _toResponse_subscriptionsGet(self, result, resource, request):
-        response = domish.Element((NS_PUBSUB, 'pubsub'))
-        subscriptions = response.addElement('subscriptions')
-        subscriptions['node'] = request.nodeIdentifier
-        for subscription in result:
-            subscription_element = subscription.toElement(NS_PUBSUB)
-            del subscription_element['node']
-            subscriptions.addChild(subscription_element)
-        return response
-
-
-    # public methods
-
-    def notifyPublish(self, service, nodeIdentifier, notifications):
-        for subscriber, subscriptions, items in notifications:
-            message = self._createNotification('items', service,
-                                               nodeIdentifier, subscriber,
-                                               subscriptions)
-            for item in items:
-                item.uri = NS_PUBSUB_EVENT
-                message.event.items.addChild(item)
-            self.send(message)
-
-
-    def notifyRetract(self, service, nodeIdentifier, notifications):
-        for subscriber, subscriptions, items in notifications:
-            message = self._createNotification('items', service,
-                                               nodeIdentifier, subscriber,
-                                               subscriptions)
-            for item in items:
-                retract = domish.Element((None, "retract"))
-                retract['id'] = item['id']
-                message.event.items.addChild(retract)
-            self.send(message)
-
-
-    def notifyDelete(self, service, nodeIdentifier, subscribers,
-                           redirectURI=None):
-        for subscriber in subscribers:
-            message = self._createNotification('delete', service,
-                                               nodeIdentifier,
-                                               subscriber)
-            if redirectURI:
-                redirect = message.event.delete.addElement('redirect')
-                redirect['uri'] = redirectURI
-            self.send(message)
-
-
-    def getNodeInfo(self, requestor, service, nodeIdentifier):
-        return None
-
-
-    def getNodes(self, requestor, service):
-        return []
-
-
-    def publish(self, requestor, service, nodeIdentifier, items):
-        raise Unsupported('publish')
-
-
-    def subscribe(self, requestor, service, nodeIdentifier, subscriber):
-        raise Unsupported('subscribe')
-
-
-    def unsubscribe(self, requestor, service, nodeIdentifier, subscriber):
-        raise Unsupported('subscribe')
-
-
-    def subscriptions(self, requestor, service):
-        raise Unsupported('retrieve-subscriptions')
-
-
-    def affiliations(self, requestor, service):
-        raise Unsupported('retrieve-affiliations')
-
-
-    def create(self, requestor, service, nodeIdentifier):
-        raise Unsupported('create-nodes')
-
-
-    def getConfigurationOptions(self):
-        return {}
-
-
-    def getDefaultConfiguration(self, requestor, service, nodeType):
-        raise Unsupported('retrieve-default')
-
-
-    def getConfiguration(self, requestor, service, nodeIdentifier):
-        raise Unsupported('config-node')
-
-
-    def setConfiguration(self, requestor, service, nodeIdentifier, options):
-        raise Unsupported('config-node')
-
-
-    def items(self, requestor, service, nodeIdentifier, maxItems,
-                    itemIdentifiers):
-        raise Unsupported('retrieve-items')
-
-
-    def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
-        raise Unsupported('retract-items')
-
-
-    def purge(self, requestor, service, nodeIdentifier):
-        raise Unsupported('purge-nodes')
-
-
-    def delete(self, requestor, service, nodeIdentifier):
-        raise Unsupported('delete-nodes')
-
-
-
-class PubSubResource(object):
-
-    implements(IPubSubResource)
-
-    features = []
-    discoIdentity = disco.DiscoIdentity('pubsub',
-                                        'service',
-                                        'Publish-Subscribe Service')
-
-
-    def locateResource(self, request):
-        return self
-
-
-    def getInfo(self, requestor, service, nodeIdentifier):
-        return defer.succeed(None)
-
-
-    def getNodes(self, requestor, service, nodeIdentifier):
-        return defer.succeed([])
-
-
-    def getConfigurationOptions(self):
-        return {}
-
-
-    def publish(self, request):
-        return defer.fail(Unsupported('publish'))
-
-
-    def subscribe(self, request):
-        return defer.fail(Unsupported('subscribe'))
-
-
-    def unsubscribe(self, request):
-        return defer.fail(Unsupported('subscribe'))
-
-
-    def subscriptions(self, request):
-        return defer.fail(Unsupported('retrieve-subscriptions'))
-
-
-    def affiliations(self, request):
-        return defer.fail(Unsupported('retrieve-affiliations'))
-
-
-    def create(self, request):
-        return defer.fail(Unsupported('create-nodes'))
-
-
-    def default(self, request):
-        return defer.fail(Unsupported('retrieve-default'))
-
-
-    def configureGet(self, request):
-        return defer.fail(Unsupported('config-node'))
-
-
-    def configureSet(self, request):
-        return defer.fail(Unsupported('config-node'))
-
-
-    def items(self, request):
-        return defer.fail(Unsupported('retrieve-items'))
-
-
-    def retract(self, request):
-        return defer.fail(Unsupported('retract-items'))
-
-
-    def purge(self, request):
-        return defer.fail(Unsupported('purge-nodes'))
-
-
-    def delete(self, request):
-        return defer.fail(Unsupported('delete-nodes'))
-
-
-    def affiliationsGet(self, request):
-        return defer.fail(Unsupported('retrieve-affiliations'))
-
-
-    def affiliationsSet(self, request):
-        return defer.fail(Unsupported('modify-affiliations'))
-
-
-    def subscriptionsGet(self, request):
-        return defer.fail(Unsupported('manage-subscriptions'))
-
-
-    def subscriptionsSet(self, request):
-        return defer.fail(Unsupported('manage-subscriptions'))
--- a/wokkel/rsm.py	Wed Nov 01 22:34:51 2017 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,425 +0,0 @@
-# -*- coding: utf-8 -*-
-# -*- test-case-name: wokkel.test.test_rsm -*-
-#
-# SàT Wokkel extension for Result Set Management (XEP-0059)
-# Copyright (C) 2015 Adien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at