[CalendarServer-changes] [15047] CalendarServer/branches/users/sredmond/clientsim/contrib/ performance/loadtest

source_changes at macosforge.org source_changes at macosforge.org
Mon Aug 17 13:38:09 PDT 2015


Revision: 15047
          http://trac.calendarserver.org//changeset/15047
Author:   sredmond at apple.com
Date:     2015-08-17 13:38:08 -0700 (Mon, 17 Aug 2015)
Log Message:
-----------
Adds files from client sim development

Added Paths:
-----------
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/README.md
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/clients.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/config.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/debug.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/push.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/records.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/Profile
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/StartupProfile
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_depth1_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendarhome_depth1_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_notification_depth1_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/post_freebusy.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/principal_search_report.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/report_principal_search.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_color_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_description_proppatch.request.xml
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_displayname_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_order_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_timezone_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_transparent_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_date_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_datetime_proppatch.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_create_calendar.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_delegate_principal_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_expand.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_initial_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principals_report.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_query_events_depth1_report.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_well_known_propfind.request
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/requester.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/resources.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/__init__.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.json
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.plist
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.dist.plist
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.json
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.plist
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/demo-clients.plist
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/templates.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/__init__.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_ical.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_logger.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_population.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_profiles.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_sim.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_trafficlogger.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_webadmin.py
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/www/
    CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/www/layout.html

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/README.md
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/README.md	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/README.md	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,14 @@
+# Calendar Client Simulator
+
+This documentation is for developers interested in contributing to the Calendar Client Simulator. For people intending to *use* the simulator, see some-file-here.rst.
+
+## Table of Contents
+...
+
+## Project Layout
+
+Modules
+
+## Design decisions
+
+## Style Guide
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/clients.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/clients.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/clients.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,389 @@
+from caldavclientlibrary.protocol.webdav.definitions import davxml
+
+from contrib.performance.loadtest.ical import BaseAppleClient
+
+from pycalendar.datetime import DateTime
+
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed
+
+from twisted.python.filepath import FilePath
+
+def loadRequestBody(clientType, label):
+    return FilePath(__file__).sibling('request-data').child(clientType).child(label + '.request').getContent()
+
+class iOS_5(BaseAppleClient):
+    """
+    Implementation of the iOS 5 network behavior.
+    """
+
+    _client_type = "iOS 5"
+
+    USER_AGENT = "iOS/5.1 (9B179) dataaccessd/1.0"
+
+    # The default interval, used if none is specified in external
+    # configuration.  This is also the actual value used by Snow
+    # Leopard iCal.
+    CALENDAR_HOME_POLL_INTERVAL = 15 * 60
+
+    # The maximum number of resources to retrieve in a single multiget
+    MULTIGET_BATCH_SIZE = 50
+
+    # Override and turn on if client supports Sync REPORT
+    _SYNC_REPORT = False
+
+    # Override and turn off if client does not support attendee lookups
+    _ATTENDEE_LOOKUPS = False
+
+    # Request body data
+    _LOAD_PATH = "iOS_5"
+
+    _STARTUP_WELL_KNOWN = loadRequestBody(_LOAD_PATH, 'startup_well_known')
+    _STARTUP_PRINCIPAL_PROPFIND_INITIAL = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind_initial')
+    _STARTUP_PRINCIPAL_PROPFIND = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind')
+    _STARTUP_PRINCIPALS_REPORT = loadRequestBody(_LOAD_PATH, 'startup_principals_report')
+    _STARTUP_PROPPATCH_CALENDAR_COLOR = loadRequestBody(_LOAD_PATH, 'startup_calendar_color_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_ORDER = loadRequestBody(_LOAD_PATH, 'startup_calendar_order_proppatch')
+
+    _POLL_CALENDARHOME_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendarhome_propfind')
+    _POLL_CALENDAR_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_CALENDAR_VEVENT_TR_QUERY = loadRequestBody(_LOAD_PATH, 'poll_calendar_vevent_tr_query')
+    _POLL_CALENDAR_VTODO_QUERY = loadRequestBody(_LOAD_PATH, 'poll_calendar_vtodo_query')
+    _POLL_CALENDAR_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind_d1')
+    _POLL_CALENDAR_MULTIGET_REPORT = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget')
+    _POLL_CALENDAR_MULTIGET_REPORT_HREF = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget_hrefs')
+
+    @inlineCallbacks
+    def _pollFirstTime1(self, homeNode, calendars):
+        # Patch calendar properties
+        for cal in calendars:
+            if cal.name != "inbox":
+                yield self._proppatch(
+                    cal.url,
+                    self._STARTUP_PROPPATCH_CALENDAR_COLOR,
+                    method_label="PROPPATCH{calendar}",
+                )
+                yield self._proppatch(
+                    cal.url,
+                    self._STARTUP_PROPPATCH_CALENDAR_ORDER,
+                    method_label="PROPPATCH{calendar}",
+                )
+
+
+    def _pollFirstTime2(self):
+        # Nothing here
+        return succeed(None)
+
+
+    def _updateCalendar(self, calendar, newToken):
+        """
+        Update the local cached data for a calendar in an appropriate manner.
+        """
+        if calendar.name == "inbox":
+            # Inbox is done as a PROPFIND Depth:1
+            return self._updateCalendar_PROPFIND(calendar, newToken)
+        elif "VEVENT" in calendar.componentTypes:
+            # VEVENTs done as time-range VEVENT-only queries
+            return self._updateCalendar_VEVENT(calendar, newToken)
+        elif "VTODO" in calendar.componentTypes:
+            # VTODOs done as VTODO-only queries
+            return self._updateCalendar_VTODO(calendar, newToken)
+
+
+    @inlineCallbacks
+    def _updateCalendar_VEVENT(self, calendar, newToken):
+        """
+        Sync all locally cached VEVENTs using a VEVENT-only time-range query.
+        """
+
+        # Grab old hrefs prior to the PROPFIND so we sync with the old state. We need this because
+        # the sim can fire a PUT between the PROPFIND and when process the removals.
+        old_hrefs = set([calendar.url + child for child in calendar.events.keys()])
+
+        now = DateTime.getNowUTC()
+        now.setDateOnly(True)
+        now.offsetMonth(-1) # 1 month back default
+        result = yield self._report(
+            calendar.url,
+            self._POLL_CALENDAR_VEVENT_TR_QUERY % {"start-date": now.getText()},
+            depth='1',
+            method_label="REPORT{vevent}",
+        )
+
+        yield self._updateApplyChanges(calendar, result, old_hrefs)
+
+        # Now update calendar to the new token
+        self._calendars[calendar.url].changeToken = newToken
+
+
+    @inlineCallbacks
+    def _updateCalendar_VTODO(self, calendar, newToken):
+        """
+        Sync all locally cached VTODOs using a VTODO-only query.
+        """
+
+        # Grab old hrefs prior to the PROPFIND so we sync with the old state. We need this because
+        # the sim can fire a PUT between the PROPFIND and when process the removals.
+        old_hrefs = set([calendar.url + child for child in calendar.events.keys()])
+
+        result = yield self._report(
+            calendar.url,
+            self._POLL_CALENDAR_VTODO_QUERY,
+            depth='1',
+            method_label="REPORT{vtodo}",
+        )
+
+        yield self._updateApplyChanges(calendar, result, old_hrefs)
+
+        # Now update calendar to the new token
+        self._calendars[calendar.url].changeToken = newToken
+
+
+    @inlineCallbacks
+    def startup(self):
+
+        # Try to read data from disk - if it succeeds self.principalURL will be set
+        self.deserialize()
+
+        if self.principalURL is None:
+            # PROPFIND well-known with redirect
+            response = yield self._startupPropfindWellKnown()
+            hrefs = response.getHrefProperties()
+            if davxml.current_user_principal in hrefs:
+                self.principalURL = hrefs[davxml.current_user_principal].toString()
+            elif davxml.principal_URL in hrefs:
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+            else:
+                # PROPFIND principal path to retrieve actual principal-URL
+                response = yield self._principalPropfindInitial(self.record.uid)
+                hrefs = response.getHrefProperties()
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+
+        # Using the actual principal URL, retrieve principal information
+        principal = yield self._extractPrincipalDetails()
+        returnValue(principal)
+
+
+
+class OS_X_10_6(BaseAppleClient):
+    """
+    Implementation of the OS X 10.6 iCal network behavior.
+
+    Anything OS X 10.6 iCal does on its own, or any particular
+    network behaviors it takes in response to a user action, belong on
+    this class.
+
+    Usage-profile based behaviors ("the user modifies an event every
+    3.2 minutes") belong elsewhere.
+    """
+
+    _client_type = "OS X 10.6"
+
+    USER_AGENT = "DAVKit/4.0.3 (732); CalendarStore/4.0.3 (991); iCal/4.0.3 (1388); Mac OS X/10.6.4 (10F569)"
+
+    # The default interval, used if none is specified in external
+    # configuration.  This is also the actual value used by Snow
+    # Leopard iCal.
+    CALENDAR_HOME_POLL_INTERVAL = 15 * 60
+
+    # The maximum number of resources to retrieve in a single multiget
+    MULTIGET_BATCH_SIZE = 200
+
+    # Override and turn on if client supports Sync REPORT
+    _SYNC_REPORT = False
+
+    # Override and turn off if client does not support attendee lookups
+    _ATTENDEE_LOOKUPS = True
+
+    # Request body data
+    _LOAD_PATH = "OS_X_10_6"
+
+    _STARTUP_WELL_KNOWN = loadRequestBody(_LOAD_PATH, 'startup_well_known')
+    _STARTUP_PRINCIPAL_PROPFIND_INITIAL = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind_initial')
+    _STARTUP_PRINCIPAL_PROPFIND = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind')
+    _STARTUP_PRINCIPALS_REPORT = loadRequestBody(_LOAD_PATH, 'startup_principals_report')
+    _STARTUP_PRINCIPAL_EXPAND = loadRequestBody(_LOAD_PATH, 'startup_principal_expand')
+    _STARTUP_PROPPATCH_CALENDAR_COLOR = loadRequestBody(_LOAD_PATH, 'startup_calendar_color_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_ORDER = loadRequestBody(_LOAD_PATH, 'startup_calendar_order_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_TIMEZONE = loadRequestBody(_LOAD_PATH, 'startup_calendar_timezone_proppatch')
+
+    _POLL_CALENDARHOME_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendarhome_propfind')
+    _POLL_CALENDAR_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_CALENDAR_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind_d1')
+    _POLL_CALENDAR_MULTIGET_REPORT = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget')
+    _POLL_CALENDAR_MULTIGET_REPORT_HREF = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget_hrefs')
+    _POLL_CALENDAR_SYNC_REPORT = None
+    _POLL_NOTIFICATION_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_NOTIFICATION_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_notification_propfind_d1')
+
+    _USER_LIST_PRINCIPAL_PROPERTY_SEARCH = loadRequestBody(_LOAD_PATH, 'user_list_principal_property_search')
+    _POST_AVAILABILITY = loadRequestBody(_LOAD_PATH, 'post_availability')
+
+    @inlineCallbacks
+    def startup(self):
+
+        # Try to read data from disk - if it succeeds self.principalURL will be set
+        self.deserialize()
+
+        if self.principalURL is None:
+            # PROPFIND principal path to retrieve actual principal-URL
+            response = yield self._principalPropfindInitial(self.record.uid)
+            hrefs = response.getHrefProperties()
+            self.principalURL = hrefs[davxml.principal_URL].toString()
+
+        # Using the actual principal URL, retrieve principal information
+        principal = (yield self._extractPrincipalDetails())
+        returnValue(principal)
+
+
+
+class OS_X_10_7(BaseAppleClient):
+    """
+    Implementation of the OS X 10.7 iCal network behavior.
+    """
+
+    _client_type = "OS X 10.7"
+
+    USER_AGENT = "CalendarStore/5.0.2 (1166); iCal/5.0.2 (1571); Mac OS X/10.7.3 (11D50)"
+
+    # The default interval, used if none is specified in external
+    # configuration.  This is also the actual value used by Snow
+    # Leopard iCal.
+    CALENDAR_HOME_POLL_INTERVAL = 15 * 60
+
+    # The maximum number of resources to retrieve in a single multiget
+    MULTIGET_BATCH_SIZE = 50
+
+    # Override and turn on if client supports Sync REPORT
+    _SYNC_REPORT = True
+
+    # Override and turn off if client does not support attendee lookups
+    _ATTENDEE_LOOKUPS = True
+
+    # Request body data
+    _LOAD_PATH = "OS_X_10_7"
+
+    _STARTUP_WELL_KNOWN = loadRequestBody(_LOAD_PATH, 'startup_well_known')
+    _STARTUP_PRINCIPAL_PROPFIND_INITIAL = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind_initial')
+    _STARTUP_PRINCIPAL_PROPFIND = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind')
+    _STARTUP_PRINCIPALS_REPORT = loadRequestBody(_LOAD_PATH, 'startup_principals_report')
+    _STARTUP_PRINCIPAL_EXPAND = loadRequestBody(_LOAD_PATH, 'startup_principal_expand')
+    _STARTUP_PROPPATCH_CALENDAR_COLOR = loadRequestBody(_LOAD_PATH, 'startup_calendar_color_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_ORDER = loadRequestBody(_LOAD_PATH, 'startup_calendar_order_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_TIMEZONE = loadRequestBody(_LOAD_PATH, 'startup_calendar_timezone_proppatch')
+
+    _POLL_CALENDARHOME_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendarhome_propfind')
+    _POLL_CALENDAR_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_CALENDAR_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind_d1')
+    _POLL_CALENDAR_MULTIGET_REPORT = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget')
+    _POLL_CALENDAR_MULTIGET_REPORT_HREF = loadRequestBody(_LOAD_PATH, 'poll_calendar_multiget_hrefs')
+    _POLL_CALENDAR_SYNC_REPORT = loadRequestBody(_LOAD_PATH, 'poll_calendar_sync')
+    _POLL_NOTIFICATION_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_NOTIFICATION_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_notification_propfind_d1')
+
+    _USER_LIST_PRINCIPAL_PROPERTY_SEARCH = loadRequestBody(_LOAD_PATH, 'user_list_principal_property_search')
+    _POST_AVAILABILITY = loadRequestBody(_LOAD_PATH, 'post_availability')
+
+
+    @inlineCallbacks
+    def startup(self):
+
+        # Try to read data from disk - if it succeeds self.principalURL will be set
+        self.deserialize()
+
+        if self.principalURL is None:
+            # PROPFIND well-known with redirect
+            response = yield self._startupPropfindWellKnown()
+            hrefs = response.getHrefProperties()
+            if davxml.current_user_principal in hrefs:
+                self.principalURL = hrefs[davxml.current_user_principal].toString()
+            elif davxml.principal_URL in hrefs:
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+            else:
+                # PROPFIND principal path to retrieve actual principal-URL
+                response = yield self._principalPropfindInitial(self.record.uid)
+                hrefs = response.getHrefProperties()
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+
+        # Using the actual principal URL, retrieve principal information
+        principal = yield self._extractPrincipalDetails()
+        returnValue(principal)
+
+
+
+class OS_X_10_11(BaseAppleClient):
+    """
+    Implementation of the OS X 10.11 Calendar.app network behavior.
+    """
+
+    _client_type = "OS X 10.11"
+
+    USER_AGENT = "Mac+OS+X/10.11 (15A216g) CalendarAgent/353"
+
+    # The default interval, used if none is specified in external
+    # configuration.  This is also the actual value used by El
+    # Capital Calendar.app.
+    CALENDAR_HOME_POLL_INTERVAL = 15 * 60  # in seconds
+
+    # The maximum number of resources to retrieve in a single multiget
+    MULTIGET_BATCH_SIZE = 50
+
+    # Override and turn on if client supports Sync REPORT
+    _SYNC_REPORT = True
+
+    # Override and turn off if client does not support attendee lookups
+    _ATTENDEE_LOOKUPS = True
+
+    # Request body data
+    _LOAD_PATH = "OS_X_10_11"
+
+    _STARTUP_WELL_KNOWN = loadRequestBody(_LOAD_PATH, 'startup_well_known_propfind')
+    _STARTUP_PRINCIPAL_PROPFIND_INITIAL = loadRequestBody(_LOAD_PATH, 'startup_principal_initial_propfind')
+    _STARTUP_PRINCIPAL_PROPFIND = loadRequestBody(_LOAD_PATH, 'startup_principal_propfind')
+    _STARTUP_PRINCIPALS_REPORT = loadRequestBody(_LOAD_PATH, 'startup_principals_report')
+    # _STARTUP_PRINCIPAL_EXPAND = loadRequestBody(_LOAD_PATH, 'startup_principal_expand')
+
+    _STARTUP_CREATE_CALENDAR = loadRequestBody(_LOAD_PATH, 'startup_create_calendar')
+    _STARTUP_PROPPATCH_CALENDAR_COLOR = loadRequestBody(_LOAD_PATH, 'startup_calendar_color_proppatch')
+    # _STARTUP_PROPPATCH_CALENDAR_NAME = loadRequestBody(_LOAD_PATH, 'startup_calendar_displayname_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_ORDER = loadRequestBody(_LOAD_PATH, 'startup_calendar_order_proppatch')
+    _STARTUP_PROPPATCH_CALENDAR_TIMEZONE = loadRequestBody(_LOAD_PATH, 'startup_calendar_timezone_proppatch')
+
+    _POLL_CALENDARHOME_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendarhome_depth1_propfind')
+    _POLL_CALENDAR_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_CALENDAR_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_calendar_depth1_propfind')
+    _POLL_CALENDAR_MULTIGET_REPORT = loadRequestBody('OS_X_10_7', 'poll_calendar_multiget')
+    _POLL_CALENDAR_MULTIGET_REPORT_HREF = loadRequestBody('OS_X_10_7', 'poll_calendar_multiget_hrefs')
+    _POLL_CALENDAR_SYNC_REPORT = loadRequestBody('OS_X_10_7', 'poll_calendar_sync')
+    _POLL_NOTIFICATION_PROPFIND = loadRequestBody(_LOAD_PATH, 'poll_calendar_propfind')
+    _POLL_NOTIFICATION_PROPFIND_D1 = loadRequestBody(_LOAD_PATH, 'poll_notification_depth1_propfind')
+
+    _USER_LIST_PRINCIPAL_PROPERTY_SEARCH = loadRequestBody('OS_X_10_7', 'user_list_principal_property_search')
+    _POST_AVAILABILITY = loadRequestBody('OS_X_10_7', 'post_availability')
+
+    _CALENDARSERVER_PRINCIPAL_SEARCH_REPORT = loadRequestBody(_LOAD_PATH, 'principal_search_report')
+
+    @inlineCallbacks
+    def startup(self):
+        # Try to read data from disk - if it succeeds self.principalURL will be set
+        self.deserialize()
+
+        if self.principalURL is None:
+            # print("No cached principal URL found - starting from scratch")
+            # PROPFIND well-known with redirect
+            response = yield self._startupPropfindWellKnown()
+            hrefs = response.getHrefProperties()
+            if davxml.current_user_principal in hrefs:
+                self.principalURL = hrefs[davxml.current_user_principal].toString()
+            elif davxml.principal_URL in hrefs:
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+            else:
+                # PROPFIND principal path to retrieve actual principal-URL
+                response = yield self._principalPropfindInitial(self.record.uid)
+                hrefs = response.getHrefProperties()
+                self.principalURL = hrefs[davxml.principal_URL].toString()
+        # print("Principal URL: " + self.principalURL)
+
+        # Using the actual principal URL, retrieve principal information
+        principal = yield self._extractPrincipalDetails()
+        # print("Principal: " + str(principal))
+        returnValue(principal)

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/config.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/config.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/config.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,81 @@
+from importlib import import_module
+
+from contrib.performance.loadtest.logger import ReportStatistics, RequestLogger, OperationLogger
+from contrib.performance.loadtest.sim import recordsFromCSVFile
+
+DEFAULTS = {
+    server = "https://127.0.0.1:8443"
+
+    accounts = recordsFromCSVFile("contrib/performance/loadtest/accounts.csv")
+
+    _requestLogger = RequestLogger()
+    _operationLogger = OperationLogger(
+        thresholdsPath="contrib/performance/loadtest/thresholds.json",
+        lagCutoff=1.0,
+        failCutoff=1.0
+    )
+    _statisticsReporter = ReportStatistics(
+        thresholdsPath="contrib/performance/loadtest/thresholds.json",
+        benchmarksPath="contrib/performance/loadtest/benchmarks.json",
+        failCutoff=1.0
+    )
+
+    arrival = SmoothRampUp(
+        groups=2,
+        groupSize=1,
+        interval=3,
+        clientsPerUser=1
+    )
+}
+
+class Config(object):
+
+
+    def __init__(self, serverConfigFile, clientConfigFile):
+        # These are modules
+        serverConfigModule = import_module(serverConfigFile)
+        clientConfigModule = import_module(clientConfigFile)
+
+        self.clients = clientConfigModule.clientConfiguration
+        self.workers = workers
+        self.configTemplate = configTemplate
+        self.workerID = workerID
+        self.workerCount = workerCount
+
+        self.server = serverConfig.get('server')
+        self.webadminPort = serverConfig.get('webadminPort')
+        self.serverStats = serverConfig.get('serverStatsPort')
+        self.serializationPath = serverConfig.get('serializationPath')
+        self.arrival = serverConfig.get('arrival')
+        self.observers = serverConfig.get('observers')
+        self.records = serverConfig.get('records')
+        self.workers = serverConfig.get('workers')
+
+        self.buildParameters()
+
+    def buildParameters(self):
+        self.parameters = PopulationParameters()
+        for client in self.clients:
+            self.parameters.addClient(
+                client["weight"],
+                ClientType(
+                    client["software"],
+                    client["params"],
+                    client["profiles"]
+                )
+            )
+
+    def buildSerializationPath(self):
+        if self.serializationPath:
+            if not isdir(serializationPath):
+                try:
+                    mkdir(serializationPath)
+                except OSError:
+                    print("Unable to create client data serialization directory: %s" % (serializationPath))
+                    print("Please consult the clientDataSerialization stanza of contrib/performance/loadtest/config.plist")
+                    raise
+
+    def get(self, attr):
+        if hasattr(self, attr):
+            return getattr(self, attr)
+        return DEFAULTS.get(attr, None)

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/debug.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/debug.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/debug.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,27 @@
+def printargs(func):
+    """
+    This decorator prints the arguments passed to a function before calling it
+
+    Example:
+        @printargs
+        def foo(a, b, c, *args, **kwargs):
+            pass
+
+        foo(1, 2, 3, 4, 5, x=6, y=7)
+        # prints `foo(a:1, b:2, c:3, args=(4, 5), kwargs={'y': 7, 'x': 6})`
+
+    """
+    fname = func.func_name
+    fc = func.func_code
+    argcount = fc.co_argcount
+    argnames = fc.co_varnames[:argcount]
+    def wrapper(*args, **kwargs):
+        named_args = ', '.join(['{0}: {1}'.format(arg, val) for arg, val in zip(argnames, args[:argcount])])
+        print "{0}({1}, args={2}, kwargs={3})".format(
+            fname,
+            named_args,
+            args[argcount:],
+            kwargs
+        )
+        return func(*args, **kwargs)
+    return wrapper

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/push.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/push.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/push.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,76 @@
+from calendarserver.push.amppush import subscribeToIDs
+from twisted.internet.defer import succeed
+
+class PushMonitor(object):
+    """
+    Representation of a watchguard that monitors
+    push notifications (AMP Push)
+    """
+
+    def __init__(
+        self,
+        reactor,
+        ampPushHost,
+        ampPushPort,
+        callback
+    ):
+        """
+        reactor: Twisted reactor
+        ampPushHost: localhost
+        ampPushPort: 62311
+        callback: a one-argument function that is fired with a calendar href
+                  upon receipt of a push notification for that resource
+        """
+        self.reactor = reactor
+        self.ampPushHost = ampPushHost
+        self.ampPushPort = ampPushPort
+
+        # Keep track of AMP parameters for calendar homes we encounter.  This
+        # dictionary has calendar home URLs as keys and pushkeys as
+        # values.
+        self.ampPushkeys = {}
+
+    def begin(self):
+        self._monitorAmpPush()
+
+    def end(self):
+        pass
+
+
+    def _monitorAmpPush(self):
+        """
+        Start monitoring for AMP-based push notifications
+        """
+        subscribeToIDs(
+            self.ampPushHost, self.ampPushPort, self.ampPushkeys,
+            self._receivedAMPPush, self.reactor
+        )
+
+
+    def _receivedAMPPush(self, inboundID, dataChangedTimestamp, priority=5):
+        print("-" * 64)
+        print("{} received a PUSH with ID={}, timestamp={}, priority={}".format(self.record.commonName, inboundID, dataChangedTimestamp, priority))
+        print("By the way, my AMP keys are {}".format(self.ampPushkeys))
+        print("-" * 64)
+
+        for href, calendar_id in self.ampPushkeys.iteritems():
+            if inboundID == calendar_id:
+                self.callback(href)
+                break
+        else:
+            # Somehow we are not subscribed to this inboundID
+            print("*" * 16 + "Oh no - we're not subscribed to " + str(inboundID) + " but we received a notification anyway!")
+            pass
+
+    def unsubscribeFromAll(self):
+        return succeed(None)
+
+
+    def addPushkey(self, href, pushkey):
+        pass # Should I subscribe to IDs right now?
+
+    def removePushkey(self, pushkey):
+        pass # Should I unsubscribe right now
+
+    def isSubscribedTo(self, href):
+        return href in self.ampPushkeys

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/records.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/records.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/records.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,36 @@
+from twisted.python.filepath import FilePath
+
+class DirectoryRecord(object):
+    def __init__(self, uid, password, commonName, email, guid):
+        self.uid = uid
+        self.password = password
+        self.commonName = commonName
+        self.email = email
+        self.guid = guid
+
+    def __repr__(self):
+        return "Record(%s:%s %s %s %s)" % (self.uid, self.password, self.commonName, self.email, self.guid)
+
+# def generateRecords(
+#     count, uidPattern="user%d", passwordPattern="user%d",
+#     namePattern="User %d", emailPattern="user%d at example.com",
+#     guidPattern="user%d"
+# ):
+#     for i in xrange(count):
+#         i += 1
+#         uid = uidPattern % (i,)
+#         password = passwordPattern % (i,)
+#         name = namePattern % (i,)
+#         email = emailPattern % (i,)
+#         guid = guidPattern % (i,)
+#         yield DirectoryRecord(uid, password, name, email, guid)
+
+def recordsFromCSVFile(path):
+    if path:
+        pathObj = FilePath(path)
+    else:
+        pathObj = FilePath(__file__).sibling("accounts.csv")
+    return [
+        DirectoryRecord(*line.decode('utf-8').split(u','))
+        for line
+        in pathObj.getContent().splitlines()]

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/Profile
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/Profile	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/Profile	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,267 @@
+PROPFIND ./well-known/caldav				->	/principals/
+	current-user-principal
+	principal-URL
+	resourcetype
+
+PROPFIND /principals/						->
+	current-user-principal						/principals/__uids__/<uid>
+	principal-URL								----
+	resourcetype								collection
+
+OPTIONS /principals/__uids__/<uid>/
+
+PROPFIND /principals/__uids__/<uid>/
+	calendar-home-set							/calendars/__uids__/<uid>/
+	calendar-user-address-set					mailto:user#@example.com
+												urn:uuid:<uid>
+												urn:x-uid:<uid>
+	current-user-principal						/principals/__uids__/<uid>/
+	displayname									User #
+	dropbox-home-URL							/calendars/__uids__/<uid>/dropbox/
+	email-address-set							user#@example.com
+	notification-URL							/calendars/__uids__/<uid>/notification/
+	principal-collection-set					/principals/
+	principal-URL								/principals/__uids__/<uid>/
+	resource-id									urn:x-uid:<uid>
+	schedule-inbox-URL							/calendars/__uids__/<uid>/inbox/
+	schedule-outbox-URL							/calendars/__uids__/<uid>/outbox/
+	supported-report-set						acl-principal-prop-set
+												principal-match
+												principal-property-search
+												expand-property
+												calendarserver-principal-search
+
+OPTIONS /principals/__uids__/<uid>
+
+REPORT /principals/							-> 
+	principal-search-property-set				displayname
+												email-address-set
+												calendar-user-address-set
+												calendar-user-type 
+
+PROPFIND /calendars/__uids__/<uid>/inbox/	->	
+	calendar-availability						???
+
+PROPFIND /calendars/__uids__/<uid>/
+Depth 1
+	add-member									
+	allowed-sharing-modes									
+	autoprovisioned									
+	bulk-requests									
+	calendar-alarm									
+	calendar-color									
+	calendar-description									
+	calendar-free-busy-set									
+	calendar-order									
+	calendar-timezone									
+	current-user-privilege-set					all/read/read-free-busy/write/write-properties/write-content/bind/unbind/unlock/read-acl/write-acl/read-current-user-privilege-set				
+	default-alarm-vevent-date									
+	default-alarm-vevent-datetime									
+	displayname									User #
+	getctag									
+	invite									
+	language-code									
+	location-code									
+	owner										/principals/__uids__/<uid>/
+	pre-publish-url									
+	publish-url									
+	push-transports									
+	pushkey										/CalDAV/localhost/<uid>/
+	quota-available-bytes						104857600
+	quota-used-bytes							0
+	refreshrate									
+	resource-id									
+	resourcetype								collection	
+	schedule-calendar-transp									
+	schedule-default-calendar-URL									
+	source									
+	subscribed-strip-alarms									
+	subscribed-strip-attachments									
+	subscribed-strip-todos									
+	supported-calendar-component-set			VEVENT/VTODO						
+	supported-calendar-component-sets									
+	supported-report-set						acl-principal-prop-set/principal-match/principal-property-search/expand-property/calendarserver-principal-search/calendar-query/calendar-multiget/free-busy-query/addressbook-query/addressbook-multiget/sync-collection			
+	sync-token									data:,36_58/<hex>
+ 	** and more **
+
+PROPPATCH /calendars/__uids__/<uid>/		->		default-alarm-vevent-date
+PROPPATCH /calendars/__uids__/<uid>/		->		default-alarm-vevent-datetime
+
+PROPPATCH /calendars/__uids__/<uid>/calendar/	->		calendar-order
+PROPPATCH /calendars/__uids__/<uid>/calendar/	->		displayname
+PROPPATCH /calendars/__uids__/<uid>/calendar/	->		calendar-color
+PROPPATCH /calendars/__uids__/<uid>/calendar/	->		calendar-order
+PROPPATCH /calendars/__uids__/<uid>/calendar/	->		calendar-timezone
+
+PROPPATCH /calendars/__uids__/<uid>/tasks/	->		calendar-order
+PROPPATCH /calendars/__uids__/<uid>/tasks/	->		displayname
+PROPPATCH /calendars/__uids__/<uid>/tasks/	->		calendar-color
+PROPPATCH /calendars/__uids__/<uid>/tasks/	->		calendar-order
+PROPPATCH /calendars/__uids__/<uid>/tasks/	->		calendar-timezone
+
+PROPFIND /calendars/__uids__/<uid>/calendar/->
+	getctag										37_63
+	sync-token									data:,37_63/<hex>
+
+REPORT /calendars/__uids__/<uid>/calendar/ 	->
+	getcontenttype
+	getetag
+REPORT /calendar/__uids__/<uid>/calendar/
+	getcontenttype
+	getetag
+
+PROPFIND /calendars/__uids__/<uid>/			->
+	checksum-versions							???
+
+PROPFIND /calendars/__uids__/<uid>/calendar/	->
+	getctag										
+	sync-token										
+PROPFIND /calendars/__uids__/<uid>/calendar/
+	getcontenttype								httpd/unix-directory
+	getetag										"<hex>"
+
+PROPFIND /calendars/__uids__/<uid>/			-> (again?) 
+	checksum-versions
+
+PROPFIND /calendars/__uids__/<uid>/tasks/	->
+	getctag
+	sync-token
+PROPFIND /calendars/__uids__/<uid>/tasks/	->
+	getcontenttype
+	getetag
+
+PROPFIND /calendars/__uids__/<uid>/inbox/	->
+	getctag
+	sync-token
+PROPFIND /calendars/__uids__/<uid>/inbox/	->
+	getcontenttype
+	getetag
+
+PROPFIND /calendars/__uids__/<uid>/tasks/	->
+	getctag
+	sync-token
+PROPFIND /calendars/__uids__/<uid>/tasks/	->
+	getcontenttype
+	getetag
+
+PROPFIND /calendars/__uids__/<uid>/notification/	->
+	getctag
+	sync-token
+PROPFIND /calendars/__uids__/<uid>/notification/	->
+	notificationtype
+	getetag
+
+REPORT /principals/__uids__/<uid>/
+	calendar-proxy-write-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+	calendar-proxy-read-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+
+REPORT /calendars/__uids__/<uid>/
+	sync-collection
+		sync-token
+		sync-level
+		*lots of properties*
+
+PROPFIND /calendars/__uids__/<uid>/inbox/
+	getctag
+	sync-token
+
+PROPFIND /principals/__uids__/<uid>/
+	calendar-proxy-write-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+	calendar-proxy-read-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+
+----------------------------------------------------------------
+Deep Refresh (CMD + SHIFT + R)
+
+PROPFIND /principals/__uids__/<uid>/
+	<B:calendar-home-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:calendar-user-address-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:current-user-principal/>
+    <A:displayname/>
+    <C:dropbox-home-URL xmlns:C="http://calendarserver.org/ns/"/>
+    <C:email-address-set xmlns:C="http://calendarserver.org/ns/"/>
+    <C:notification-URL xmlns:C="http://calendarserver.org/ns/"/>
+    <A:principal-collection-set/>
+    <A:principal-URL/>
+    <A:resource-id/>
+    <B:schedule-inbox-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:schedule-outbox-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:supported-report-set/>
+
+OPTIONS /principals/__uids__/10000000-0000-0000-0000-000000000001/
+
+REPORT /principals/
+	principal-search-property-set
+
+PROPFIND /calendars/__uids__/10000000-0000-0000-0000-000000000001/inbox/
+	calendar-availability
+
+PROPFIND /calendars/__uids__/10000000-0000-0000-0000-000000000001/
+Depth 1
+	<A:add-member/>
+    <C:allowed-sharing-modes xmlns:C="http://calendarserver.org/ns/"/>
+    <D:autoprovisioned xmlns:D="http://apple.com/ns/ical/"/>
+    <E:bulk-requests xmlns:E="http://me.com/_namespace/"/>
+    <B:calendar-alarm xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <D:calendar-color xmlns:D="http://apple.com/ns/ical/"/>
+    <B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:calendar-free-busy-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <D:calendar-order xmlns:D="http://apple.com/ns/ical/"/>
+    <B:calendar-timezone xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:current-user-privilege-set/>
+    <B:default-alarm-vevent-date xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:default-alarm-vevent-datetime xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:displayname/>
+    <C:getctag xmlns:C="http://calendarserver.org/ns/"/>
+    <C:invite xmlns:C="http://calendarserver.org/ns/"/>
+    <D:language-code xmlns:D="http://apple.com/ns/ical/"/>
+    <D:location-code xmlns:D="http://apple.com/ns/ical/"/>
+    <A:owner/>
+    <C:pre-publish-url xmlns:C="http://calendarserver.org/ns/"/>
+    <C:publish-url xmlns:C="http://calendarserver.org/ns/"/>
+    <C:push-transports xmlns:C="http://calendarserver.org/ns/"/>
+    <C:pushkey xmlns:C="http://calendarserver.org/ns/"/>
+    <A:quota-available-bytes/>
+    <A:quota-used-bytes/>
+    <D:refreshrate xmlns:D="http://apple.com/ns/ical/"/>
+    <A:resource-id/>
+    <A:resourcetype/>
+    <B:schedule-calendar-transp xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:schedule-default-calendar-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <C:source xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-alarms xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-attachments xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-todos xmlns:C="http://calendarserver.org/ns/"/>
+    <B:supported-calendar-component-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:supported-calendar-component-sets xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:supported-report-set/>
+    <A:sync-token/>
+
+PROPFIND on calendar/tasks/inbox/notifications as before
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+									
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/StartupProfile
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/StartupProfile	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/StartupProfile	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,71 @@
+PROPFIND  ./well-known/caldav					- startup_well_known_propfind
+
+PROPFIND  /principals/							- startup_principal_initial_propfind
+
+PROPFIND  /principals/__uids__/<uid>/			- startup_principal_propfind
+
+REPORT    /principals/							- startup_principals_report
+
+PROPFIND  /calendars/__uids__/<uid>/inbox/		- ???
+	calendar-availability						
+
+PROPFIND  /calendars/__uids__/<uid>/			- poll_calendar_home_depth1_propfind
+
+PROPPATCH /calendars/__uids__/<uid>/			- startup_calendarhome_default_alarm_date_proppatch
+PROPPATCH /calendars/__uids__/<uid>/			- startup_calendarhome_default_alarm_datetime_proppatch
+
+PROPPATCH /calendars/__uids__/<uid>/calendar/	- startup_calendar_order_proppatch
+PROPPATCH /calendars/__uids__/<uid>/calendar/	- startup_calendar_displayname_proppatch
+PROPPATCH /calendars/__uids__/<uid>/calendar/	- startup_calendar_color_proppatch
+PROPPATCH /calendars/__uids__/<uid>/calendar/	- startup_calendar_timezone_proppatch
+
+PROPPATCH /calendars/__uids__/<uid>/tasks/		- startup_calendar_order_proppatch
+PROPPATCH /calendars/__uids__/<uid>/tasks/		- startup_calendar_displayname_proppatch
+PROPPATCH /calendars/__uids__/<uid>/tasks/		- startup_calendar_color_proppatch
+PROPPATCH /calendars/__uids__/<uid>/tasks/		- startup_calendar_timezone_proppatch
+
+PROPFIND  /calendars/__uids__/<uid>/calendar/	- poll_calendar_propfind
+
+REPORT   /calendars/__uids__/<uid>/calendar/ 	- startup_query_events_depth1_report.request
+
+PROPFIND  /calendars/__uids__/<uid>/calendar/	- poll_calendar_propfind
+PROPFIND  /calendars/__uids__/<uid>/calendar/	- poll_calendar_depth1_propfind
+
+PROPFIND  /calendars/__uids__/<uid>/tasks/		- poll_calendar_propfind
+PROPFIND  /calendars/__uids__/<uid>/tasks/		- poll_calendar_depth1_propfind
+PROPFIND  /calendars/__uids__/<uid>/inbox/		- poll_calendar_propfind
+PROPFIND  /calendars/__uids__/<uid>/inbox/		- poll_calendar_depth1_propfind
+PROPFIND  /calendars/__uids__/<uid>/tasks/		- poll_calendar_propfind
+PROPFIND  /calendars/__uids__/<uid>/tasks/		- poll_calendar_depth1_propfind
+PROPFIND  /calendars/__uids__/<uid>/notification/	- poll_calendar_propfind
+PROPFIND  /calendars/__uids__/<uid>/notification/	- poll_notification_depth1_propfind
+
+REPORT    /principals/__uids__/<uid>/
+	calendar-proxy-write-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+	calendar-proxy-read-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+
+REPORT    /calendars/__uids__/<uid>/
+	sync-collection
+		sync-token
+		sync-level
+		*lots of properties*
+
+PROPFIND  /calendars/__uids__/<uid>/inbox/
+	getctag
+	sync-token
+
+PROPFIND  /principals/__uids__/<uid>/
+	calendar-proxy-write-for
+		calendar-user-address-set
+		email-address-set
+		displayname
+	calendar-proxy-read-for
+		calendar-user-address-set
+		email-address-set
+		displayname

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_depth1_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_depth1_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_depth1_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:getcontenttype/>
+    <A:getetag/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendar_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <C:getctag xmlns:C="http://calendarserver.org/ns/"/>
+    <A:sync-token/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendarhome_depth1_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendarhome_depth1_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_calendarhome_depth1_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:add-member/>
+    <C:allowed-sharing-modes xmlns:C="http://calendarserver.org/ns/"/>
+    <D:autoprovisioned xmlns:D="http://apple.com/ns/ical/"/>
+    <E:bulk-requests xmlns:E="http://me.com/_namespace/"/>
+    <B:calendar-alarm xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <D:calendar-color xmlns:D="http://apple.com/ns/ical/"/>
+    <B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:calendar-free-busy-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <D:calendar-order xmlns:D="http://apple.com/ns/ical/"/>
+    <B:calendar-timezone xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:current-user-privilege-set/>
+    <B:default-alarm-vevent-date xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:default-alarm-vevent-datetime xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:displayname/>
+    <C:getctag xmlns:C="http://calendarserver.org/ns/"/>
+    <C:invite xmlns:C="http://calendarserver.org/ns/"/>
+    <D:language-code xmlns:D="http://apple.com/ns/ical/"/>
+    <D:location-code xmlns:D="http://apple.com/ns/ical/"/>
+    <A:owner/>
+    <C:pre-publish-url xmlns:C="http://calendarserver.org/ns/"/>
+    <C:publish-url xmlns:C="http://calendarserver.org/ns/"/>
+    <C:push-transports xmlns:C="http://calendarserver.org/ns/"/>
+    <C:pushkey xmlns:C="http://calendarserver.org/ns/"/>
+    <A:quota-available-bytes/>
+    <A:quota-used-bytes/>
+    <D:refreshrate xmlns:D="http://apple.com/ns/ical/"/>
+    <A:resource-id/>
+    <A:resourcetype/>
+    <B:schedule-calendar-transp xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:schedule-default-calendar-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <C:source xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-alarms xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-attachments xmlns:C="http://calendarserver.org/ns/"/>
+    <C:subscribed-strip-todos xmlns:C="http://calendarserver.org/ns/"/>
+    <B:supported-calendar-component-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:supported-calendar-component-sets xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:supported-report-set/>
+    <A:sync-token/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_notification_depth1_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_notification_depth1_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/poll_notification_depth1_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:getetag/>
+    <C:notificationtype xmlns:C="http://calendarserver.org/ns/"/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/post_freebusy.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/post_freebusy.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/post_freebusy.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,13 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REPLY
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VFREEBUSY
+UID:4288F0F3-5C5B-4DF4-9AD8-B1E5FE3F5B97
+DTSTART:20150804T211500Z
+DTEND:20150804T231500Z
+ATTENDEE:urn:uuid:30000000-0000-0000-0000-000000000005
+DTSTAMP:20150727T203410Z
+ORGANIZER:mailto:user01 at example.com
+END:VFREEBUSY
+END:VCALENDAR
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/principal_search_report.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/principal_search_report.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/principal_search_report.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<C:calendarserver-principal-search xmlns:C="http://calendarserver.org/ns/" context="{context}">
+  {searchTokens}
+  <A:prop xmlns:A="DAV:">
+    <C:email-address-set/>
+    <B:calendar-user-type xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:calendar-user-address-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:principal-URL/>
+    <C:last-name/>
+    <C:record-type/>
+    <A:displayname/>
+    <C:first-name/>
+  </A:prop>
+</C:calendarserver-principal-search>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/report_principal_search.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/report_principal_search.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/report_principal_search.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<C:calendarserver-principal-search xmlns:C="http://calendarserver.org/ns/" context="attendee">
+  <C:search-token>%(search)s</C:search-token>
+  <A:prop xmlns:A="DAV:">
+    <B:calendar-user-type xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <C:email-address-set/>
+    <A:displayname/>
+    <C:first-name/>
+    <C:last-name/>
+    <A:principal-URL/>
+    <C:record-type/>
+    <B:calendar-user-address-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+  </A:prop>
+</C:calendarserver-principal-search>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_color_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_color_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_color_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><D:calendar-color xmlns:D="http://apple.com/ns/ical/" symbolic-color="orange">#FD8208FF</D:calendar-color></A:prop></A:set></A:propertyupdate>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_description_proppatch.request.xml
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_description_proppatch.request.xml	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_description_proppatch.request.xml	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav">some description</B:calendar-description></A:prop></A:set></A:propertyupdate>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_displayname_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_displayname_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_displayname_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><A:displayname>calendar</A:displayname></A:prop></A:set></A:propertyupdate>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_order_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_order_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_order_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><D:calendar-order xmlns:D="http://apple.com/ns/ical/">1</D:calendar-order></A:prop></A:set></A:propertyupdate>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_timezone_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_timezone_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_timezone_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:calendar-timezone xmlns:B="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR&#13;
+VERSION:2.0&#13;
+PRODID:-//Apple Inc.//Mac OS X 10.11//EN&#13;
+CALSCALE:GREGORIAN&#13;
+BEGIN:VTIMEZONE&#13;
+TZID:America/Los_Angeles&#13;
+BEGIN:DAYLIGHT&#13;
+TZOFFSETFROM:-0800&#13;
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU&#13;
+DTSTART:20070311T020000&#13;
+TZNAME:PDT&#13;
+TZOFFSETTO:-0700&#13;
+END:DAYLIGHT&#13;
+BEGIN:STANDARD&#13;
+TZOFFSETFROM:-0700&#13;
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU&#13;
+DTSTART:20071104T020000&#13;
+TZNAME:PST&#13;
+TZOFFSETTO:-0800&#13;
+END:STANDARD&#13;
+END:VTIMEZONE&#13;
+END:VCALENDAR&#13;
+</B:calendar-timezone></A:prop></A:set></A:propertyupdate>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_transparent_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_transparent_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendar_transparent_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:schedule-calendar-transp xmlns:B="urn:ietf:params:xml:ns:caldav"><B:transparent/></B:schedule-calendar-transp></A:prop></A:set></A:propertyupdate>
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:schedule-calendar-transp xmlns:B="urn:ietf:params:xml:ns:caldav"><B:opaque/></B:schedule-calendar-transp></A:prop></A:set></A:propertyupdate>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_date_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_date_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_date_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:default-alarm-vevent-date xmlns:B="urn:ietf:params:xml:ns:caldav">BEGIN:VALARM&#13;
+X-WR-ALARMUID:49F29226-D2D7-4464-AE22-0147EDEFB2B4&#13;
+UID:49F29226-D2D7-4464-AE22-0147EDEFB2B4&#13;
+TRIGGER:-PT15H&#13;
+ATTACH;VALUE=URI:Basso&#13;
+ACTION:AUDIO&#13;
+END:VALARM&#13;
+</B:default-alarm-vevent-date></A:prop></A:set></A:propertyupdate>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_datetime_proppatch.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_datetime_proppatch.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_calendarhome_default_alarm_datetime_proppatch.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:default-alarm-vevent-datetime xmlns:B="urn:ietf:params:xml:ns:caldav">BEGIN:VALARM&#13;
+X-WR-ALARMUID:4AD03A33-54A6-42BE-A157-47273DD60803&#13;
+UID:4AD03A33-54A6-42BE-A157-47273DD60803&#13;
+TRIGGER;VALUE=DATE-TIME:19760401T005545Z&#13;
+ACTION:NONE&#13;
+END:VALARM&#13;
+</B:default-alarm-vevent-datetime></A:prop></A:set></A:propertyupdate>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_create_calendar.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_create_calendar.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_create_calendar.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<B:mkcalendar xmlns:B="urn:ietf:params:xml:ns:caldav">
+  <A:set xmlns:A="DAV:">
+    <A:prop>
+      <D:calendar-order xmlns:D="http://apple.com/ns/ical/">{order}</D:calendar-order>
+      <B:supported-calendar-component-set>
+        <B:comp name="{component_type}"/>
+      </B:supported-calendar-component-set>
+      <D:calendar-color xmlns:D="http://apple.com/ns/ical/" symbolic-color="custom">#{color}</D:calendar-color>
+      <B:calendar-timezone>BEGIN:VCALENDAR&#13;
+VERSION:2.0&#13;
+PRODID:-//Apple Inc.//Mac OS X 10.11//EN&#13;
+CALSCALE:GREGORIAN&#13;
+BEGIN:VTIMEZONE&#13;
+TZID:America/Los_Angeles&#13;
+BEGIN:DAYLIGHT&#13;
+TZOFFSETFROM:-0800&#13;
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU&#13;
+DTSTART:20070311T020000&#13;
+TZNAME:PDT&#13;
+TZOFFSETTO:-0700&#13;
+END:DAYLIGHT&#13;
+BEGIN:STANDARD&#13;
+TZOFFSETFROM:-0700&#13;
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU&#13;
+DTSTART:20071104T020000&#13;
+TZNAME:PST&#13;
+TZOFFSETTO:-0800&#13;
+END:STANDARD&#13;
+END:VTIMEZONE&#13;
+END:VCALENDAR&#13;
+</B:calendar-timezone>
+      <A:displayname>{name}</A:displayname>
+      <B:schedule-calendar-transp>
+        <B:opaque/>
+      </B:schedule-calendar-transp>
+    </A:prop>
+  </A:set>
+</B:mkcalendar>
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_delegate_principal_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_delegate_principal_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_delegate_principal_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <B:allowed-calendar-component-set xmlns:B="http://calendarserver.org/ns/"/>
+    <C:calendar-home-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
+    <C:calendar-user-address-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
+    <A:current-user-principal/>
+    <A:displayname/>
+    <B:dropbox-home-URL xmlns:B="http://calendarserver.org/ns/"/>
+    <B:email-address-set xmlns:B="http://calendarserver.org/ns/"/>
+    <B:notification-URL xmlns:B="http://calendarserver.org/ns/"/>
+    <A:principal-collection-set/>
+    <A:principal-URL/>
+    <A:resource-id/>
+    <C:schedule-inbox-URL xmlns:C="urn:ietf:params:xml:ns:caldav"/>
+    <C:schedule-outbox-URL xmlns:C="urn:ietf:params:xml:ns:caldav"/>
+    <A:supported-report-set/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_expand.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_expand.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_expand.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:expand-property xmlns:A="DAV:">
+  <A:property name="calendar-proxy-read-for" namespace="http://calendarserver.org/ns/">
+    <A:property name="calendar-user-address-set" namespace="urn:ietf:params:xml:ns:caldav"/>
+    <A:property name="email-address-set" namespace="http://calendarserver.org/ns/"/>
+    <A:property name="displayname" namespace="DAV:"/>
+  </A:property>
+  <A:property name="calendar-proxy-write-for" namespace="http://calendarserver.org/ns/">
+    <A:property name="calendar-user-address-set" namespace="urn:ietf:params:xml:ns:caldav"/>
+    <A:property name="email-address-set" namespace="http://calendarserver.org/ns/"/>
+    <A:property name="displayname" namespace="DAV:"/>
+  </A:property>
+</A:expand-property>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_initial_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_initial_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_initial_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:current-user-principal/>
+    <A:principal-URL/>
+    <A:resourcetype/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principal_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <B:calendar-home-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:calendar-user-address-set xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:current-user-principal/>
+    <A:displayname/>
+    <C:dropbox-home-URL xmlns:C="http://calendarserver.org/ns/"/>
+    <C:email-address-set xmlns:C="http://calendarserver.org/ns/"/>
+    <C:notification-URL xmlns:C="http://calendarserver.org/ns/"/>
+    <A:principal-collection-set/>
+    <A:principal-URL/>
+    <A:resource-id/>
+    <B:schedule-inbox-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <B:schedule-outbox-URL xmlns:B="urn:ietf:params:xml:ns:caldav"/>
+    <A:supported-report-set/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principals_report.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principals_report.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_principals_report.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:principal-search-property-set xmlns:A="DAV:"/>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_query_events_depth1_report.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_query_events_depth1_report.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_query_events_depth1_report.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<B:calendar-query xmlns:B="urn:ietf:params:xml:ns:caldav">
+  <A:prop xmlns:A="DAV:">
+    <A:getetag/>
+    <A:getcontenttype/>
+  </A:prop>
+  <B:filter>
+    <B:comp-filter name="VCALENDAR">
+      <B:comp-filter name="VEVENT">
+        <B:time-range start="20150630T010101Z" end="20150721T010101Z"/>
+      </B:comp-filter>
+    </B:comp-filter>
+  </B:filter>
+</B:calendar-query>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_well_known_propfind.request
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_well_known_propfind.request	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/request-data/OS_X_10_11/startup_well_known_propfind.request	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+  <A:prop>
+    <A:current-user-principal/>
+    <A:principal-URL/>
+    <A:resourcetype/>
+  </A:prop>
+</A:propfind>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/requester.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/requester.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/requester.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,303 @@
+from caldavclientlibrary.protocol.webdav.propfindparser import PropFindParser
+
+from contrib.performance.httpauth import AuthHandlerAgent
+from contrib.performance.httpclient import StringProducer, readBody
+
+from twisted.web.http import OK, MULTI_STATUS, CREATED, NO_CONTENT
+from twisted.web.http_headers import Headers
+from twisted.web.client import Agent, ContentDecoderAgent, GzipDecoder, \
+    _DeprecatedToCurrentPolicyForHTTPS
+
+from twisted.python.log import msg
+
+from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.internet.ssl import ClientContextFactory
+
+"""
+TODO
+Finish the comment table
+THink about better ways to do default headers
+Try to log messages in a more intelligent way
+
+"""
+
+class Requester(object):
+    """
+    Utility to create requests on behalf of a client. Public methods are:
+    method     url     body     headers     status     method_label
+    ------------------------------------------------------------------------
+    GET        req     ---
+    POST       req     req
+    PUT        req     req*
+    DELETE     req     ---
+    PROPFIND   req
+    PROPPATCH  req
+    REPORT     req
+    MKCALENDAR req
+
+    req: required
+    opt: optional
+    ---: disallowed
+    All of these rely on a private method _request
+
+    """
+
+    def __init__(
+        self,
+        root,
+        headers,
+        title,
+        uid,
+        client_id,
+        auth,
+        reactor
+    ):
+        self._root = root
+        self._headers = headers
+        self._title = title
+        self._uid = uid
+        self._client_id = client_id
+
+        self._reactor = reactor
+
+        # The server might use gzip encoding
+        agent = Agent(
+            self._reactor,
+            contextFactory=_DeprecatedToCurrentPolicyForHTTPS(WebClientContextFactory()),
+        )
+        agent = ContentDecoderAgent(agent, [("gzip", GzipDecoder)])
+        self._agent = AuthHandlerAgent(agent, auth)
+
+    def _addDefaultHeaders(self, headers):
+        """
+        Add the clients default set of headers to ones being used in a request.
+        Default is to add User-Agent, sub-classes should override to add other
+        client specific things, Accept etc.
+        """
+        for k, v in self._headers.iteritems():
+            headers.setRawHeaders(k, v)
+
+    @inlineCallbacks
+    def _request(self, method, url, expectedResponseCodes, headers=None, body=None, method_label=None):
+        """
+        Execute a request and check against the expected response codes.
+        """
+        if type(expectedResponseCodes) is int:
+            expectedResponseCodes = (expectedResponseCodes,)
+        if not method_label:
+            method_label = method
+        if headers is None:
+            headers = Headers({})
+        self._addDefaultHeaders(headers)
+        url = self._root + url.encode('utf-8')
+
+        msg(
+            type="request",
+            method=method_label,
+            url=url,
+            user=self._uid,
+            client_type=self._title,
+            client_id=self._client_id,
+        )
+
+        before = self._reactor.seconds()
+        response = yield self._agent.request(method, url, headers, StringProducer(body) if body else None)
+
+        # XXX This is time to receive response headers, not time
+        # to receive full response.  Should measure the latter, if
+        # not both.
+        after = self._reactor.seconds()
+
+        success = response.code in expectedResponseCodes
+
+        msg(
+            type="response",
+            success=success,
+            method=method_label,
+            headers=headers,
+            body=body,
+            code=response.code,
+            user=self._uid,
+            client_type=self._title,
+            client_id=self._client_id,
+            duration=(after - before),
+            url=url,
+        )
+
+        if success:
+            returnValue(response)
+
+        raise IncorrectResponseCode(expectedResponseCodes, response)
+
+
+    @inlineCallbacks
+    def get(self, url, method_label=None):
+        response = yield self._request(
+            'GET',
+            url,
+            (OK,),
+            method_label=method_label
+        )
+        returnValue(response)
+
+
+    @inlineCallbacks
+    def post(self, url, body, headers=None, method_label=None):
+        response = yield self._request(
+            'POST',
+            url,
+            (OK, CREATED, MULTI_STATUS),
+            headers=headers,
+            body=body,
+            method_label=method_label
+        )
+        returnValue(response)
+
+
+    @inlineCallbacks
+    def put(self, expectedResponseCodes, url, component, headers=None, method_label=None):
+        response = yield self._request(
+            'PUT',
+            url,
+            expectedResponseCodes,
+            headers=headers,
+            body=component.getTextWithTimezones(includeTimezones=True),
+            method_label=method_label
+        )
+        returnValue(response)
+
+
+    @inlineCallbacks
+    def delete(self, url, method_label=None):
+        response = yield self._request(
+            'DELETE',
+            url,
+            (NO_CONTENT,),
+            method_label=method_label
+        )
+        returnValue(response)
+
+
+    def _parseMultiStatus(self, response, otherTokens=False):
+        """
+        Parse a <multistatus> - might need to return other top-level elements
+        in the response - e.g. DAV:sync-token
+        I{PROPFIND} request for the principal URL.
+
+        @type response: C{str}
+        @rtype: C{cls}
+        """
+        parser = PropFindParser()
+        parser.parseData(response)
+        if otherTokens:
+            return (parser.getResults(), parser.getOthers(),)
+        else:
+            return parser.getResults()
+
+    @inlineCallbacks
+    def propfind(self, url, body, depth='0', allowedStatus=(MULTI_STATUS,), method_label=None):
+        """
+        Issue a PROPFIND on the chosen URL
+        """
+        hdrs = Headers({'content-type': ['text/xml']})
+        if depth is not None:
+            hdrs.addRawHeader('depth', depth)
+        response = yield self._request(
+            'PROPFIND',
+            url,
+            allowedStatus,
+            headers=hdrs,
+            body=body,
+            method_label=method_label,
+        )
+
+        body = yield readBody(response)
+        result = self._parseMultiStatus(body) if response.code == MULTI_STATUS else None
+
+        returnValue((response, result,))
+
+    @inlineCallbacks
+    def proppatch(self, url, body, method_label=None):
+        """
+        Issue a PROPPATCH on the chosen URL
+        """
+        hdrs = Headers({'content-type': ['text/xml']})
+        response = yield self._request(
+            'PROPPATCH',
+            url,
+            (OK, MULTI_STATUS,),
+            headers=hdrs,
+            body=body,
+            method_label=method_label,
+        )
+        if response.code == MULTI_STATUS:
+            body = yield readBody(response)
+            result = self._parseMultiStatus(body)
+            returnValue(result)
+        else:
+            returnValue(None)
+
+    @inlineCallbacks
+    def report(self, url, body, depth='0', allowedStatus=(MULTI_STATUS,), otherTokens=False, method_label=None):
+        """
+        Issue a REPORT on the chosen URL
+        """
+        hdrs = Headers({'content-type': ['text/xml']})
+        if depth is not None:
+            hdrs.addRawHeader('depth', depth)
+        response = yield self._request(
+            'REPORT',
+            url,
+            allowedStatus,
+            headers=hdrs,
+            body=body,
+            method_label=method_label,
+        )
+
+        body = yield readBody(response)
+        result = self._parseMultiStatus(body, otherTokens) if response.code == MULTI_STATUS else None
+
+        returnValue(result)
+
+    @inlineCallbacks
+    def mkcalendar(self, url, body, method_label=None):
+        """
+        Issue a MKCALENDAR on the chosen URL with the given body
+        url: an href like /calendars/__uids__/<user-uid>/<calendar-uid>/
+        body: the XML body of the request
+        """
+        headers = Headers({'content-type': ['text/xml']})
+        response = yield self._request(
+            'MKCALENDAR',
+            url,
+            (CREATED,),
+            headers=headers,
+            body=body,
+            method_label=method_label
+        )
+        body = yield readBody(response)
+        returnValue(body)
+
+
+class IncorrectResponseCode(Exception):
+    """
+    Raised when a response has a code other than the one expected.
+
+    @ivar expected: The response codes which was expected.
+    @type expected: C{tuple} of C{int}
+
+    @ivar response: The response which was received
+    @type response: L{twisted.web.client.Response}
+    """
+    def __init__(self, expected, response):
+        self.expected = expected
+        self.response = response
+
+
+class WebClientContextFactory(ClientContextFactory):
+    """
+    A web context factory which ignores the hostname and port and does no
+    certificate verification.
+    """
+    def getContext(self, hostname, port):
+        return ClientContextFactory.getContext(self)

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/resources.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/resources.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/resources.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,318 @@
+from twistedcaldav.ical import Component
+
+from urlparse import urljoin
+
+from caldavclientlibrary.protocol.caldav.definitions import caldavxml
+
+from caldavclientlibrary.protocol.calendarserver.invite import AddInvitees, RemoveInvitee, InviteUser
+from caldavclientlibrary.protocol.webdav.proppatch import PropPatch
+
+import os
+
+from xml.etree import ElementTree
+
+def u2str(data):
+    return data.encode("utf-8") if type(data) is unicode else data
+
+
+
+# class EventComponent(object):
+#     """Utilities for event modification (which will always change the *.ics file)"""
+#     # HARD THINGS
+# # Event Location
+# # Attachments
+# # Inviting people (and rooms) to events
+
+# # Alarms + acknowledgements and all that fun stuff
+# RRULES
+
+#     def _modifyEvent(self, event):
+#         component = event.component
+#         vevent = component.mainComponent()
+#         vevent.replaceProperty(Property("DTSTAMP", DateTime.getNowUTC()))
+#         # Do some stuff
+#         pass
+#         event.component = component
+
+# # setEventTitle, setEventNotes, setEventURL, and setEventTransparency
+# # all simply modify a VEVENT passed to them
+#     def setEventTitle(self, vevent, title):
+#         vevent.replaceProperty(Property("SUMMARY", title))
+
+#     def setEventNotes(self, vevent, notes):
+#         vevent.replaceProperty(Property("DESCRIPTION", notes))
+
+
+
+#     def setEventTimes(self, vevent, dtstart, dtend):
+#         timezone = {"TZID": "America/Los_Angeles"}
+#         vevent.replaceProperty(Property("DTSTART", dtstart, params=timezone))
+#         vevent.replaceProperty(Property("DTEND", dtend, params=timezone))
+
+#     def setEventTravelDuration(self, vevent, duration):
+#         # X-APPLE-TRAVEL-START;ROUTING=WALKING;VALUE=URI: ?
+#         if duration == 0:
+#             vevent.removeProperty('X-APPLE-TRAVEL-DURATION')
+#             return
+
+#         hours = duration / 60
+#         minutes = duration % 60
+#         if hours == 0:
+#             durationText = "PT{minutes}M".format(minutes=minutes)
+#         else:
+#             durationText = "PT{hours}H{minutes}M".format(hours=hours, minutes=minutes)
+#         vevent.replaceProperty("X-APPLE-TRAVEL-DURATION", durationText, valuetype=Value.VALUETYPE_DURATION)
+
+# # setEventLocation
+# # modify the internal VCALENDAR, and perhaps make request to the server along the way
+#     def addRawEventLocation(self, vevent, location):
+#         vevent.replaceProperty("LOCATION", location)
+
+#     def addStructuredEventLocation(self, vevent, location):
+#         """Adds a structured event to the given VEVENT. Since the client is usually
+#         responsible for determining all of the extra information, we'll forgo that,
+#         and instead just add Stanford
+#         """
+#         vevent.addRawEventLocation(vevent, "Stanford University\n450 Serra Mall\nStanford\, CA  94305-2004")
+#         vevent.replaceProperty(Property("X-APPLE-STRUCTURED-LOCATION", "geo:37.428183,-122.170050", valuetype=Value.VALUETYPE_URI, params={
+#             "X-ADDRESS": "450 Serra Mall\nStanford, CA  94305-2004",
+#             "X-APPLE-RADIUS": "14164.55109758551",
+#             "X-TITLE": "Stanford University"
+#         }))
+
+#     def addEventRoom(self, vevent, location):
+#         # Do a bunch of principal searches and freebusy lookups
+#         pass
+
+#     def addAttachment(self, event, attachmentSize):
+#         # Post a large attachment to the event
+#         ch = chr(self.random.randint(0, 255))
+#         text = ch * attachmentSize
+#         self._client.addAttachment()
+#         # Get the updated event from the server and cache the response
+
+
+class Event(object):
+    def __init__(self, serializeBasePath, url, etag, component=None):
+        self.serializeBasePath = serializeBasePath
+        self.url = url
+        self.etag = etag
+        self.scheduleTag = None
+        if component is not None:
+            self.component = component
+        self.uid = component.resourceUID() if component is not None else None
+
+    def getUID(self):
+        """
+        Return the UID of the calendar resource.
+        """
+        return self.uid
+
+
+    def serializePath(self):
+        if self.serializeBasePath:
+            calendar = os.path.join(self.serializeBasePath, self.url.split("/")[-2])
+            if not os.path.exists(calendar):
+                os.makedirs(calendar)
+            return os.path.join(calendar, self.url.split("/")[-1])
+        else:
+            return None
+
+
+    def serialize(self):
+        """
+        Create a dict of the data so we can serialize as JSON.
+        """
+
+        result = {}
+        for attr in ("url", "etag", "scheduleTag", "uid",):
+            result[attr] = getattr(self, attr)
+        return result
+
+
+    @staticmethod
+    def deserialize(serializeLocation, data):
+        """
+        Convert dict (deserialized from JSON) into an L{Event}.
+        """
+
+        event = Event(serializeLocation, None, None)
+        for attr in ("url", "etag", "scheduleTag", "uid",):
+            setattr(event, attr, u2str(data[attr]))
+        return event
+
+
+    @property
+    def component(self):
+        """
+        Data always read from disk - never cached in the object.
+        """
+        path = self.serializePath()
+        if path and os.path.exists(path):
+            f = open(path)
+            comp = Component.fromString(f.read())
+            f.close()
+            return comp
+        else:
+            return None
+
+
+    @component.setter
+    def component(self, component):
+        """
+        Data always written to disk - never cached on the object.
+        """
+        path = self.serializePath()
+        if path:
+            if component is None:
+                os.remove(path)
+            else:
+                f = open(path, "w")
+                f.write(str(component))
+                f.close()
+        self.uid = component.resourceUID() if component is not None else None
+
+
+    def removed(self):
+        """
+        Resource no longer exists on the server - remove associated data.
+        """
+        path = self.serializePath()
+        if path and os.path.exists(path):
+            os.remove(path)
+
+
+
+class Calendar(object):
+    def __init__(self, resourceType, componentTypes, name, url, changeToken):
+        self.resourceType = resourceType
+        self.componentTypes = componentTypes
+        self.name = name
+        self.url = url
+        self.changeToken = changeToken
+        self.events = {}
+        # print("----\nNew Calendar")
+        # print("Resource Type: ", self.resourceType)
+        # print("Component Types: ", self.componentTypes)
+        # print("Name: ", self.name)
+        # print("URL: ", self.url)
+        # print("Change Token: ", self.changeToken)
+        # print("Events: ", self.events)
+
+
+    def serialize(self):
+        """
+        Create a dict of the data so we can serialize as JSON.
+        """
+
+        result = {}
+        for attr in ("resourceType", "name", "url", "changeToken"):
+            result[attr] = getattr(self, attr)
+        result["componentTypes"] = list(sorted(self.componentTypes))
+        result["events"] = sorted(self.events.keys())
+        return result
+
+
+    @staticmethod
+    def deserialize(data, events):
+        """
+        Convert dict (deserialized from JSON) into an L{Calendar}.
+        """
+
+        calendar = Calendar(None, None, None, None, None)
+        for attr in ("resourceType", "name", "url", "changeToken"):
+            setattr(calendar, attr, u2str(data[attr]))
+        calendar.componentTypes = set(map(u2str, data["componentTypes"]))
+
+        for event in data["events"]:
+            url = urljoin(calendar.url, event)
+            if url in events:
+                calendar.events[event] = events[url]
+            else:
+                # Ughh - an event is missing - force changeToken to empty to trigger full resync
+                calendar.changeToken = ""
+        return calendar
+
+    @staticmethod
+    def buildCalendarXML(order=0, component_type='VEVENT', rgba_color='FB524FFF', name='Simple Calendar'):
+        # TODO add timezone information
+
+        # MakeCalendar(None, '/', name, )
+
+        # body = _STARTUP_CREATE_CALENDAR.format(
+        #     order=order,
+        #     component_type=component_type,
+        #     color=rgba_color,
+        #     name=name)
+        # return body
+        return ""
+
+    @staticmethod
+    def addInviteeXML(uid, summary, readwrite=True):
+        return AddInvitees(None, '/', [uid], readwrite, summary=summary).request_data.text
+
+
+    @staticmethod
+    def removeInviteeXML(uid):
+        invitee = InviteUser()
+        # Usually an InviteUser is populated through .parseFromUser, but we only care about a uid
+        invitee.user_uid = uid
+        return RemoveInvitee(None, '/', invitee).request_data.text
+
+
+    @staticmethod
+    def _buildPropPatchXML(element):
+        """
+        Change the specified element on the calendar given by href.
+        """
+        return PropPatch(None, '/', [element]).request_data.text
+
+
+    # def setCalendarDisplayName(self, calendar, displayname):
+    #     self._calendars[calendar.url].displayname = displayname # Update the cached copy
+    #     qn = davxml.displayname
+    #     el = ElementTree.Element(qn)
+    #     el.text = displayname
+    #     yield self._property_update(el)
+
+    @staticmethod
+    def setCalendarDescriptionXML(calendar, description):
+        qn = caldavxml.calendar_description
+        el = ElementTree.Element(qn)
+        el.text = description
+        return Calendar._buildPropPatchXML(el)
+
+
+    @staticmethod
+    def setCalendarTransparencyXML(calendar, isTransparent):
+        qn = caldavxml.schedule_calendar_transp
+        el = ElementTree.Element(qn)
+        transp_qn = caldavxml.transparent if isTransparent else caldavxml.opaque
+        ElementTree.SubElement(el, transp_qn)
+        return Calendar._buildPropPatchXML(el)
+
+
+    @staticmethod
+    def setCalendarColorXML(calendar, color):
+        """ color is an RGBA string, e.g. "#FF0088FF" """
+        qn = ElementTree.QName('http://apple.com/ns/ical/', 'calendar-color')
+        el = ElementTree.Element(qn)
+        el.text = color
+        el.set('symbolic-color', 'custom')
+        return Calendar._buildPropPatchXML(el)
+
+
+    @staticmethod
+    def setCalendarOrder(self, calendar, order):
+        qn = ElementTree.QName('http://apple.com/ns/ical/', 'calendar-order')
+        el = ElementTree.Element(qn)
+        el.text = order
+        return Calendar._buildPropPatchXML(el)
+
+    # @inlineCallbacks
+    # def setCalendarProperty(self, calendar):
+
+    # def do_stuff(...):
+    #     body = 
+    #     yield self.requester.proppatch(href, body, method_label="PROPPATCH{calendar}")
+    #     
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/__init__.py
===================================================================
Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.json
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.json	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.json	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,211 @@
+
+
+calendars-only = {
+	"clients": [
+		{
+			"software": "contrib.performance.loadtest.clients.OS_X_10_11",
+			"params": {
+				"title": "10.11",
+				"calendarHomePollInterval": 5,
+				"supportPush": false,
+				"supportAmpPush": true
+			},
+			"profiles": [
+				{
+					"class": "contrib.performance.loadtest.profiles.CalendarMaker",
+					"params": {
+						"enabled": false,
+						"interval": 15
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.CalendarUpdater",
+					"params": {
+						"enabled": true,
+						"interval": 5
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.CalendarSharer",
+					"params": {
+						"enabled": true,
+						"interval": 30
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.CalendarDeleter",
+					"params": {
+						"enabled": false,
+						"interval": 30
+					}
+				}
+			],
+			"weight": 1
+		}
+	]
+}
+
+event-updates-only = {
+	"clients": [
+		{
+			"software": "contrib.performance.loadtest.clients.OS_X_10_11",
+			"params": {
+				"title": "10.7",
+				"calendarHomePollInterval": 300000,
+				"supportPush": false,
+				"supportAmpPush": false
+			},
+			"profiles": [
+				{
+					"class": "contrib.performance.loadtest.profiles.Eventer",
+					"params": {
+						"enabled": false,
+						"interval": 20,
+						"eventStartDistribution": {
+							"type": "contrib.performance.stats.WorkDistribution",
+							"params": {
+								"daysOfWeek": ["mon", "tue", "wed", "thu", "fri"],
+								"beginHour": 8,
+								"endHour": 8,
+								"tzname": "America/Los_Angeles"
+							}
+						},
+						"recurrenceDistibution": {
+							"type": "contrib.performance.stats.RecurrenceDistribution",
+							"params": {
+								"allowRecurrence": false,
+								"weights": {
+									"none": 50,
+									"daily": 10,
+									"weekly": 20,
+									"monthly": 2,
+									"yearly": 1,
+									"dailylimit": 2,
+									"weeklylimit": 5,
+									"workdays": 10
+								}
+							}
+						}
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.EventUpdater",
+					"params": {
+						"enabled": true,
+						"interval": 5,
+						"eventStartDistribution": {
+							"type": "contrib.performance.stats.WorkDistribution",
+							"params": {
+								"daysOfWeek": ["mon", "tue", "wed", "thu", "fri"],
+								"beginHour": 8,
+								"endHour": 8,
+								"tzname": "America/Los_Angeles"
+							}
+						},
+						"recurrenceDistibution": {
+							"type": "contrib.performance.stats.RecurrenceDistribution",
+							"params": {
+								"allowRecurrence": true,
+								"weights": {
+									"none": 50,
+									"daily": 25,
+									"weekly": 25,
+									"monthly": 0,
+									"yearly": 0,
+									"dailylimit": 0,
+									"weeklylimit": 0,
+									"workdays": 0
+								}
+							}
+						}
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.RealisticInviter",
+					"params": {
+						"enabled": false,
+						"sendInvitationDistribution": {
+							"type": "contrib.performance.stats.NormalDistribution",
+							"params": {
+								"mu": 10,
+								"sigma": 5
+							}
+						},
+						"inviteeDistribution": {
+							"type": "contrib.performance.stats.UniformIntegerDistribution",
+							"params": {
+								"min": 0,
+								"max": 99
+							}
+						},
+						"inviteeClumping": true,
+						"inviteeCountDistribution": {
+							"type": "contrib.performance.stats.LogNormalDistribution",
+							"params": {
+								"mode": 1,
+								"median": 6,
+								"maximum": 100
+							}
+						},
+						"eventStartDistribution": {
+							"type": "contrib.performance.stats.WorkDistribution",
+							"params": {
+								"daysOfWeek": ["mon", "tue", "wed", "thu", "fri"],
+								"beginHour": 8,
+								"endHour": 8,
+								"tzname": "America/Los_Angeles"
+							}
+						},
+						"recurrenceDistibution": {
+							"type": "contrib.performance.stats.RecurrenceDistribution",
+							"params": {
+								"allowRecurrence": false,
+								"weights": {
+									"none": 50,
+									"daily": 10,
+									"weekly": 20,
+									"monthly": 2,
+									"yearly": 1,
+									"dailylimit": 2,
+									"weeklylimit": 5,
+									"workdays": 10
+								}
+							}
+						}
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.Accepter",
+					"params": {
+						"enabled": false,
+						"acceptDelayDistribution": {
+							"type": "contrib.performance.stats.LogNormalDistribution",
+							"params": {
+								"mode": 300,
+								"median": 1800
+							}
+						}
+					}
+				},
+				{
+					"class": "contrib.performance.loadtest.profiles.Tasker",
+					"params": {
+						"enabled": false,
+						"interval": 300,
+						"taskDueDistribution": {
+							"type": "contrib.performance.stats.WorkDistribution",
+							"params": {
+								"daysOfWeek": ["mon", "tue", "wed", "thu", "fri"],
+								"beginHour": 8,
+								"endHour": 8,
+								"tzname": "America/Los_Angeles"
+							}
+						}
+					}
+				}
+			],
+			"weight": 1
+		}
+	]
+}
+

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.plist
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.plist	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.plist	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+    Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+  -->
+
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<!-- Define the kinds of software and user behavior the load simulation
+			will simulate. -->
+		<key>clients</key>
+
+		<!-- Have as many different kinds of software and user behavior configurations
+			as you want. Each is a dict -->
+		<array>
+
+			<dict>
+
+				<!-- Here is a OS X client simulator. -->
+				<key>software</key>
+				<string>contrib.performance.loadtest.clients.OS_X_10_11</string>
+
+				<!-- Arguments to use to initialize the OS_X_10_7 instance. -->
+				<key>params</key>
+				<dict>
+					<!-- Name that appears in logs. -->
+					<key>title</key>
+					<string>10.11 Intern</string>
+
+					<!-- OS_X_10_7 can poll the calendar home at some interval. This is
+						in seconds. -->
+					<key>calendarHomePollInterval</key>
+					<integer>30</integer>
+
+					<!-- If the server advertises AMP push, the client can wait for notifications about calendar home changes in addition to polling them periodically. If this option is true, look for the server advertisement for AMP push and use it if possible
+						about calendar home changes instead of polling for them periodically. If
+						this option is true, then look for the server advertisement for xmpp push
+						and use it if possible. Still fall back to polling if there is no xmpp push
+						advertised. -->
+					<key>supportAmpPush</key>
+					<true/>
+					<key>ampPushHost</key>
+					<string>localhost</string>
+					<key>ampPushPort</key>
+					<integer>62311</integer>
+				</dict>
+
+				<!-- The profiles define certain types of user behavior on top of the
+					client software being simulated. -->
+				<key>profiles</key>
+				<array>
+					<dict>
+						<key>class</key>
+						<string>contrib.performance.loadtest.profiles.Inviter</string>
+
+						<key>params</key>
+						<dict>
+							<key>enabled</key>
+							<true/>
+
+							<!-- Define the interval (in seconds) at which this profile will use
+								its client to create a new event. -->
+							<key>interval</key>
+							<integer>1</integer>
+						</dict>
+					</dict>
+				</array>
+
+				<!-- Determine the frequency at which this client configuration will
+					appear in the clients which are created by the load tester. -->
+				<key>weight</key>
+				<integer>1</integer>
+			</dict>
+		</array>
+	</dict>
+</plist>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/clients.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,153 @@
+from contrib.performance.loadtest.clients import iOS_5, OS_X_10_6, OS_X_10_7, OS_X_10_11
+from contrib.performance.loadtest.profiles import CalendarMaker, CalendarUpdater, CalendarSharer, CalendarDeleter
+from contrib.performance.stats import WorkDistribution, RecurrenceDistribution
+
+from contrib.performance.loadtest.population import ProfileType
+
+
+STANDARD_WORK_DISTRIBUTION = WorkDistribution(
+    daysOfWeek=["mon", "tue", "wed", "thu", "fri"],
+    beginHour=8,
+    endHour=16,
+    tzname="America/Los_Angeles"
+)
+
+LOW_RECURRENCE_DISTRIBUTION = RecurrenceDistribution(
+    allowRecurrence=True,
+    weights={
+        "none": 50,
+        "daily": 25,
+        "weekly": 25,
+        "monthly": 0,
+        "yearly": 0,
+        "dailylimit": 0,
+        "weeklylimit": 0,
+        "workdays": 0
+    }
+)
+
+MEDIUM_RECURRENCE_DISTRIBUTION = RecurrenceDistribution(
+    allowRecurrence=True,
+    weights={
+        "none": 50,
+        "daily": 10,
+        "weekly": 20,
+        "monthly": 2,
+        "yearly": 1,
+        "dailylimit": 2,
+        "weeklylimit": 5,
+        "workdays": 10
+    }
+)
+
+# We have to roll our own deep copy method because you can't deep copy Twisted's reactor
+class ClientFactory(object):
+
+    def __init__(self, client, weight):
+        pass
+
+    @staticmethod
+    def _duplicateClient(client):
+        return type(client)(
+            # some params
+        )
+
+    def new(reactor, ):
+        pass
+
+class ProfileFactory(object):
+    def __init__(self, profile):
+        pass
+
+    @staticmethod
+    def _duplicateProfile(profile):
+        return type(profile)()
+
+calendars_only = [
+    {
+        "software": OS_X_10_11,
+        "params": {
+            "title": "10.11",
+            "calendarHomePollInterval": 5,
+            "supportAmpPush": True,
+            "ampPushHost": "localhost",
+            "ampPushPort": 62311
+        },
+        "profiles": [
+            ProfileType(CalendarMaker, dict(enabled=True, interval=15)),
+
+            # CalendarMaker(enabled=True, interval=15),
+            # CalendarUpdater(enabled=True, interval=5),
+            # CalendarSharer(enabled=True, interval=30),
+            # CalendarDeleter(false=True, interval=30)
+        ],
+        "weight": 1
+    }
+]
+
+# TBD what about multiple weights?
+calendars_only_ideal = [
+    OS_X_10_11(
+        title="10.11",
+        calendarHomePollInterval=5,
+        supportAmpPush=True,
+        ampPushHost="localhost",
+        ampPushPort=62311,
+        profiles=[
+            CalendarMaker(enabled=True, interval=15),
+            # CalendarUpdater(enabled=True, interval=5),
+            # CalendarSharer(enabled=False, interval=30),
+            # CalendarDeleter(enabled=False, interval=30)
+        ]
+    )
+]
+
+# event_updates_only = [
+#     {
+#         "software": OS_X_10_11,
+#         "params": {
+#             "title": "10.11",
+#             "calendarHomePollInterval": 5,
+#             "supportAmpPush": True,
+#             "ampPushHost": "localhost",
+#             "ampPushPort": 62311
+#         },
+#         "profiles": [
+#             ProfileType(Eventer, dict(
+#                 enabled=False,
+#                 interval=20,
+#                 eventStartDistribution=STANDARD_WORK_DISTRIBUTION,
+#                 recurrenceDistribution=MEDIUM_RECURRENCE_DISTRIBUTION
+#             )),
+#             ProfileType(EventerUpdater, dict(
+#                 enabled=True,
+#                 interval=5,
+#                 eventStartDistribution=STANDARD_WORK_DISTRIBUTION,
+#                 recurrenceDistribution=LOW_RECURRENCE_DISTRIBUTION
+#             )),
+#             ProfileType(RealisticInviter, dict(
+#                 enabled=False,
+#                 sendInvitationDistribution=LogNormalDistribution(mu=10, sigma=5),
+#                 inviteeDistribution=UniformIntegerDistribution(0, 99),
+#                 inviteeClumping=True,
+#                 inviteeCountDistribution=LogNormalDistribution(mode=1, median=6, maximum=100),
+#                 eventStartDistribution=STANDARD_WORK_DISTRIBUTION,
+#                 recurrenceDistribution=MEDIUM_RECURRENCE_DISTRIBUTION
+#             )),
+#             ProfileType(Accepter, dict(
+#                 enabled=False,
+#                 acceptDelayDistribution=LogNormalDistribution(mode=300, median=1800)
+#             )),
+#             ProfileType(Tasker, dict(
+#                 enabled=False,
+#                 interval=300,
+#                 taskDueDistribution=STANDARD_WORK_DISTRIBUTION
+#             ))
+#         ],
+#         "weight": 1
+#     }
+# ]
+
+
+# clientConfiguration = calendars_only
+# __all__ = [clientConfiguration]

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.dist.plist
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.dist.plist	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.dist.plist	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,184 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+    Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+  -->
+
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<!-- This is a distributed orchestrator configuration; 'workers' is a list of
+							shell commands to run sub-processes.
+							-->
+		<key>workers</key>
+		<array>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+			<string>./bin/python contrib/performance/loadtest/ampsim.py</string>
+		</array>
+
+		<!-- Identify the server to be load tested. -->
+		<key>server</key>
+		<string>https://127.0.0.1:8443</string>
+
+		<!-- Configure Admin Web UI. -->
+		<key>webadmin</key>
+		<dict>
+			<key>enabled</key>
+			<true/>
+
+			<key>HTTPPort</key>
+			<integer>8080</integer>
+		</dict>
+
+		<!--  Define whether server supports stats socket. -->
+		<key>serverStats</key>
+		<dict>
+			<key>enabled</key>
+			<true/>
+			<key>Port</key>
+			<integer>8100</integer>
+		</dict>
+
+		<!--  Define whether client data should be re-used. It will always be saved to the specified path.-->
+		<key>clientDataSerialization</key>
+		<dict>
+			<key>UseOldData</key>
+			<true/>
+			<key>Path</key>
+			<string>/tmp/sim</string>
+		</dict>
+
+		<!-- Define the credentials of the clients which will be used to load test
+			the server. These credentials must already be valid on the server. -->
+		<key>accounts</key>
+		<dict>
+			<!-- The loader is the fully-qualified Python name of a callable which
+				returns a list of directory service records defining all of the client accounts
+				to use. contrib.performance.loadtest.sim.recordsFromCSVFile reads username,
+				password, mailto triples from a CSV file and returns them as a list of faked
+				directory service records. -->
+			<key>loader</key>
+			<string>contrib.performance.loadtest.sim.recordsFromCSVFile</string>
+
+			<!-- Keyword arguments may be passed to the loader. -->
+			<key>params</key>
+			<dict>
+				<!-- recordsFromCSVFile interprets the path relative to the config.plist,
+					to make it independent of the script's working directory while still allowing
+					a relative path. This isn't a great solution. -->
+				<key>path</key>
+				<string>contrib/performance/loadtest/accounts.csv</string>
+			</dict>
+		</dict>
+
+		<!-- Define how many clients will participate in the load test and how
+			they will show up. -->
+		<key>arrival</key>
+		<dict>
+
+			<!-- Specify a class which creates new clients and introduces them into
+				the test. contrib.performance.loadtest.population.SmoothRampUp introduces
+				groups of new clients at fixed intervals up to a maximum. The size of the
+				group, interval, and maximum are configured by the parameters below. The
+				total number of clients is groups * groupSize, which needs to be no larger
+				than the number of credentials created in the accounts section. -->
+			<key>factory</key>
+			<string>contrib.performance.loadtest.population.SmoothRampUp</string>
+
+			<key>params</key>
+			<dict>
+				<!-- groups gives the total number of groups of clients to introduce. -->
+				<key>groups</key>
+				<integer>99</integer>
+
+				<!-- groupSize is the number of clients in each group of clients. It's
+					really only a "smooth" ramp up if this is pretty small. -->
+				<key>groupSize</key>
+				<integer>1</integer>
+
+				<!-- Number of seconds between the introduction of each group. -->
+				<key>interval</key>
+				<integer>3</integer>
+
+				<!-- Number of clients each user is assigned to. -->
+				<!-- Set weight of clients to 1 if this is > 1. Number of clients must match this value if > 1. -->
+				<key>clientsPerUser</key>
+				<integer>1</integer>
+			</dict>
+
+		</dict>
+
+		<!-- Define some log observers to report on the load test. -->
+		<key>observers</key>
+		<array>
+			<!-- ReportStatistics generates an end-of-run summary of the HTTP requests
+				made, their timings, and their results. -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.ReportStatistics</string>
+				<key>params</key>
+				<dict>
+					<!-- The thresholds for each request type -->
+					<key>thresholdsPath</key>
+					<string>contrib/performance/loadtest/thresholds.json</string>
+
+					<!-- The benchmarks for overall QoS -->
+					<key>benchmarksPath</key>
+					<string>contrib/performance/loadtest/benchmarks.json</string>
+
+					<!-- The % of failures that constitute a failed test -->
+					<key>failCutoff</key>
+					<real>1.0</real>
+				</dict>
+			</dict>
+
+			<!-- RequestLogger generates a realtime log of all HTTP requests made
+				during the load test. -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.RequestLogger</string>
+				<key>params</key>
+				<dict>
+				</dict>
+			</dict>
+
+			<!-- OperationLogger generates an end-of-run summary of the gross operations
+				performed (logical operations which may span more than one HTTP request,
+				such as inviting an attendee to an event). -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.OperationLogger</string>
+				<key>params</key>
+				<dict>
+					<!-- The thresholds for each operation type -->
+					<key>thresholdsPath</key>
+					<string>contrib/performance/loadtest/thresholds.json</string>
+
+					<!-- The % of operations beyond the lag cut-off that constitute a failed test -->
+					<key>lagCutoff</key>
+					<real>1.0</real>
+
+					<!-- The % of failures that constitute a failed test -->
+					<key>failCutoff</key>
+					<real>1.0</real>
+				</dict>
+			</dict>
+		</array>
+	</dict>
+</plist>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.json
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.json	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.json	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,113 @@
+config = {
+    "server": "https://127.0.0.1:8443",
+    "webadmin": {
+        "enabled": true,
+        "HTTPPort": 8080
+    },
+    "serverStats": {
+        "enabled": true,
+        "Port": 8100
+    },
+    "clientDataSerialization": {
+        "UseOldData": true,
+        "Path": "/tmp/sim"
+    },
+    "accounts": {
+        "loader": "contrib.performance.loadtest.sim.recordsFromCSVFile",
+        "params": {
+            "path": "contrib/performance/loadtest/accounts.csv"
+        }
+    },
+    "arrival": {
+        "factory": "contrib.performance.loadtest.population.SmoothRampUp",
+        "params": {
+            "groups": 2,
+            "groupSize": 1,
+            "interval": 3,
+            "clientsPerUser": 1
+        }
+    },
+    "observers": [
+        {
+            "type": "contrib.performance.loadtest.logger.ReportStatistics",
+            "params": {
+                "thresholdsPath": "contrib/performance/loadtest/thresholds.json",
+                "benchmarksPath": "contrib/performance/loadtest/benchmarks.json",
+                "failCutoff": 1.0
+            }
+        },
+        {
+            "type": "contrib.performance.loadtest.logger.RequestLogger",
+            "params": {}
+        },
+        {
+            "type": "contrib.performance.loadtest.logger.OperationLogger",
+            "params": {
+                "thresholdsPath": "contrib/performance/loadtest/thresholds.json",
+                "lagCutoff": 1.0,
+                "failCutoff": 1.0
+            }
+        }
+    ]
+}
+
+config-dist = {
+    "workers": [
+        "./bin/python contrib/performance/loadtest/ampsim.py",
+        "./bin/python contrib/performance/loadtest/ampsim.py",
+        "./bin/python contrib/performance/loadtest/ampsim.py",
+        "./bin/python contrib/performance/loadtest/ampsim.py",
+        "./bin/python contrib/performance/loadtest/ampsim.py",
+        "./bin/python contrib/performance/loadtest/ampsim.py"
+    ],
+    "server": "https://127.0.0.1:8443",
+    "webadmin": {
+        "enabled": true,
+        "HTTPPort": 8080
+    },
+    "serverStats": {
+        "enabled": true,
+        "Port": 8100
+    },
+    "clientDataSerialization": {
+        "UseOldData": true,
+        "Path": "/tmp/sim"
+    },
+    "accounts": {
+        "loader": "contrib.performance.loadtest.sim.recordsFromCSVFile",
+        "params": {
+            "path": "contrib/performance/loadtest/accounts.csv"
+        }
+    },
+        "arrival": {
+        "factory": "contrib.performance.loadtest.population.SmoothRampUp",
+        "params": {
+            "groups": 99,
+            "groupSize": 1,
+            "interval": 3,
+            "clientsPerUser": 1
+        }
+    },
+    "observers": [
+        {
+            "type": "contrib.performance.loadtest.logger.ReportStatistics",
+            "params": {
+                "thresholdsPath": "contrib/performance/loadtest/thresholds.json",
+                "benchmarksPath": "contrib/performance/loadtest/benchmarks.json",
+                "failCutoff": 1.0
+            }
+        },
+        {
+            "type": "contrib.performance.loadtest.logger.RequestLogger",
+            "params": {}
+        },
+        {
+            "type": "contrib.performance.loadtest.logger.OperationLogger",
+            "params": {
+                "thresholdsPath": "contrib/performance/loadtest/thresholds.json",
+                "lagCutoff": 1.0,
+                "failCutoff": 1.0
+            }
+        }
+    ]
+}
\ No newline at end of file

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.plist
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.plist	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.plist	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+    Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+  -->
+
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<!-- Identify the server to be load tested. -->
+		<key>server</key>
+		<string>https://127.0.0.1:8443</string>
+
+		<!-- Configure Admin Web UI. -->
+		<key>webadmin</key>
+		<dict>
+			<key>enabled</key>
+			<true/>
+
+			<key>HTTPPort</key>
+			<integer>8080</integer>
+		</dict>
+
+		<!--  Define whether server supports stats socket. -->
+		<key>serverStats</key>
+		<dict>
+			<key>enabled</key>
+			<true/>
+			<key>Port</key>
+			<integer>8100</integer>
+		</dict>
+
+		<!--  Define whether client data should be re-used. It will always be saved to the specified path.-->
+		<key>clientDataSerialization</key>
+		<dict>
+			<key>UseOldData</key>
+			<true/>
+			<key>Path</key>
+			<string>/tmp/sim</string>
+		</dict>
+
+		<!-- Define the credentials of the clients which will be used to load test
+			the server. These credentials must already be valid on the server. -->
+		<key>accounts</key>
+		<dict>
+			<!-- The loader is the fully-qualified Python name of a callable which
+				returns a list of directory service records defining all of the client accounts
+				to use. contrib.performance.loadtest.sim.recordsFromCSVFile reads username,
+				password, mailto triples from a CSV file and returns them as a list of faked
+				directory service records. -->
+			<key>loader</key>
+			<string>contrib.performance.loadtest.records.recordsFromCSVFile</string>
+
+			<!-- Keyword arguments may be passed to the loader. -->
+			<key>params</key>
+			<dict>
+				<!-- recordsFromCSVFile interprets the path relative to the config.plist,
+					to make it independent of the script's working directory while still allowing
+					a relative path. This isn't a great solution. -->
+				<key>path</key>
+				<string>contrib/performance/loadtest/accounts.csv</string>
+			</dict>
+		</dict>
+
+		<!-- Define how many clients will participate in the load test and how
+			they will show up. -->
+		<key>arrival</key>
+		<dict>
+
+			<!-- Specify a class which creates new clients and introduces them into
+				the test. contrib.performance.loadtest.population.SmoothRampUp introduces
+				groups of new clients at fixed intervals up to a maximum. The size of the
+				group, interval, and maximum are configured by the parameters below. The
+				total number of clients is groups * groupSize, which needs to be no larger
+				than the number of credentials created in the accounts section. -->
+			<key>factory</key>
+			<string>contrib.performance.loadtest.population.SmoothRampUp</string>
+
+			<key>params</key>
+			<dict>
+				<!-- groups gives the total number of groups of clients to introduce. -->
+				<key>groups</key>
+				<integer>3</integer>
+
+				<!-- groupSize is the number of clients in each group of clients. It's
+					really only a "smooth" ramp up if this is pretty small. -->
+				<key>groupSize</key>
+				<integer>1</integer>
+
+				<!-- Number of seconds between the introduction of each group. -->
+				<key>interval</key>
+				<integer>15</integer>
+
+				<!-- Number of clients each user is assigned to. -->
+				<!-- Set weight of clients to 1 if this is > 1. Number of clients must match this value if > 1. -->
+				<key>clientsPerUser</key>
+				<integer>1</integer>
+			</dict>
+
+		</dict>
+
+		<!-- Define some log observers to report on the load test. -->
+		<key>observers</key>
+		<array>
+			<!-- ReportStatistics generates an end-of-run summary of the HTTP requests
+				made, their timings, and their results. -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.ReportStatistics</string>
+				<key>params</key>
+				<dict>
+					<!-- The thresholds for each request type -->
+					<key>thresholdsPath</key>
+					<string>contrib/performance/loadtest/thresholds.json</string>
+
+					<!-- The benchmarks for overall QoS -->
+					<key>benchmarksPath</key>
+					<string>contrib/performance/loadtest/benchmarks.json</string>
+
+					<!-- The % of failures that constitute a failed test -->
+					<key>failCutoff</key>
+					<real>1.0</real>
+				</dict>
+			</dict>
+
+			<!-- RequestLogger generates a realtime log of all HTTP requests made
+				during the load test. -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.RequestLogger</string>
+				<key>params</key>
+				<dict>
+				</dict>
+			</dict>
+
+			<!-- OperationLogger generates an end-of-run summary of the gross operations
+				performed (logical operations which may span more than one HTTP request,
+				such as inviting an attendee to an event). -->
+			<dict>
+				<key>type</key>
+				<string>contrib.performance.loadtest.logger.OperationLogger</string>
+				<key>params</key>
+				<dict>
+					<!-- The thresholds for each operation type -->
+					<key>thresholdsPath</key>
+					<string>contrib/performance/loadtest/thresholds.json</string>
+
+					<!-- The % of operations beyond the lag cut-off that constitute a failed test -->
+					<key>lagCutoff</key>
+					<real>1.0</real>
+
+					<!-- The % of failures that constitute a failed test -->
+					<key>failCutoff</key>
+					<real>1.0</real>
+				</dict>
+			</dict>
+		</array>
+	</dict>
+</plist>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/config.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,25 @@
+# Generally, the defaults are good enough for us.
+
+config = Config(dict(
+    server=server,
+    webadminPort=8080,
+    serverStatsPort=8100,
+    serializationPath='/tmp/sim',
+    arrival=arrival,
+    observers=[_requestLogger, _operationLogger, _statisticsReporter],
+    records=accounts
+)
+
+config_dist = dict(
+    server=server,
+    webadminPort=8080,
+    serverStatsPort=8100,
+    serializationPath='/tmp/sim',
+    arrival=arrival,
+    observers=[_requestLogger, _operationLogger, _statisticsReporter],
+    records=accounts,
+    workers=["./bin/python contrib/performance/loadtest/ampsim.py"] * 6,
+)
+
+# if __name__ == "__main__":
+#     print("Verifying Python games")

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/demo-clients.plist
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/demo-clients.plist	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/settings/demo-clients.plist	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,166 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+    Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+  -->
+
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>clients</key>
+		<array>
+			<dict>
+				<!-- Here is a OS X client simulator. -->
+				<key>software</key>
+				<string>contrib.performance.loadtest.clients.OS_X_10_11</string>
+
+				<!-- Arguments to use to initialize the OS_X_10_7 instance. -->
+				<key>params</key>
+				<dict>
+					<!-- Name that appears in logs. -->
+					<key>title</key>
+					<string>10.11</string>
+
+					<!-- OS_X_10_11 can poll the calendar home at some interval. This is
+						in seconds. -->
+					<key>calendarHomePollInterval</key>
+					<integer>30</integer>
+
+					<!-- If the server advertises AMP push, the client can wait for notifications about calendar home changes in addition to polling them periodically. If this option is true, look for the server advertisement for AMP push and use it if possible. Still fall back to polling if there is no AMP push						advertised. -->
+					<key>supportAmpPush</key>
+					<true/>
+					<key>ampPushHost</key>
+					<string>localhost</string>
+					<key>ampPushPort</key>
+					<integer>62311</integer>
+				</dict>
+
+				<!-- The profiles define certain types of user behavior on top of the
+					client software being simulated. -->
+				<key>profiles</key>
+				<array>
+					<dict>
+						<key>class</key>
+						<string>contrib.performance.loadtest.profiles.Eventer</string>
+
+						<key>params</key>
+						<dict>
+							<key>enabled</key>
+							<true/>
+
+							<!-- Define the interval (in seconds) at which this profile will use
+								its client to create a new event. -->
+							<key>interval</key>
+							<integer>1</integer>
+
+							<!-- Define how start times (DTSTART) for the randomly generated events
+								will be selected. This is an example of a "Distribution" parameter. The value
+								for most "Distribution" parameters are interchangeable and extensible. -->
+							<key>eventStartDistribution</key>
+							<dict>
+
+								<!-- This distribution is pretty specialized. It produces timestamps
+									in the near future, limited to certain days of the week and certain hours
+									of the day. -->
+								<key>type</key>
+								<string>contrib.performance.stats.WorkDistribution</string>
+
+								<key>params</key>
+								<dict>
+									<!-- These are the days of the week the distribution will use. -->
+									<key>daysOfWeek</key>
+									<array>
+										<string>mon</string>
+										<string>tue</string>
+										<string>wed</string>
+										<string>thu</string>
+										<string>fri</string>
+									</array>
+
+									<!-- The earliest hour of a day at which an event might be scheduled. -->
+									<key>beginHour</key>
+									<integer>8</integer>
+
+									<!-- And the latest hour of a day (at which an event will be scheduled
+										to begin!). -->
+									<key>endHour</key>
+									<integer>16</integer>
+
+									<!-- The timezone in which the event is scheduled. (XXX Does this
+										really work right?) -->
+									<key>tzname</key>
+									<string>America/Los_Angeles</string>
+								</dict>
+							</dict>
+						</dict>
+					</dict>
+					<dict>
+						<key>class</key>
+						<string>contrib.performance.loadtest.profiles.Rescheduler</string>
+
+						<key>params</key>
+						<dict>
+							<key>enabled</key>
+							<true/>
+							<key>interval</key>
+							<integer>5</integer>
+							<key>eventStartDistribution</key>
+							<dict>
+
+								<!-- This distribution is pretty specialized. It produces timestamps
+									in the near future, limited to certain days of the week and certain hours
+									of the day. -->
+								<key>type</key>
+								<string>contrib.performance.stats.WorkDistribution</string>
+
+								<key>params</key>
+								<dict>
+									<!-- These are the days of the week the distribution will use. -->
+									<key>daysOfWeek</key>
+									<array>
+										<string>mon</string>
+										<string>tue</string>
+										<string>wed</string>
+										<string>thu</string>
+										<string>fri</string>
+									</array>
+
+									<!-- The earliest hour of a day at which an event might be scheduled. -->
+									<key>beginHour</key>
+									<integer>8</integer>
+
+									<!-- And the latest hour of a day (at which an event will be scheduled
+										to begin!). -->
+									<key>endHour</key>
+									<integer>16</integer>
+
+									<!-- The timezone in which the event is scheduled. (XXX Does this
+										really work right?) -->
+									<key>tzname</key>
+									<string>America/Los_Angeles</string>
+								</dict>
+							</dict>
+						</dict>
+					</dict>
+				</array>
+
+				<!-- Determine the frequency at which this client configuration will
+					appear in the clients which are created by the load tester. -->
+				<key>weight</key>
+				<integer>1</integer>
+			</dict>
+		</array>
+	</dict>
+</plist>

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/templates.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/templates.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/templates.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,73 @@
+# -*- test-case-name: contrib.performance.loadtest.test_population -*-
+##
+# Copyright (c) 2010-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+"""
+A list of predefined component templates for use in the client sim
+"""
+
+from twistedcaldav.ical import Component
+
+# Default event
+# Todo - decide on default timezone, also should we have X-ATAB?
+eventTemplate = Component.fromString("""\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//Mac OS X 10.11//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+TRANSP:OPAQUE
+X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
+SUMMARY:Sample Event
+UID:00000000-0000-0000-0000-000000000000
+CREATED:00000000T000000Z
+DTSTAMP:00000000T000000Z
+DTSTART;TZID=America/Los_Angeles:00000000T000000
+DTEND;TZID=America/Los_Angeles:00000000T000000
+SEQUENCE:0
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n"))
+
+alarmTemplate = Component.fromString("""\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//Mac OS X 10.11//EN
+CALSCALE:GREGORIAN
+BEGIN:VALARM
+X-WR-ALARMUID:00000000-0000-0000-0000-000000000000
+UID:00000000-0000-0000-0000-000000000000
+DESCRIPTION:Sample Alarm
+TRIGGER:-PT5M
+ACTION:DISPLAY
+END:VALARM
+END:VCALENDAR
+""".replace("\n", "\r\n"))
+
+taskTemplate = Component.fromString("""\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//Mac OS X 10.11//EN
+CALSCALE:GREGORIAN
+BEGIN:VTODO
+SUMMARY:Sample Task
+UID:00000000-0000-0000-0000-000000000000
+CREATED:00000000T000000Z
+DTSTAMP:00000000T000000Z
+SEQUENCE:0
+END:VTODO
+END:VCALENDAR
+""".replace("\n", "\r\n"))

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/__init__.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/__init__.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/__init__.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,27 @@
+##
+# Copyright (c) 2012-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+"""
+Unit tests for load-testing tool
+Tests:
+  ical.py
+  logger.py
+  population.py
+  profiles.py
+  sim.py
+  trafficlogger.py
+  webadmin.py
+"""

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_ical.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_ical.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_ical.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,2022 @@
+##
+# Copyright (c) 2010-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+from caldavclientlibrary.protocol.caldav.definitions import caldavxml
+from caldavclientlibrary.protocol.caldav.definitions import csxml
+from caldavclientlibrary.protocol.url import URL
+from caldavclientlibrary.protocol.webdav.definitions import davxml
+
+from contrib.performance.httpclient import MemoryConsumer, StringProducer
+from contrib.performance.loadtest.ical import XMPPPush, Event, Calendar, OS_X_10_6
+from contrib.performance.loadtest.sim import _DirectoryRecord
+
+from pycalendar.datetime import DateTime
+from pycalendar.timezone import Timezone
+
+from twisted.internet.defer import Deferred, inlineCallbacks, returnValue
+from twisted.internet.protocol import ProtocolToConsumerAdapter
+from twisted.python.failure import Failure
+from twisted.trial.unittest import TestCase
+from twisted.web.client import ResponseDone
+from twisted.web.http import OK, NO_CONTENT, CREATED, MULTI_STATUS
+from twisted.web.http_headers import Headers
+
+from twistedcaldav.ical import Component
+from twistedcaldav.timezones import TimezoneCache
+
+import json
+import os
+
+EVENT_UID = 'D94F247D-7433-43AF-B84B-ADD684D023B0'
+
+EVENT = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20101018T155454Z
+UID:%(UID)s
+DTEND;TZID=America/New_York:20101028T130000
+ATTENDEE;CN="User 03";CUTYPE=INDIVIDUAL;EMAIL="user03 at example.com";PARTS
+ TAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:user03 at example.co
+ m
+ATTENDEE;CN="User 01";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:user01@
+ example.com
+TRANSP:OPAQUE
+SUMMARY:Attended Event
+DTSTART;TZID=America/New_York:20101028T120000
+DTSTAMP:20101018T155513Z
+ORGANIZER;CN="User 01":mailto:user01 at example.com
+SEQUENCE:3
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {'UID': EVENT_UID}
+
+EVENT_INVITE = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+X-LIC-LOCATION:America/New_York
+BEGIN:STANDARD
+DTSTART:18831118T120358
+RDATE:18831118T120358
+TZNAME:EST
+TZOFFSETFROM:-045602
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19180331T020000
+RRULE:FREQ=YEARLY;UNTIL=19190330T070000Z;BYDAY=-1SU;BYMONTH=3
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19181027T020000
+RRULE:FREQ=YEARLY;UNTIL=19191026T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:STANDARD
+DTSTART:19200101T000000
+RDATE:19200101T000000
+RDATE:19420101T000000
+RDATE:19460101T000000
+RDATE:19670101T000000
+TZNAME:EST
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19200328T020000
+RDATE:19200328T020000
+RDATE:19740106T020000
+RDATE:19750223T020000
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19201031T020000
+RDATE:19201031T020000
+RDATE:19450930T020000
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19210424T020000
+RRULE:FREQ=YEARLY;UNTIL=19410427T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19210925T020000
+RRULE:FREQ=YEARLY;UNTIL=19410928T060000Z;BYDAY=-1SU;BYMONTH=9
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19420209T020000
+RDATE:19420209T020000
+TZNAME:EWT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19450814T190000
+RDATE:19450814T190000
+TZNAME:EPT
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19460428T020000
+RRULE:FREQ=YEARLY;UNTIL=19660424T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19460929T020000
+RRULE:FREQ=YEARLY;UNTIL=19540926T060000Z;BYDAY=-1SU;BYMONTH=9
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:STANDARD
+DTSTART:19551030T020000
+RRULE:FREQ=YEARLY;UNTIL=19661030T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;UNTIL=19730429T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;UNTIL=20061029T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;UNTIL=19860427T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;UNTIL=20060402T070000Z;BYDAY=1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+CREATED:20101018T155454Z
+UID:%(UID)s
+DTEND;TZID=America/New_York:20101028T130000
+ATTENDEE;CN="User 02";CUTYPE=INDIVIDUAL;EMAIL="user02 at example.com";PARTS
+ TAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:user02 at example.co
+ m
+ATTENDEE;CN="User 03";CUTYPE=INDIVIDUAL;EMAIL="user03 at example.com";PARTS
+ TAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:user03 at example.co
+ m
+ATTENDEE;CN="User 01";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:urn:uuid:user01
+TRANSP:OPAQUE
+SUMMARY:Attended Event
+DTSTART;TZID=America/New_York:20101028T120000
+DTSTAMP:20101018T155513Z
+ORGANIZER;CN="User 01":urn:uuid:user01
+SEQUENCE:3
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {'UID': EVENT_UID}
+
+EVENT_AND_TIMEZONE = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+X-LIC-LOCATION:America/New_York
+BEGIN:STANDARD
+DTSTART:18831118T120358
+RDATE:18831118T120358
+TZNAME:EST
+TZOFFSETFROM:-045602
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19180331T020000
+RRULE:FREQ=YEARLY;UNTIL=19190330T070000Z;BYDAY=-1SU;BYMONTH=3
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19181027T020000
+RRULE:FREQ=YEARLY;UNTIL=19191026T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:STANDARD
+DTSTART:19200101T000000
+RDATE:19200101T000000
+RDATE:19420101T000000
+RDATE:19460101T000000
+RDATE:19670101T000000
+TZNAME:EST
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19200328T020000
+RDATE:19200328T020000
+RDATE:19740106T020000
+RDATE:19750223T020000
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19201031T020000
+RDATE:19201031T020000
+RDATE:19450930T020000
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19210424T020000
+RRULE:FREQ=YEARLY;UNTIL=19410427T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19210925T020000
+RRULE:FREQ=YEARLY;UNTIL=19410928T060000Z;BYDAY=-1SU;BYMONTH=9
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19420209T020000
+RDATE:19420209T020000
+TZNAME:EWT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19450814T190000
+RDATE:19450814T190000
+TZNAME:EPT
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19460428T020000
+RRULE:FREQ=YEARLY;UNTIL=19660424T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19460929T020000
+RRULE:FREQ=YEARLY;UNTIL=19540926T060000Z;BYDAY=-1SU;BYMONTH=9
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:STANDARD
+DTSTART:19551030T020000
+RRULE:FREQ=YEARLY;UNTIL=19661030T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;UNTIL=19730429T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;UNTIL=20061029T060000Z;BYDAY=-1SU;BYMONTH=10
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;UNTIL=19860427T070000Z;BYDAY=-1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;UNTIL=20060402T070000Z;BYDAY=1SU;BYMONTH=4
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+CREATED:20101018T155454Z
+UID:%(UID)s
+DTEND;TZID=America/New_York:20101028T130000
+ATTENDEE;CN="User 03";CUTYPE=INDIVIDUAL;EMAIL="user03 at example.com";PARTS
+ TAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:user03 at example.co
+ m
+ATTENDEE;CN="User 01";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mailto:user01@
+ example.com
+TRANSP:OPAQUE
+SUMMARY:Attended Event
+DTSTART;TZID=America/New_York:20101028T120000
+DTSTAMP:20101018T155513Z
+ORGANIZER;CN="User 01":mailto:user01 at example.com
+SEQUENCE:3
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {'UID': EVENT_UID}
+
+
+
+class EventTests(TestCase):
+    """
+    Tests for L{Event}.
+    """
+    def test_uid(self):
+        """
+        When the C{vevent} attribute of an L{Event} instance is set,
+        L{Event.getUID} returns the UID value from it.
+        """
+        event = Event(None, u'/foo/bar', u'etag', Component.fromString(EVENT))
+        self.assertEquals(event.getUID(), EVENT_UID)
+
+
+    def test_withoutUID(self):
+        """
+        When an L{Event} has a C{vevent} attribute set to C{None},
+        L{Event.getUID} returns C{None}.
+        """
+        event = Event(None, u'/bar/baz', u'etag')
+        self.assertIdentical(event.getUID(), None)
+
+
+
+PRINCIPAL_PROPFIND_RESPONSE = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/principals/__uids__/user01/</href>
+    <propstat>
+      <prop>
+        <principal-collection-set>
+          <href>/principals/</href>
+        </principal-collection-set>
+        <calendar-home-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01</href>
+        </calendar-home-set>
+        <calendar-user-address-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/principals/__uids__/user01/</href>
+          <href xmlns='DAV:'>/principals/users/user01/</href>
+        </calendar-user-address-set>
+        <schedule-inbox-URL xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/inbox/</href>
+        </schedule-inbox-URL>
+        <schedule-outbox-URL xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/outbox/</href>
+        </schedule-outbox-URL>
+        <dropbox-home-URL xmlns='http://calendarserver.org/ns/'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/dropbox/</href>
+        </dropbox-home-URL>
+        <notification-URL xmlns='http://calendarserver.org/ns/'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/notification/</href>
+        </notification-URL>
+        <displayname>User 01</displayname>
+        <principal-URL>
+          <href>/principals/__uids__/user01/</href>
+        </principal-URL>
+        <supported-report-set>
+          <supported-report>
+            <report>
+              <acl-principal-prop-set/>
+            </report>
+          </supported-report>
+          <supported-report>
+            <report>
+              <principal-match/>
+            </report>
+          </supported-report>
+          <supported-report>
+            <report>
+              <principal-property-search/>
+            </report>
+          </supported-report>
+          <supported-report>
+            <report>
+              <expand-property/>
+            </report>
+          </supported-report>
+        </supported-report-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+</multistatus>
+"""
+
+_CALENDAR_HOME_PROPFIND_RESPONSE_TEMPLATE = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/calendars/__uids__/user01/</href>
+    <propstat>
+      <prop>
+        %(xmpp)s
+        <displayname>User 01</displayname>
+        <resourcetype>
+          <collection/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+        </current-user-privilege-set>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'>/Some/Unique/Value</pushkey>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <getctag xmlns='http://calendarserver.org/ns/'/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/notification/</href>
+    <propstat>
+      <prop>
+        <displayname>notification</displayname>
+        <resourcetype>
+          <collection/>
+          <notification xmlns='http://calendarserver.org/ns/'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+        </current-user-privilege-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <getctag xmlns='http://calendarserver.org/ns/'/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/dropbox/</href>
+    <propstat>
+      <prop>
+        <resourcetype>
+          <collection/>
+          <dropbox-home xmlns='http://calendarserver.org/ns/'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+        </current-user-privilege-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <getctag xmlns='http://calendarserver.org/ns/'/>
+        <displayname/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/calendar/</href>
+    <propstat>
+      <prop>
+        <getctag xmlns='http://calendarserver.org/ns/'>c2696540-4c4c-4a31-adaf-c99630776828#3</getctag>
+        <displayname>calendar</displayname>
+        <calendar-color xmlns='http://apple.com/ns/ical/'>#0252D4FF</calendar-color>
+        <calendar-order xmlns='http://apple.com/ns/ical/'>1</calendar-order>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <comp name='VEVENT'/>
+          <comp name='VTODO'/>
+          <comp name='VTIMEZONE'/>
+          <comp name='VFREEBUSY'/>
+        </supported-calendar-component-set>
+        <resourcetype>
+          <collection/>
+          <calendar xmlns='urn:ietf:params:xml:ns:caldav'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'>
+          <opaque/>
+        </schedule-calendar-transp>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:DAYLIGHT
+TZOFFSETFROM:-0500
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+DTSTART:20070311T020000
+TZNAME:EDT
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:-0400
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+DTSTART:20071104T020000
+TZNAME:EST
+TZOFFSETTO:-0500
+END:STANDARD
+END:VTIMEZONE
+END:VCALENDAR
+]]></calendar-timezone>
+        <current-user-privilege-set>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+        </current-user-privilege-set>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/outbox/</href>
+    <propstat>
+      <prop>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <comp name='VEVENT'/>
+          <comp name='VTODO'/>
+          <comp name='VTIMEZONE'/>
+          <comp name='VFREEBUSY'/>
+        </supported-calendar-component-set>
+        <resourcetype>
+          <collection/>
+          <schedule-outbox xmlns='urn:ietf:params:xml:ns:caldav'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+          <privilege>
+            <schedule-send xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <schedule xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+        </current-user-privilege-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <getctag xmlns='http://calendarserver.org/ns/'/>
+        <displayname/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/freebusy</href>
+    <propstat>
+      <prop>
+        <resourcetype>
+          <free-busy-url xmlns='http://calendarserver.org/ns/'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <schedule-deliver xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <schedule xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+        </current-user-privilege-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <getctag xmlns='http://calendarserver.org/ns/'/>
+        <displayname/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/calendars/__uids__/user01/inbox/</href>
+    <propstat>
+      <prop>
+        <getctag xmlns='http://calendarserver.org/ns/'>a483dab3-1391-445b-b1c3-5ae9dfc81c2f#0</getctag>
+        <displayname>inbox</displayname>
+        <supported-calendar-component-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <comp name='VEVENT'/>
+          <comp name='VTODO'/>
+          <comp name='VTIMEZONE'/>
+          <comp name='VFREEBUSY'/>
+        </supported-calendar-component-set>
+        <resourcetype>
+          <collection/>
+          <schedule-inbox xmlns='urn:ietf:params:xml:ns:caldav'/>
+        </resourcetype>
+        <owner>
+          <href>/principals/__uids__/user01/</href>
+        </owner>
+        <calendar-free-busy-set xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/calendar</href>
+        </calendar-free-busy-set>
+        <schedule-default-calendar-URL xmlns='urn:ietf:params:xml:ns:caldav'>
+          <href xmlns='DAV:'>/calendars/__uids__/user01/calendar</href>
+        </schedule-default-calendar-URL>
+        <quota-available-bytes>104855434</quota-available-bytes>
+        <quota-used-bytes>2166</quota-used-bytes>
+        <current-user-privilege-set>
+          <privilege>
+            <schedule-deliver xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <schedule xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+          <privilege>
+            <all/>
+          </privilege>
+          <privilege>
+            <read/>
+          </privilege>
+          <privilege>
+            <write/>
+          </privilege>
+          <privilege>
+            <write-properties/>
+          </privilege>
+          <privilege>
+            <write-content/>
+          </privilege>
+          <privilege>
+            <bind/>
+          </privilege>
+          <privilege>
+            <unbind/>
+          </privilege>
+          <privilege>
+            <unlock/>
+          </privilege>
+          <privilege>
+            <read-acl/>
+          </privilege>
+          <privilege>
+            <write-acl/>
+          </privilege>
+          <privilege>
+            <read-current-user-privilege-set/>
+          </privilege>
+          <privilege>
+            <read-free-busy xmlns='urn:ietf:params:xml:ns:caldav'/>
+          </privilege>
+        </current-user-privilege-set>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>
+        <calendar-description xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-color xmlns='http://apple.com/ns/ical/'/>
+        <calendar-order xmlns='http://apple.com/ns/ical/'/>
+        <schedule-calendar-transp xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <calendar-timezone xmlns='urn:ietf:params:xml:ns:caldav'/>
+        <source xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-alarms xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-attachments xmlns='http://calendarserver.org/ns/'/>
+        <subscribed-strip-todos xmlns='http://calendarserver.org/ns/'/>
+        <refreshrate xmlns='http://apple.com/ns/ical/'/>
+        <push-transports xmlns='http://calendarserver.org/ns/'/>
+        <pushkey xmlns='http://calendarserver.org/ns/'/>
+        <publish-url xmlns='http://calendarserver.org/ns/'/>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+</multistatus>
+"""
+
+CALENDAR_HOME_PROPFIND_RESPONSE = _CALENDAR_HOME_PROPFIND_RESPONSE_TEMPLATE % {
+    "xmpp": """\
+        <xmpp-server xmlns='http://calendarserver.org/ns/'/>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'/>""",
+}
+
+CALENDAR_HOME_PROPFIND_RESPONSE_WITH_XMPP = _CALENDAR_HOME_PROPFIND_RESPONSE_TEMPLATE % {
+    "xmpp": """\
+        <xmpp-server xmlns='http://calendarserver.org/ns/'>xmpp.example.invalid:1952</xmpp-server>
+        <xmpp-uri xmlns='http://calendarserver.org/ns/'>xmpp:pubsub.xmpp.example.invalid?pubsub;node=/CalDAV/another.example.invalid/user01/</xmpp-uri>""",
+}
+
+CALENDAR_HOME_PROPFIND_RESPONSE_XMPP_MISSING = _CALENDAR_HOME_PROPFIND_RESPONSE_TEMPLATE % {"xmpp": ""}
+
+
+
+class MemoryResponse(object):
+    def __init__(self, version, code, phrase, headers, bodyProducer):
+        self.version = version
+        self.code = code
+        self.phrase = phrase
+        self.headers = headers
+        self.length = bodyProducer.length
+        self._bodyProducer = bodyProducer
+
+
+    def deliverBody(self, protocol):
+        protocol.makeConnection(self._bodyProducer)
+        d = self._bodyProducer.startProducing(ProtocolToConsumerAdapter(protocol))
+        d.addCallback(lambda ignored: protocol.connectionLost(Failure(ResponseDone())))
+
+
+
+class OS_X_10_6Mixin:
+    """
+    Mixin for L{TestCase}s for L{OS_X_10_6}.
+    """
+    def setUp(self):
+        TimezoneCache.create()
+        self.record = _DirectoryRecord(
+            u"user91", u"user91", u"User 91", u"user91 at example.org", u"user91",
+        )
+        serializePath = self.mktemp()
+        os.mkdir(serializePath)
+        self.client = OS_X_10_6(
+            None,
+            "http://127.0.0.1",
+            "/principals/users/%s/",
+            serializePath,
+            self.record,
+            None,
+        )
+
+
+    def interceptRequests(self):
+        requests = []
+        def request(*args, **kwargs):
+            result = Deferred()
+            requests.append((result, args))
+            return result
+        self.client._request = request
+        return requests
+
+
+
+class OS_X_10_6Tests(OS_X_10_6Mixin, TestCase):
+    """
+    Tests for L{OS_X_10_6}.
+    """
+    def test_parsePrincipalPROPFINDResponse(self):
+        """
+        L{Principal._parsePROPFINDResponse} accepts an XML document
+        like the one in the response to a I{PROPFIND} request for
+        I{/principals/__uids__/<uid>/} and returns a C{PropFindResult}
+        representing the data from it.
+        """
+        principals = self.client._parseMultiStatus(PRINCIPAL_PROPFIND_RESPONSE)
+        principal = principals['/principals/__uids__/user01/']
+        self.assertEquals(
+            principal.getHrefProperties(),
+            {
+                davxml.principal_collection_set: URL(path='/principals/'),
+                caldavxml.calendar_home_set: URL(path='/calendars/__uids__/user01'),
+                caldavxml.calendar_user_address_set: (
+                    URL(path='/principals/__uids__/user01/'),
+                    URL(path='/principals/users/user01/'),
+                ),
+                caldavxml.schedule_inbox_URL: URL(path='/calendars/__uids__/user01/inbox/'),
+                caldavxml.schedule_outbox_URL: URL(path='/calendars/__uids__/user01/outbox/'),
+                csxml.dropbox_home_URL: URL(path='/calendars/__uids__/user01/dropbox/'),
+                csxml.notification_URL: URL(path='/calendars/__uids__/user01/notification/'),
+                davxml.principal_URL: URL(path='/principals/__uids__/user01/'),
+            }
+        )
+        self.assertEquals(
+            principal.getTextProperties(),
+            {davxml.displayname: 'User 01'})
+
+#         self.assertEquals(
+#             principal.getSomething(),
+#             {SUPPORTED_REPORT_SET: (
+#                     '{DAV:}acl-principal-prop-set',
+#                     '{DAV:}principal-match',
+#                     '{DAV:}principal-property-search',
+#                     '{DAV:}expand-property',
+#                     )})
+
+
+    def test_extractCalendars(self):
+        """
+        L{OS_X_10_6._extractCalendars} accepts a calendar home
+        PROPFIND response body and returns a list of calendar objects
+        constructed from the data extracted from the response.
+        """
+        home = "/calendars/__uids__/user01/"
+        calendars = self.client._extractCalendars(
+            self.client._parseMultiStatus(CALENDAR_HOME_PROPFIND_RESPONSE), home)
+        calendars.sort(key=lambda cal: cal.resourceType)
+        calendar, inbox = calendars
+
+        self.assertEquals(calendar.resourceType, caldavxml.calendar)
+        self.assertEquals(calendar.name, "calendar")
+        self.assertEquals(calendar.url, "/calendars/__uids__/user01/calendar/")
+        self.assertEquals(calendar.changeToken, "c2696540-4c4c-4a31-adaf-c99630776828#3")
+
+        self.assertEquals(inbox.resourceType, caldavxml.schedule_inbox)
+        self.assertEquals(inbox.name, "inbox")
+        self.assertEquals(inbox.url, "/calendars/__uids__/user01/inbox/")
+        self.assertEquals(inbox.changeToken, "a483dab3-1391-445b-b1c3-5ae9dfc81c2f#0")
+
+        self.assertEqual({}, self.client.xmpp)
+
+
+    def test_extractCalendarsXMPP(self):
+        """
+        If there is XMPP push information in a calendar home PROPFIND response,
+        L{OS_X_10_6._extractCalendars} finds it and records it.
+        """
+        home = "/calendars/__uids__/user01/"
+        self.client._extractCalendars(
+            self.client._parseMultiStatus(CALENDAR_HOME_PROPFIND_RESPONSE_WITH_XMPP),
+            home
+        )
+        self.assertEqual({
+            home: XMPPPush(
+                "xmpp.example.invalid:1952",
+                "xmpp:pubsub.xmpp.example.invalid?pubsub;node=/CalDAV/another.example.invalid/user01/",
+                "/Some/Unique/Value"
+            )},
+            self.client.xmpp
+        )
+
+
+    def test_handleMissingXMPP(self):
+        home = "/calendars/__uids__/user01/"
+        self.client._extractCalendars(
+            self.client._parseMultiStatus(CALENDAR_HOME_PROPFIND_RESPONSE_XMPP_MISSING), home)
+        self.assertEqual({}, self.client.xmpp)
+
+
+    @inlineCallbacks
+    def test_changeEventAttendee(self):
+        """
+        OS_X_10_6.changeEventAttendee removes one attendee from an
+        existing event and appends another.
+        """
+        requests = self.interceptRequests()
+
+        vevent = Component.fromString(EVENT)
+        attendees = tuple(vevent.mainComponent().properties("ATTENDEE"))
+        old = attendees[0]
+        new = old.duplicate()
+        new.setParameter('CN', 'Some Other Guy')
+        event = Event(self.client.serializeLocation(), u'/some/calendar/1234.ics', None, vevent)
+        self.client._events[event.url] = event
+        self.client.changeEventAttendee(event.url, old, new)
+
+        _ignore_result, req = requests.pop(0)
+
+        # iCal PUTs the new VCALENDAR object.
+        _ignore_expectedResponseCode, method, url, headers, body = req
+        self.assertEquals(method, 'PUT')
+        self.assertEquals(url, 'http://127.0.0.1' + event.url)
+        self.assertIsInstance(url, str)
+        self.assertEquals(headers.getRawHeaders('content-type'), ['text/calendar'])
+
+        consumer = MemoryConsumer()
+        yield body.startProducing(consumer)
+        vevent = Component.fromString(consumer.value())
+        attendees = tuple(vevent.mainComponent().properties("ATTENDEE"))
+        self.assertEquals(len(attendees), 2)
+        self.assertEquals(attendees[0].parameterValue('CN'), 'User 01')
+        self.assertEquals(attendees[1].parameterValue('CN'), 'Some Other Guy')
+
+
+    def test_addEvent(self):
+        """
+        L{OS_X_10_6.addEvent} PUTs the event passed to it to the
+        server and updates local state to reflect its existence.
+        """
+        requests = self.interceptRequests()
+
+        calendar = Calendar(caldavxml.calendar, set(('VEVENT',)), u'calendar', u'/mumble/', None)
+        self.client._calendars[calendar.url] = calendar
+
+        vcalendar = Component.fromString(EVENT)
+        d = self.client.addEvent(u'/mumble/frotz.ics', vcalendar)
+
+        result, req = requests.pop(0)
+
+        # iCal PUTs the new VCALENDAR object.
+        expectedResponseCode, method, url, headers, body = req
+        self.assertEqual(expectedResponseCode, CREATED)
+        self.assertEqual(method, 'PUT')
+        self.assertEqual(url, 'http://127.0.0.1/mumble/frotz.ics')
+        self.assertIsInstance(url, str)
+        self.assertEqual(headers.getRawHeaders('content-type'), ['text/calendar'])
+
+        consumer = MemoryConsumer()
+        finished = body.startProducing(consumer)
+        def cbFinished(ignored):
+            self.assertEqual(
+                Component.fromString(consumer.value()),
+                Component.fromString(EVENT_AND_TIMEZONE))
+        finished.addCallback(cbFinished)
+
+        def requested(ignored):
+            response = MemoryResponse(
+                ('HTTP', '1', '1'), CREATED, "Created", Headers({}),
+                StringProducer(""))
+            result.callback(response)
+        finished.addCallback(requested)
+
+        return d
+
+
+    @inlineCallbacks
+    def test_addInvite(self):
+        """
+        L{OS_X_10_6.addInvite} PUTs the event passed to it to the
+        server and updates local state to reflect its existence, but
+        it also does attendee auto-complete and free-busy checks before
+        the PUT.
+        """
+
+        calendar = Calendar(caldavxml.calendar, set(('VEVENT',)), u'calendar', u'/mumble/', None)
+        self.client._calendars[calendar.url] = calendar
+
+        vcalendar = Component.fromString(EVENT_INVITE)
+
+        self.client.uuid = u'urn:uuid:user01'
+        self.client.email = u'mailto:user01 at example.com'
+        self.client.principalCollection = "/principals/"
+        self.client.outbox = "/calendars/__uids__/user01/outbox/"
+
+        @inlineCallbacks
+        def _testReport(*args, **kwargs):
+            expectedResponseCode, method, url, headers, body = args
+            self.assertEqual(expectedResponseCode, (MULTI_STATUS,))
+            self.assertEqual(method, 'REPORT')
+            self.assertEqual(url, 'http://127.0.0.1/principals/')
+            self.assertIsInstance(url, str)
+            self.assertEqual(headers.getRawHeaders('content-type'), ['text/xml'])
+
+            consumer = MemoryConsumer()
+            yield body.startProducing(consumer)
+
+            response = MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "MultiStatus", Headers({}),
+                StringProducer("<?xml version='1.0' encoding='UTF-8'?><multistatus xmlns='DAV:' />"))
+
+            returnValue(response)
+
+        @inlineCallbacks
+        def _testPost(*args, **kwargs):
+            expectedResponseCode, method, url, headers, body = args
+            self.assertEqual(expectedResponseCode, OK)
+            self.assertEqual(method, 'POST')
+            self.assertEqual(url, 'http://127.0.0.1/calendars/__uids__/user01/outbox/')
+            self.assertIsInstance(url, str)
+            self.assertEqual(headers.getRawHeaders('content-type'), ['text/calendar'])
+
+            consumer = MemoryConsumer()
+            yield body.startProducing(consumer)
+            self.assertNotEqual(consumer.value().find(kwargs["attendee"]), -1)
+
+            response = MemoryResponse(
+                ('HTTP', '1', '1'), OK, "OK", Headers({}),
+                StringProducer(""))
+
+            returnValue(response)
+
+        def _testPost02(*args, **kwargs):
+            return _testPost(*args, attendee="ATTENDEE:mailto:user02 at example.com", **kwargs)
+
+        def _testPost03(*args, **kwargs):
+            return _testPost(*args, attendee="ATTENDEE:mailto:user03 at example.com", **kwargs)
+
+        @inlineCallbacks
+        def _testPut(*args, **kwargs):
+            expectedResponseCode, method, url, headers, body = args
+            self.assertEqual(expectedResponseCode, CREATED)
+            self.assertEqual(method, 'PUT')
+            self.assertEqual(url, 'http://127.0.0.1/mumble/frotz.ics')
+            self.assertIsInstance(url, str)
+            self.assertEqual(headers.getRawHeaders('content-type'), ['text/calendar'])
+
+            consumer = MemoryConsumer()
+            yield body.startProducing(consumer)
+            self.assertEqual(
+                Component.fromString(consumer.value()),
+                Component.fromString(EVENT_INVITE))
+
+            response = MemoryResponse(
+                ('HTTP', '1', '1'), CREATED, "Created", Headers({}),
+                StringProducer(""))
+
+            returnValue(response)
+
+        requests = [_testReport, _testPost02, _testReport, _testPost03, _testPut, ]
+
+        def _requestHandler(*args, **kwargs):
+            handler = requests.pop(0)
+            return handler(*args, **kwargs)
+        self.client._request = _requestHandler
+        yield self.client.addInvite('/mumble/frotz.ics', vcalendar)
+
+
+    def test_deleteEvent(self):
+        """
+        L{OS_X_10_6.deleteEvent} DELETEs the event at the relative
+        URL passed to it and updates local state to reflect its
+        removal.
+        """
+        requests = self.interceptRequests()
+
+        calendar = Calendar(caldavxml.calendar, set(('VEVENT',)), u'calendar', u'/foo/', None)
+        event = Event(None, calendar.url + u'bar.ics', None)
+        self.client._calendars[calendar.url] = calendar
+        self.client._setEvent(event.url, event)
+
+        d = self.client.deleteEvent(event.url)
+
+        result, req = requests.pop()
+
+        expectedResponseCode, method, url = req
+
+        self.assertEqual(expectedResponseCode, NO_CONTENT)
+        self.assertEqual(method, 'DELETE')
+        self.assertEqual(url, 'http://127.0.0.1' + event.url)
+        self.assertIsInstance(url, str)
+
+        self.assertNotIn(event.url, self.client._events)
+        self.assertNotIn(u'bar.ics', calendar.events)
+
+        response = MemoryResponse(
+            ('HTTP', '1', '1'), NO_CONTENT, "No Content", None,
+            StringProducer(""))
+        result.callback(response)
+        return d
+
+
+    def test_serialization(self):
+        """
+        L{OS_X_10_6.serialize} properly generates a JSON document.
+        """
+        clientPath = os.path.join(self.client.serializePath, "user91-OS_X_10.6")
+        self.assertFalse(os.path.exists(clientPath))
+        indexPath = os.path.join(clientPath, "index.json")
+        self.assertFalse(os.path.exists(indexPath))
+
+        cal1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:004f8e41-b071-4b30-bb3b-6aada4adcc10
+DTSTART:20120817T113000
+DTEND:20120817T114500
+DTSTAMP:20120815T154420Z
+SEQUENCE:2
+SUMMARY:Simple event
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n")
+        cal2 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:00a79cad-857b-418e-a54a-340b5686d747
+DTSTART:20120817T113000
+DTEND:20120817T114500
+DTSTAMP:20120815T154420Z
+SEQUENCE:2
+SUMMARY:Simple event
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n")
+        events = (
+            Event(self.client.serializeLocation(), u'/home/calendar/1.ics', u'123.123', Component.fromString(cal1)),
+            Event(self.client.serializeLocation(), u'/home/inbox/i1.ics', u'123.123', Component.fromString(cal2)),
+        )
+        self.client._events.update(dict([[event.url, event] for event in events]))
+
+        calendars = (
+            Calendar(str(caldavxml.calendar), set(('VEVENT',)), u'calendar', u'/home/calendar/', "123"),
+            Calendar(str(caldavxml.calendar), set(('VTODO',)), u'tasks', u'/home/tasks/', "456"),
+            Calendar(str(caldavxml.schedule_inbox), set(('VEVENT', "VTODO",)), u'calendar', u'/home/inbox/', "789"),
+        )
+        self.client._calendars.update(dict([[calendar.url, calendar] for calendar in calendars]))
+        self.client._calendars["/home/calendar/"].events["1.ics"] = events[0]
+        self.client._calendars["/home/inbox/"].events["i1.ics"] = events[1]
+
+        self.client.serialize()
+        self.assertTrue(os.path.exists(clientPath))
+        self.assertTrue(os.path.exists(indexPath))
+        def _normDict(d):
+            return dict([(k, sorted(v, key=lambda x: x["changeToken" if k == "calendars" else "url"]) if v else None,) for k, v in d.items()])
+        self.assertEqual(_normDict(json.loads(open(indexPath).read())), _normDict(json.loads("""{
+  "calendars": [
+    {
+      "changeToken": "123",
+      "name": "calendar",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}calendar",
+      "componentTypes": [
+        "VEVENT"
+      ],
+      "url": "/home/calendar/",
+      "events": [
+        "1.ics"
+      ]
+    },
+    {
+      "changeToken": "789",
+      "name": "calendar",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}schedule-inbox",
+      "componentTypes": [
+        "VEVENT",
+        "VTODO"
+      ],
+      "url": "/home/inbox/",
+      "events": [
+        "i1.ics"
+      ]
+    },
+    {
+      "changeToken": "456",
+      "name": "tasks",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}calendar",
+      "componentTypes": [
+        "VTODO"
+      ],
+      "url": "/home/tasks/",
+      "events": []
+    }
+  ],
+  "principalURL": null,
+  "events": [
+    {
+      "url": "/home/calendar/1.ics",
+      "scheduleTag": null,
+      "etag": "123.123",
+      "uid": "004f8e41-b071-4b30-bb3b-6aada4adcc10"
+    },
+    {
+      "url": "/home/inbox/i1.ics",
+      "scheduleTag": null,
+      "etag": "123.123",
+      "uid": "00a79cad-857b-418e-a54a-340b5686d747"
+    }
+  ]
+}""")))
+
+        event1Path = os.path.join(clientPath, "calendar", "1.ics")
+        self.assertTrue(os.path.exists(event1Path))
+        self.assertEqual(open(event1Path).read(), cal1)
+
+        event2Path = os.path.join(clientPath, "inbox", "i1.ics")
+        self.assertTrue(os.path.exists(event2Path))
+        self.assertEqual(open(event2Path).read(), cal2)
+
+
+    def test_deserialization(self):
+        """
+        L{OS_X_10_6.deserailize} properly parses a JSON document.
+        """
+
+        cal1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:004f8e41-b071-4b30-bb3b-6aada4adcc10
+DTSTART:20120817T113000
+DTEND:20120817T114500
+DTSTAMP:20120815T154420Z
+SEQUENCE:2
+SUMMARY:Simple event
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n")
+        cal2 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:00a79cad-857b-418e-a54a-340b5686d747
+DTSTART:20120817T113000
+DTEND:20120817T114500
+DTSTAMP:20120815T154420Z
+SEQUENCE:2
+SUMMARY:Simple event
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n")
+
+        clientPath = os.path.join(self.client.serializePath, "user91-OS_X_10.6")
+        os.mkdir(clientPath)
+        indexPath = os.path.join(clientPath, "index.json")
+        open(indexPath, "w").write("""{
+  "calendars": [
+    {
+      "changeToken": "321",
+      "name": "calendar",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}calendar",
+      "componentTypes": [
+        "VEVENT"
+      ],
+      "url": "/home/calendar/",
+      "events": [
+        "2.ics"
+      ]
+    },
+    {
+      "changeToken": "987",
+      "name": "calendar",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}schedule-inbox",
+      "componentTypes": [
+        "VEVENT",
+        "VTODO"
+      ],
+      "url": "/home/inbox/",
+      "events": [
+        "i2.ics"
+      ]
+    },
+    {
+      "changeToken": "654",
+      "name": "tasks",
+      "resourceType": "{urn:ietf:params:xml:ns:caldav}calendar",
+      "componentTypes": [
+        "VTODO"
+      ],
+      "url": "/home/tasks/",
+      "events": []
+    }
+  ],
+  "principalURL": null,
+  "events": [
+    {
+      "url": "/home/calendar/2.ics",
+      "scheduleTag": null,
+      "etag": "321.321",
+      "uid": "004f8e41-b071-4b30-bb3b-6aada4adcc10"
+    },
+    {
+      "url": "/home/inbox/i2.ics",
+      "scheduleTag": null,
+      "etag": "987.987",
+      "uid": "00a79cad-857b-418e-a54a-340b5686d747"
+    }
+  ]
+}""")
+
+        os.mkdir(os.path.join(clientPath, "calendar"))
+        event1Path = os.path.join(clientPath, "calendar", "2.ics")
+        open(event1Path, "w").write(cal1)
+        os.mkdir(os.path.join(clientPath, "inbox"))
+        event1Path = os.path.join(clientPath, "inbox", "i2.ics")
+        open(event1Path, "w").write(cal2)
+
+        self.client.deserialize()
+
+        self.assertEqual(len(self.client._calendars), 3)
+        self.assertTrue("/home/calendar/" in self.client._calendars)
+        self.assertEqual(self.client._calendars["/home/calendar/"].changeToken, "321")
+        self.assertEqual(self.client._calendars["/home/calendar/"].name, "calendar")
+        self.assertEqual(self.client._calendars["/home/calendar/"].resourceType, "{urn:ietf:params:xml:ns:caldav}calendar")
+        self.assertEqual(self.client._calendars["/home/calendar/"].componentTypes, set(("VEVENT",)))
+        self.assertTrue("/home/tasks/" in self.client._calendars)
+        self.assertTrue("/home/inbox/" in self.client._calendars)
+        self.assertEqual(self.client._calendars["/home/inbox/"].componentTypes, set(("VEVENT", "VTODO",)))
+        self.assertEqual(len(self.client._events), 2)
+        self.assertTrue("/home/calendar/2.ics" in self.client._events)
+        self.assertEqual(self.client._events["/home/calendar/2.ics"].scheduleTag, None)
+        self.assertEqual(self.client._events["/home/calendar/2.ics"].etag, "321.321")
+        self.assertEqual(self.client._events["/home/calendar/2.ics"].getUID(), "004f8e41-b071-4b30-bb3b-6aada4adcc10")
+        self.assertEqual(str(self.client._events["/home/calendar/2.ics"].component), cal1)
+        self.assertTrue("/home/inbox/i2.ics" in self.client._events)
+        self.assertEqual(self.client._events["/home/inbox/i2.ics"].scheduleTag, None)
+        self.assertEqual(self.client._events["/home/inbox/i2.ics"].etag, "987.987")
+        self.assertEqual(self.client._events["/home/inbox/i2.ics"].getUID(), "00a79cad-857b-418e-a54a-340b5686d747")
+        self.assertEqual(str(self.client._events["/home/inbox/i2.ics"].component), cal2)
+
+
+
+class UpdateCalendarTests(OS_X_10_6Mixin, TestCase):
+    """
+    Tests for L{OS_X_10_6._updateCalendar}.
+    """
+
+    _CALENDAR_PROPFIND_RESPONSE_BODY = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/something/anotherthing.ics</href>
+    <propstat>
+      <prop>
+        <resourcetype>
+          <collection/>
+        </resourcetype>
+        <getetag>"None"</getetag>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+    <propstat>
+      <prop>
+      </prop>
+      <status>HTTP/1.1 404 Not Found</status>
+    </propstat>
+  </response>
+  <response>
+    <href>/something/else.ics</href>
+    <propstat>
+      <prop>
+        <resourcetype>
+          <collection/>
+        </resourcetype>
+        <getetag>"None"</getetag>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+   </response>
+</multistatus>
+"""
+    _CALENDAR_REPORT_RESPONSE_BODY = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/something/anotherthing.ics</href>
+    <status>HTTP/1.1 404 Not Found</status>
+  </response>
+  <response>
+    <href>/something/else.ics</href>
+    <propstat>
+      <prop>
+        <getetag>"ef70beb4cb7da4b2e2950350b09e9a01"</getetag>
+        <calendar-data xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:CD54161A13AA8A4649D3781E at caldav.corp.apple.com
+DTSTART:20110715T140000Z
+DURATION:PT1H
+DTSTAMP:20110715T144217Z
+SUMMARY:Test2
+END:VEVENT
+END:VCALENDAR
+]]></calendar-data>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+  </response>
+</multistatus>
+"""
+
+    _CALENDAR_REPORT_RESPONSE_BODY_1 = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/something/anotherthing.ics</href>
+    <propstat>
+      <prop>
+        <getetag>"ef70beb4cb7da4b2e2950350b09e9a01"</getetag>
+        <calendar-data xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:anotherthing at caldav.corp.apple.com
+DTSTART:20110715T140000Z
+DURATION:PT1H
+DTSTAMP:20110715T144217Z
+SUMMARY:Test1
+END:VEVENT
+END:VCALENDAR
+]]></calendar-data>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+  </response>
+</multistatus>
+"""
+
+    _CALENDAR_REPORT_RESPONSE_BODY_2 = """\
+<?xml version='1.0' encoding='UTF-8'?>
+<multistatus xmlns='DAV:'>
+  <response>
+    <href>/something/else.ics</href>
+    <propstat>
+      <prop>
+        <getetag>"ef70beb4cb7da4b2e2950350b09e9a01"</getetag>
+        <calendar-data xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VEVENT
+UID:else at caldav.corp.apple.com
+DTSTART:20110715T140000Z
+DURATION:PT1H
+DTSTAMP:20110715T144217Z
+SUMMARY:Test2
+END:VEVENT
+END:VCALENDAR
+]]></calendar-data>
+      </prop>
+      <status>HTTP/1.1 200 OK</status>
+    </propstat>
+  </response>
+</multistatus>
+"""
+
+    def test_eventMissing(self):
+        """
+        If an event included in the calendar PROPFIND response no longer exists
+        by the time a REPORT is issued for that event, the 404 is handled and
+        the rest of the normal update logic for that event is skipped.
+        """
+        requests = self.interceptRequests()
+
+        calendar = Calendar(None, set(('VEVENT',)), 'calendar', '/something/', None)
+        self.client._calendars[calendar.url] = calendar
+        self.client._updateCalendar(calendar, "1234")
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, _ignore_headers, _ignore_body = req
+        self.assertEqual('PROPFIND', method)
+        self.assertEqual('http://127.0.0.1/something/', url)
+        self.assertEqual((MULTI_STATUS,), expectedResponseCode)
+
+        result.callback(
+            MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "Multi-status", None,
+                StringProducer(self._CALENDAR_PROPFIND_RESPONSE_BODY)))
+
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, _ignore_headers, _ignore_body = req
+        self.assertEqual('REPORT', method)
+        self.assertEqual('http://127.0.0.1/something/', url)
+        self.assertEqual((MULTI_STATUS,), expectedResponseCode)
+
+        # Someone else comes along and gets rid of the event
+        del self.client._events["/something/anotherthing.ics"]
+
+        result.callback(
+            MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "Multi-status", None,
+                StringProducer(self._CALENDAR_REPORT_RESPONSE_BODY)))
+
+        # Verify that processing proceeded to the response after the one with a
+        # 404 status.
+        self.assertIn('/something/else.ics', self.client._events)
+
+
+    def test_multigetBatch(self):
+        """
+        If an event included in the calendar PROPFIND response no longer exists
+        by the time a REPORT is issued for that event, the 404 is handled and
+        the rest of the normal update logic for that event is skipped.
+        """
+        requests = self.interceptRequests()
+
+        self.patch(self.client, "MULTIGET_BATCH_SIZE", 1)
+
+        calendar = Calendar(None, set(('VEVENT',)), 'calendar', '/something/', None)
+        self.client._calendars[calendar.url] = calendar
+        self.client._updateCalendar(calendar, "1234")
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, _ignore_headers, _ignore_body = req
+        self.assertEqual('PROPFIND', method)
+        self.assertEqual('http://127.0.0.1/something/', url)
+        self.assertEqual((MULTI_STATUS,), expectedResponseCode)
+
+        result.callback(
+            MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "Multi-status", None,
+                StringProducer(self._CALENDAR_PROPFIND_RESPONSE_BODY)))
+
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, _ignore_headers, _ignore_body = req
+        self.assertEqual('REPORT', method)
+        self.assertEqual('http://127.0.0.1/something/', url)
+        self.assertEqual((MULTI_STATUS,), expectedResponseCode)
+
+        result.callback(
+            MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "Multi-status", None,
+                StringProducer(self._CALENDAR_REPORT_RESPONSE_BODY_1)))
+
+        self.assertTrue(self.client._events['/something/anotherthing.ics'].etag is not None)
+        self.assertTrue(self.client._events['/something/else.ics'].etag is None)
+
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, _ignore_headers, _ignore_body = req
+        self.assertEqual('REPORT', method)
+        self.assertEqual('http://127.0.0.1/something/', url)
+        self.assertEqual((MULTI_STATUS,), expectedResponseCode)
+
+        result.callback(
+            MemoryResponse(
+                ('HTTP', '1', '1'), MULTI_STATUS, "Multi-status", None,
+                StringProducer(self._CALENDAR_REPORT_RESPONSE_BODY_2)))
+
+        self.assertTrue(self.client._events['/something/anotherthing.ics'].etag is not None)
+        self.assertTrue(self.client._events['/something/else.ics'].etag is not None)
+
+
+
+class VFreeBusyTests(OS_X_10_6Mixin, TestCase):
+    """
+    Tests for L{OS_X_10_6.requestAvailability}.
+    """
+    def test_requestAvailability(self):
+        """
+        L{OS_X_10_6.requestAvailability} accepts a date range and a set of
+        account uuids and issues a VFREEBUSY request.  It returns a Deferred
+        which fires with a dict mapping account uuids to availability range
+        information.
+        """
+        self.client.uuid = u'urn:uuid:user01'
+        self.client.email = u'mailto:user01 at example.com'
+        self.client.outbox = "/calendars/__uids__/%s/outbox/" % (self.record.uid,)
+        requests = self.interceptRequests()
+
+        start = DateTime(2011, 6, 10, 10, 45, 0, tzid=Timezone.UTCTimezone)
+        end = DateTime(2011, 6, 10, 11, 15, 0, tzid=Timezone.UTCTimezone)
+        d = self.client.requestAvailability(
+            start, end, [u"urn:uuid:user05", u"urn:uuid:user10"])
+
+        result, req = requests.pop(0)
+        expectedResponseCode, method, url, headers, body = req
+
+        self.assertEqual(OK, expectedResponseCode)
+        self.assertEqual('POST', method)
+        self.assertEqual(
+            'http://127.0.0.1/calendars/__uids__/%s/outbox/' % (self.record.uid,),
+            url)
+
+        self.assertEqual(headers.getRawHeaders('originator'), ['mailto:user01 at example.com'])
+        self.assertEqual(headers.getRawHeaders('recipient'), ['urn:uuid:user05, urn:uuid:user10'])
+        self.assertEqual(headers.getRawHeaders('content-type'), ['text/calendar'])
+
+        consumer = MemoryConsumer()
+        finished = body.startProducing(consumer)
+        def cbFinished(ignored):
+            vevent = Component.fromString(consumer.value())
+            uid = vevent.resourceUID()
+            dtstamp = vevent.mainComponent().propertyValue("DTSTAMP")
+            dtstamp = dtstamp.getText()
+            self.assertEqual("""BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+VERSION:2.0
+METHOD:REQUEST
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+BEGIN:VFREEBUSY
+UID:%(uid)s
+DTEND:20110611T000000Z
+ATTENDEE:urn:uuid:user05
+ATTENDEE:urn:uuid:user10
+DTSTART:20110610T000000Z
+DTSTAMP:%(dtstamp)s
+ORGANIZER:mailto:user01 at example.com
+SUMMARY:Availability for urn:uuid:user05, urn:uuid:user10
+END:VFREEBUSY
+END:VCALENDAR
+""".replace('\n', '\r\n') % {'uid': uid, 'dtstamp': dtstamp}, consumer.value())
+
+        finished.addCallback(cbFinished)
+
+        def requested(ignored):
+            response = MemoryResponse(
+                ('HTTP', '1', '1'), OK, "Ok", Headers({}),
+                StringProducer(""))
+            result.callback(response)
+        finished.addCallback(requested)
+
+        return d

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_logger.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_logger.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_logger.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,447 @@
+##
+# Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+from StringIO import StringIO
+
+from twisted.trial.unittest import TestCase
+
+from contrib.performance.loadtest.logger import ReportStatistics, OperationLogger
+
+class ReportStatisticsTests(TestCase):
+    """
+    Tests for L{loadtest.population.ReportStatistics}.
+    """
+    def test_countUsers(self):
+        """
+        L{ReportStatistics.countUsers} returns the number of users observed to
+        have acted in the simulation.
+        """
+        logger = ReportStatistics()
+        users = ['user01', 'user02', 'user03']
+        for user in users:
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=1.23, user=user, client_type="test", client_id="1234"
+            ))
+        self.assertEqual(len(users), logger.countUsers())
+
+
+    def test_countClients(self):
+        """
+        L{ReportStatistics.countClients} returns the number of clients observed to
+        have acted in the simulation.
+        """
+        logger = ReportStatistics()
+        clients = ['c01', 'c02', 'c03']
+        for client in clients:
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=1.23, user="user01", client_type="test", client_id=client
+            ))
+        self.assertEqual(len(clients), logger.countClients())
+
+
+    def test_clientFailures(self):
+        """
+        L{ReportStatistics.countClientFailures} returns the number of clients observed to
+        have failed in the simulation.
+        """
+        logger = ReportStatistics()
+        clients = ['c01', 'c02', 'c03']
+        for client in clients:
+            logger.observe(dict(
+                type='client-failure', reason="testing %s" % (client,)
+            ))
+        self.assertEqual(len(clients), logger.countClientFailures())
+
+
+    def test_simFailures(self):
+        """
+        L{ReportStatistics.countSimFailures} returns the number of clients observed to
+        have caused an error in the simulation.
+        """
+        logger = ReportStatistics()
+        clients = ['c01', 'c02', 'c03']
+        for client in clients:
+            logger.observe(dict(
+                type='sim-failure', reason="testing %s" % (client,)
+            ))
+        self.assertEqual(len(clients), logger.countSimFailures())
+
+
+    def test_noFailures(self):
+        """
+        If fewer than 1% of requests fail, fewer than 1% of requests take 5
+        seconds or more, and fewer than 5% of requests take 3 seconds or more,
+        L{ReportStatistics.failures} returns an empty list.
+        """
+        logger = ReportStatistics()
+        logger.observe(dict(
+            type='response', method='GET', success=True,
+            duration=2.5, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual([], logger.failures())
+
+
+    def test_requestFailures(self):
+        """
+        If more than 1% of requests fail, L{ReportStatistics.failures} returns a
+        list containing a string describing this.
+        """
+        logger = ReportStatistics()
+        for _ignore in range(98):
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=2.5, user='user01', client_type="test", client_id="1234"
+            ))
+        logger.observe(dict(
+            type='response', method='GET', success=False,
+            duration=2.5, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 1% GET failed"],
+            logger.failures())
+
+
+    def test_threeSecondFailure(self):
+        """
+        If more than 5% of requests take longer than 3 seconds,
+        L{ReportStatistics.failures} returns a list containing a string
+        describing that.
+        """
+        logger = ReportStatistics()
+        for _ignore in range(94):
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=2.5, user='user01', client_type="test", client_id="1234"
+            ))
+        for _ignore in range(5):
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=3.5, user='user02', client_type="test", client_id="1234"
+            ))
+        self.assertEqual(
+            ["Greater than 5% GET exceeded 3 second response time"],
+            logger.failures())
+
+
+    def test_fiveSecondFailure(self):
+        """
+        If more than 1% of requests take longer than 5 seconds,
+        L{ReportStatistics.failures} returns a list containing a string
+        describing that.
+        """
+        logger = ReportStatistics()
+        for _ignore in range(98):
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=2.5, user='user01', client_type="test", client_id="1234"
+            ))
+        logger.observe(dict(
+            type='response', method='GET', success=True,
+            duration=5.5, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 1% GET exceeded 5 second response time"],
+            logger.failures())
+
+
+    def test_methodsCountedSeparately(self):
+        """
+        The counts for one method do not affect the results of another method.
+        """
+        logger = ReportStatistics()
+        for _ignore in range(99):
+            logger.observe(dict(
+                type='response', method='GET', success=True,
+                duration=2.5, user='user01', client_type="test", client_id="1234"
+            ))
+            logger.observe(dict(
+                type='response', method='POST', success=True,
+                duration=2.5, user='user01', client_type="test", client_id="1234"
+            ))
+
+        logger.observe(dict(
+            type='response', method='GET', success=False,
+            duration=2.5, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='POST', success=False,
+            duration=2.5, user='user01', client_type="test", client_id="1234"
+        ))
+
+        self.assertEqual([], logger.failures())
+
+
+    def test_bucketRequest(self):
+        """
+        PUT(xxx-huge/large/medium/small} have different thresholds. Test that requests straddling
+        each of those are correctly determined to be failures or not.
+        """
+
+        _thresholds = {
+            "requests": {
+                "limits": [0.1, 0.5, 1.0, 3.0, 5.0, 10.0, 30.0],
+                "thresholds": {
+                    "default": [100.0, 100.0, 100.0, 5.0, 1.0, 0.5, 0.0],
+                    "PUT{organizer-small}": [100.0, 50.0, 25.0, 5.0, 1.0, 0.5, 0.0],
+                    "PUT{organizer-medium}": [100.0, 100.0, 50.0, 25.0, 5.0, 1.0, 0.5],
+                    "PUT{organizer-large}": [100.0, 100.0, 100.0, 50.0, 25.0, 5.0, 1.0],
+                    "PUT{organizer-huge}": [100.0, 100.0, 100.0, 100.0, 100.0, 50.0, 25.0],
+                }
+            }
+        }
+
+        # -small below threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual([], logger.failures())
+
+        # -small above 0.5 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-small}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 50% PUT{organizer-small} exceeded 0.5 second response time"],
+            logger.failures()
+        )
+
+        # -medium below 0.5 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=0.6, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            [],
+            logger.failures()
+        )
+
+        # -medium above 1.0 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-medium}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 50% PUT{organizer-medium} exceeded 1 second response time"],
+            logger.failures()
+        )
+
+        # -large below 1.0 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=1.6, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            [],
+            logger.failures()
+        )
+
+        # -large above 3.0 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=0.2, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=3.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=3.6, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-large}', success=True,
+            duration=3.6, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 50% PUT{organizer-large} exceeded 3 second response time"],
+            logger.failures()
+        )
+
+        # -huge below 10.0 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=12.0, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=8, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=11.0, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=9.0, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            [],
+            logger.failures()
+        )
+
+        # -huge above 10.0 threshold
+        logger = ReportStatistics(thresholds=_thresholds)
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=12.0, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=9.0, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=12.0, user='user01', client_type="test", client_id="1234"
+        ))
+        logger.observe(dict(
+            type='response', method='PUT{organizer-huge}', success=True,
+            duration=42.42, user='user01', client_type="test", client_id="1234"
+        ))
+        self.assertEqual(
+            ["Greater than 50% PUT{organizer-huge} exceeded 10 second response time"],
+            logger.failures()
+        )
+
+
+
+class OperationLoggerTests(TestCase):
+    """
+    Tests for L{OperationLogger}.
+    """
+    def test_noFailures(self):
+        """
+        If the median lag is below 1 second and the failure rate is below 1%,
+        L{OperationLogger.failures} returns an empty list.
+        """
+        logger = OperationLogger(outfile=StringIO())
+        logger.observe(dict(
+            type='operation', phase='start', user='user01',
+            label='testing', lag=0.5)
+        )
+        logger.observe(dict(
+            type='operation', phase='end', user='user01',
+            duration=0.35, label='testing', success=True)
+        )
+        self.assertEqual([], logger.failures())
+
+
+    def test_lagLimitExceeded(self):
+        """
+        If the median scheduling lag for any operation in the simulation
+        exceeds 1 second, L{OperationLogger.failures} returns a list containing
+        a string describing that issue.
+        """
+        logger = OperationLogger(outfile=StringIO())
+        for lag in [100.0, 1100.0, 1200.0]:
+            logger.observe(dict(
+                type='operation', phase='start', user='user01',
+                label='testing', lag=lag)
+            )
+        self.assertEqual(
+            ["Median TESTING scheduling lag greater than 1000.0ms"],
+            logger.failures())
+
+
+    def test_failureLimitExceeded(self):
+        """
+        If the failure rate for any operation exceeds 1%,
+        L{OperationLogger.failures} returns a list containing a string
+        describing that issue.
+        """
+        logger = OperationLogger(outfile=StringIO())
+        for _ignore in range(98):
+            logger.observe(dict(
+                type='operation', phase='end', user='user01',
+                duration=0.25, label='testing', success=True)
+            )
+        logger.observe(dict(
+            type='operation', phase='end', user='user01',
+            duration=0.25, label='testing', success=False)
+        )
+        self.assertEqual(
+            ["Greater than 1% TESTING failed"],
+            logger.failures())

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_population.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_population.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_population.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,21 @@
+##
+# Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+"""
+Tests for some things in L{loadtest.population}.
+"""
+

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_profiles.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_profiles.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_profiles.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,1029 @@
+##
+# Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+"""
+Tests for loadtest.profiles.
+"""
+
+from caldavclientlibrary.protocol.caldav.definitions import caldavxml, csxml
+
+from twisted.trial.unittest import TestCase
+from twisted.internet.task import Clock
+from twisted.internet.defer import succeed, fail
+from twisted.web.http import NO_CONTENT, PRECONDITION_FAILED
+from twisted.web.client import Response
+
+from twistedcaldav.ical import Component, Property
+
+from contrib.performance.loadtest.profiles import Eventer, Inviter, Accepter
+from contrib.performance.loadtest.profiles import RealisticInviter
+from contrib.performance.loadtest.population import Populator, CalendarClientSimulator
+from contrib.performance.loadtest.ical import IncorrectResponseCode, Calendar, Event, BaseClient
+from contrib.performance.loadtest.sim import _DirectoryRecord
+
+import os
+
+SIMPLE_EVENT = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.3//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:DAYLIGHT
+TZOFFSETFROM:-0500
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+DTSTART:20070311T020000
+TZNAME:EDT
+TZOFFSETTO:-0400
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:-0400
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+DTSTART:20071104T020000
+TZNAME:EST
+TZOFFSETTO:-0500
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+CREATED:20101018T155431Z
+UID:C98AD237-55AD-4F7D-9009-0D355D835822
+DTEND;TZID=America/New_York:20101021T130000
+TRANSP:OPAQUE
+SUMMARY:Simple event
+DTSTART;TZID=America/New_York:20101021T120000
+DTSTAMP:20101018T155438Z
+SEQUENCE:2
+END:VEVENT
+END:VCALENDAR
+"""
+
+INVITED_EVENT = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:882C3D50-0DAE-45CB-A2E7-DA75DA9BE452
+DTSTART;TZID=America/New_York:20110131T130000
+DTEND;TZID=America/New_York:20110131T140000
+ATTENDEE;CN=User 01;CUTYPE=INDIVIDUAL;EMAIL=user01 at example.com;PARTSTAT=AC
+ CEPTED:urn:uuid:user01
+ATTENDEE;CN=User 02;CUTYPE=INDIVIDUAL;EMAIL=user02 at example.com;PARTSTAT=NE
+ EDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:urn:uuid:user02
+CREATED:20110124T170357Z
+DTSTAMP:20110124T170425Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:user01
+SEQUENCE:3
+SUMMARY:Some Event For You
+TRANSP:TRANSPARENT
+X-APPLE-NEEDS-REPLY:TRUE
+END:VEVENT
+END:VCALENDAR
+"""
+
+ACCEPTED_EVENT = """\
+BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZNAME:EST
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZNAME:EDT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:882C3D50-0DAE-45CB-A2E7-DA75DA9BE452
+DTSTART;TZID=America/New_York:20110131T130000
+DTEND;TZID=America/New_York:20110131T140000
+ATTENDEE;CN=User 01;CUTYPE=INDIVIDUAL;EMAIL=user01 at example.com;PARTSTAT=AC
+ CEPTED:urn:uuid:user01
+ATTENDEE;CN=User 02;CUTYPE=INDIVIDUAL;EMAIL=user02 at example.com;PARTSTAT=AC
+ CEPTED:urn:uuid:user02
+CREATED:20110124T170357Z
+DTSTAMP:20110124T170425Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:user01
+SEQUENCE:3
+SUMMARY:Some Event For You
+TRANSP:TRANSPARENT
+X-APPLE-NEEDS-REPLY:TRUE
+END:VEVENT
+END:VCALENDAR
+"""
+
+INBOX_REPLY = """\
+BEGIN:VCALENDAR
+METHOD:REPLY
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:12345-67890
+ORGANIZER;CN="User 01":mailto:user1 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user1 at example.com
+END:VEVENT
+END:VCALENDAR
+"""
+
+
+
+class AnyUser(object):
+    def __getitem__(self, index):
+        return _AnyRecord(index)
+
+
+
+class _AnyRecord(object):
+    def __init__(self, index):
+        self.uid = u"user%02d" % (index,)
+        self.password = u"user%02d" % (index,)
+        self.commonName = u"User %02d" % (index,)
+        self.email = u"user%02d at example.com" % (index,)
+        self.guid = u"user%02d" % (index,)
+
+
+
+class Deterministic(object):
+    def __init__(self, value=None):
+        self.value = value
+
+
+    def gauss(self, mean, stddev):
+        """
+        Pretend to return a value from a gaussian distribution with mu
+        parameter C{mean} and sigma parameter C{stddev}.  But actually
+        always return C{mean + 1}.
+        """
+        return mean + 1
+
+
+    def choice(self, sequence):
+        return sequence[0]
+
+
+    def sample(self):
+        return self.value
+
+
+
+class StubClient(BaseClient):
+    """
+    Stand in for an iCalendar client.
+
+    @ivar rescheduled: A set of event URLs which will not allow
+        attendee changes due to a changed schedule tag.
+    @ivar _pendingFailures: dict mapping URLs to failure objects
+    """
+    def __init__(self, number, serializePath):
+        self.serializePath = serializePath
+        os.mkdir(self.serializePath)
+        self.title = "StubClient"
+        self._events = {}
+        self._calendars = {}
+        self._pendingFailures = {}
+        self.record = _DirectoryRecord(
+            "user%02d" % (number,), "user%02d" % (number,),
+            "User %02d" % (number,), "user%02d at example.org" % (number,),
+            "user%02d" % (number,))
+        self.email = "mailto:user%02d at example.com" % (number,)
+        self.uuid = "urn:uuid:user%02d" % (number,)
+        self.rescheduled = set()
+        self.started = True
+
+
+    def _failDeleteWithObject(self, href, failureObject):
+        """
+        Accessor for inserting intentional failures for deletes.
+        """
+        self._pendingFailures[href] = failureObject
+
+
+    def serializeLocation(self):
+        """
+        Return the path to the directory where data for this user is serialized.
+        """
+        if self.serializePath is None or not os.path.isdir(self.serializePath):
+            return None
+
+        key = "%s-%s" % (self.record.uid, "StubClient")
+        path = os.path.join(self.serializePath, key)
+        if not os.path.exists(path):
+            os.mkdir(path)
+        elif not os.path.isdir(path):
+            return None
+
+        return path
+
+
+    def addEvent(self, href, vevent):
+        self._events[href] = Event(self.serializePath, href, None, vevent)
+        return succeed(None)
+
+
+    def addInvite(self, href, vevent):
+        return self.addEvent(href, vevent)
+
+
+    def deleteEvent(self, href,):
+        del self._events[href]
+        calendar, uid = href.rsplit('/', 1)
+        del self._calendars[calendar + '/'].events[uid]
+        if href in self._pendingFailures:
+            failureObject = self._pendingFailures.pop(href)
+            return fail(failureObject)
+        else:
+            return succeed(None)
+
+
+    def updateEvent(self, href):
+        self.rescheduled.remove(href)
+        return succeed(None)
+
+
+    def addEventAttendee(self, href, attendee):
+        vevent = self._events[href].component
+        vevent.mainComponent().addProperty(attendee)
+        self._events[href].component = vevent
+
+
+    def changeEventAttendee(self, href, old, new):
+        if href in self.rescheduled:
+            return fail(IncorrectResponseCode(
+                NO_CONTENT,
+                Response(
+                    ('HTTP', 1, 1), PRECONDITION_FAILED,
+                    'Precondition Failed', None, None))
+            )
+
+        vevent = self._events[href].component
+        vevent.mainComponent().removeProperty(old)
+        vevent.mainComponent().addProperty(new)
+        self._events[href].component = vevent
+        return succeed(None)
+
+
+    def _makeSelfAttendee(self):
+        attendee = Property(
+            name=u'ATTENDEE',
+            value=self.email,
+            params={
+                'CN': self.record.commonName,
+                'CUTYPE': 'INDIVIDUAL',
+                'PARTSTAT': 'ACCEPTED',
+            },
+        )
+        return attendee
+
+
+    def _makeSelfOrganizer(self):
+        organizer = Property(
+            name=u'ORGANIZER',
+            value=self.email,
+            params={
+                'CN': self.record.commonName,
+            },
+        )
+        return organizer
+
+
+
+class SequentialDistribution(object):
+    def __init__(self, values):
+        self.values = values
+
+
+    def sample(self):
+        return self.values.pop(0)
+
+
+
+class InviterTests(TestCase):
+    """
+    Tests for loadtest.profiles.Inviter.
+    """
+    def setUp(self):
+        self.sim = CalendarClientSimulator(
+            AnyUser(), Populator(None), None, None, None, None, None)
+
+
+    def _simpleAccount(self, userNumber, eventText):
+        client = StubClient(userNumber, self.mktemp())
+
+        vevent = Component.fromString(eventText)
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', u'/cal/', None)
+        client._calendars.update({calendar.url: calendar})
+
+        event = Event(client.serializeLocation(), calendar.url + u'1234.ics', None, vevent)
+
+        client._events.update({event.url: event})
+        calendar.events = {u'1234.ics': event}
+
+        return vevent, event, calendar, client
+
+
+    def test_enabled(self):
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+
+        inviter = Inviter(None, self.sim, client, userNumber, **{"enabled": False})
+        self.assertEqual(inviter.enabled, False)
+
+        inviter = Inviter(None, self.sim, client, userNumber, **{"enabled": True})
+        self.assertEqual(inviter.enabled, True)
+
+
+    def test_doNotAddAttendeeToInbox(self):
+        """
+        When the only calendar with any events is a schedule inbox, no
+        attempt is made to add attendees to an event on that calendar.
+        """
+        userNumber = 10
+        vevent, _ignore_event, calendar, client = self._simpleAccount(
+            userNumber, SIMPLE_EVENT)
+        calendar.resourceType = caldavxml.schedule_inbox
+        inviter = Inviter(None, self.sim, client, userNumber)
+        inviter._invite()
+        self.assertFalse(vevent.mainComponent().hasProperty('ATTENDEE'))
+
+
+    def test_doNotAddAttendeeToNoCalendars(self):
+        """
+        When there are no calendars and no events at all, the inviter
+        does nothing.
+        """
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+        inviter = Inviter(None, self.sim, client, userNumber)
+        inviter._invite()
+        self.assertEquals(client._events, {})
+        self.assertEquals(client._calendars, {})
+
+
+    def test_doNotAddAttendeeToUninitializedEvent(self):
+        """
+        When there is an L{Event} on a calendar but the details of the
+        event have not yet been retrieved, no attempt is made to add
+        invitees to that event.
+        """
+        userNumber = 19
+        _ignore_vevent, event, calendar, client = self._simpleAccount(
+            userNumber, SIMPLE_EVENT)
+        event.component = event.etag = event.scheduleTag = None
+        inviter = Inviter(None, self.sim, client, userNumber)
+        inviter._invite()
+        self.assertEquals(client._events, {event.url: event})
+        self.assertEquals(client._calendars, {calendar.url: calendar})
+
+
+    def test_addAttendeeToEvent(self):
+        """
+        When there is a normal calendar with an event, inviter adds an
+        attendee to it.
+        """
+        userNumber = 16
+        _ignore_vevent, event, _ignore_calendar, client = self._simpleAccount(
+            userNumber, SIMPLE_EVENT)
+        inviter = Inviter(Clock(), self.sim, client, userNumber)
+        inviter.setParameters(inviteeDistribution=Deterministic(1))
+        inviter._invite()
+        attendees = tuple(event.component.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 1)
+        for paramname, paramvalue in {
+            'CN': 'User %d' % (userNumber + 1,),
+            'CUTYPE': 'INDIVIDUAL',
+            'PARTSTAT': 'NEEDS-ACTION',
+            'ROLE': 'REQ-PARTICIPANT',
+            'RSVP': 'TRUE'
+        }.items():
+            self.assertTrue(attendees[0].hasParameter(paramname))
+            self.assertEqual(attendees[0].parameterValue(paramname), paramvalue)
+
+
+    def test_doNotAddSelfToEvent(self):
+        """
+        If the inviter randomly selects its own user to be added to
+        the attendee list, a different user is added instead.
+        """
+        selfNumber = 12
+        _ignore_vevent, event, _ignore_calendar, client = self._simpleAccount(
+            selfNumber, SIMPLE_EVENT)
+
+        otherNumber = 20
+        values = [selfNumber - selfNumber, otherNumber - selfNumber]
+
+        inviter = Inviter(Clock(), self.sim, client, selfNumber)
+        inviter.setParameters(inviteeDistribution=SequentialDistribution(values))
+        inviter._invite()
+        attendees = tuple(event.component.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 1)
+        for paramname, paramvalue in {
+            'CN': 'User %d' % (otherNumber,),
+            'CUTYPE': 'INDIVIDUAL',
+            'PARTSTAT': 'NEEDS-ACTION',
+            'ROLE': 'REQ-PARTICIPANT',
+            'RSVP': 'TRUE'
+        }.items():
+            self.assertTrue(attendees[0].hasParameter(paramname))
+            self.assertEqual(attendees[0].parameterValue(paramname), paramvalue)
+
+
+    def test_doNotAddExistingToEvent(self):
+        """
+        If the inviter randomly selects a user which is already an
+        invitee on the event, a different user is added instead.
+        """
+        selfNumber = 1
+        _ignore_vevent, event, _ignore_calendar, client = self._simpleAccount(
+            selfNumber, INVITED_EVENT)
+
+        invitee = tuple(event.component.mainComponent().properties('ATTENDEE'))[0]
+        inviteeNumber = int(invitee.parameterValue('CN').split()[1])
+        anotherNumber = inviteeNumber + 5
+        values = [inviteeNumber - selfNumber, anotherNumber - selfNumber]
+
+        inviter = Inviter(Clock(), self.sim, client, selfNumber)
+        inviter.setParameters(inviteeDistribution=SequentialDistribution(values))
+        inviter._invite()
+        attendees = tuple(event.component.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 3)
+        for paramname, paramvalue in {
+            'CN': 'User %02d' % (anotherNumber,),
+            'CUTYPE': 'INDIVIDUAL',
+            'PARTSTAT': 'NEEDS-ACTION',
+            'ROLE': 'REQ-PARTICIPANT',
+            'RSVP': 'TRUE'
+        }.items():
+            self.assertTrue(attendees[2].hasParameter(paramname))
+            self.assertEqual(attendees[2].parameterValue(paramname), paramvalue)
+
+
+    def test_everybodyInvitedAlready(self):
+        """
+        If the first so-many randomly selected users we come across
+        are already attendees on the event, the invitation attempt is
+        abandoned.
+        """
+        selfNumber = 1
+        vevent, _ignore_event, _ignore_calendar, client = self._simpleAccount(
+            selfNumber, INVITED_EVENT)
+        inviter = Inviter(Clock(), self.sim, client, selfNumber)
+        # Always return a user number which has already been invited.
+        inviter.setParameters(inviteeDistribution=Deterministic(2 - selfNumber))
+        inviter._invite()
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 2)
+
+
+    def test_doNotInviteToSomeoneElsesEvent(self):
+        """
+        If there are events on our calendar which are being organized
+        by someone else, the inviter does not attempt to invite new
+        users to them.
+        """
+        selfNumber = 2
+        vevent, _ignore_event, _ignore_calendar, client = self._simpleAccount(
+            selfNumber, INVITED_EVENT)
+        inviter = Inviter(None, self.sim, client, selfNumber)
+        # Try to send an invitation, but with only one event on the
+        # calendar, of which we are not the organizer.  It should be
+        # unchanged afterwards.
+        inviter._invite()
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        self.assertEqual(len(attendees), 2)
+        self.assertEqual(attendees[0].parameterValue('CN'), 'User 01')
+        self.assertEqual(attendees[1].parameterValue('CN'), 'User 02')
+
+
+
+class RealisticInviterTests(TestCase):
+    """
+    Tests for loadtest.profiles.RealisticInviter.
+    """
+    def setUp(self):
+        self.sim = CalendarClientSimulator(
+            AnyUser(), Populator(None), None, None, None, None, None)
+
+
+    def _simpleAccount(self, userNumber, eventText):
+        client = StubClient(userNumber, self.mktemp())
+        vevent = Component.fromString(eventText)
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', u'/cal/', None)
+        event = Event(client.serializeLocation(), calendar.url + u'1234.ics', None, vevent)
+        calendar.events = {u'1234.ics': event}
+        client._events.update({event.url: event})
+        client._calendars.update({calendar.url: calendar})
+
+        return vevent, event, calendar, client
+
+
+    def test_enabled(self):
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+
+        inviter = RealisticInviter(None, self.sim, client, userNumber, **{"enabled": False})
+        self.assertEqual(inviter.enabled, False)
+
+        inviter = RealisticInviter(None, self.sim, client, userNumber, **{"enabled": True})
+        self.assertEqual(inviter.enabled, True)
+
+
+    def test_doNotAddInviteToInbox(self):
+        """
+        When the only calendar with any events is a schedule inbox, no
+        attempt is made to add attendees to that calendar.
+        """
+        calendar = Calendar(
+            caldavxml.schedule_inbox, set(), u'inbox', u'/sched/inbox', None)
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+
+        inviter = RealisticInviter(None, self.sim, client, userNumber, **{"enabled": False})
+        inviter._invite()
+
+        self.assertEquals(client._events, {})
+
+
+    def test_doNotAddInviteToNoCalendars(self):
+        """
+        When there are no calendars and no events at all, the inviter
+        does nothing.
+        """
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+        inviter = RealisticInviter(None, self.sim, client, userNumber)
+        inviter._invite()
+        self.assertEquals(client._events, {})
+        self.assertEquals(client._calendars, {})
+
+
+    def test_addInvite(self):
+        """
+        When there is a normal calendar, inviter adds an invite to it.
+        """
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'personal stuff', u'/cals/personal', None)
+        userNumber = 16
+        serializePath = self.mktemp()
+        os.mkdir(serializePath)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+        inviter = RealisticInviter(Clock(), self.sim, client, userNumber)
+        inviter.setParameters(
+            inviteeDistribution=Deterministic(1),
+            inviteeCountDistribution=Deterministic(1)
+        )
+        inviter._invite()
+        self.assertEquals(len(client._events), 1)
+        attendees = tuple(client._events.values()[0].component.mainComponent().properties('ATTENDEE'))
+        expected = set(("mailto:user%02d at example.com" % (userNumber,), "mailto:user%02d at example.com" % (userNumber + 1,),))
+        for attendee in attendees:
+            expected.remove(attendee.value())
+        self.assertEqual(len(expected), 0)
+
+
+    def test_doNotAddSelfToEvent(self):
+        """
+        If the inviter randomly selects its own user to be added to
+        the attendee list, a different user is added instead.
+        """
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'personal stuff', u'/cals/personal', None)
+        selfNumber = 12
+        client = StubClient(selfNumber, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+
+        otherNumber = 20
+        values = [selfNumber - selfNumber, otherNumber - selfNumber]
+
+        inviter = RealisticInviter(Clock(), self.sim, client, selfNumber)
+        inviter.setParameters(
+            inviteeDistribution=SequentialDistribution(values),
+            inviteeCountDistribution=Deterministic(1)
+        )
+        inviter._invite()
+        self.assertEquals(len(client._events), 1)
+        attendees = tuple(client._events.values()[0].component.mainComponent().properties('ATTENDEE'))
+        expected = set(("mailto:user%02d at example.com" % (selfNumber,), "mailto:user%02d at example.com" % (otherNumber,),))
+        for attendee in attendees:
+            expected.remove(attendee.value())
+        self.assertEqual(len(expected), 0)
+
+
+    def test_doNotAddExistingToEvent(self):
+        """
+        If the inviter randomly selects a user which is already an
+        invitee on the event, a different user is added instead.
+        """
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'personal stuff', u'/cals/personal', None)
+        selfNumber = 1
+        client = StubClient(selfNumber, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+
+        inviteeNumber = 20
+        anotherNumber = inviteeNumber + 5
+        values = [inviteeNumber - selfNumber, inviteeNumber - selfNumber, anotherNumber - selfNumber]
+
+        inviter = RealisticInviter(Clock(), self.sim, client, selfNumber)
+        inviter.setParameters(
+            inviteeDistribution=SequentialDistribution(values),
+            inviteeCountDistribution=Deterministic(2)
+        )
+        inviter._invite()
+        self.assertEquals(len(client._events), 1)
+        attendees = tuple(client._events.values()[0].component.mainComponent().properties('ATTENDEE'))
+        expected = set((
+            "mailto:user%02d at example.com" % (selfNumber,),
+            "mailto:user%02d at example.com" % (inviteeNumber,),
+            "mailto:user%02d at example.com" % (anotherNumber,),
+        ))
+        for attendee in attendees:
+            expected.remove(attendee.value())
+        self.assertEqual(len(expected), 0)
+
+
+    def test_everybodyInvitedAlready(self):
+        """
+        If the first so-many randomly selected users we come across
+        are already attendees on the event, the invitation attempt is
+        abandoned.
+        """
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'personal stuff', u'/cals/personal', None)
+        userNumber = 1
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+        inviter = RealisticInviter(Clock(), self.sim, client, userNumber)
+        inviter.setParameters(
+            inviteeDistribution=Deterministic(1),
+            inviteeCountDistribution=Deterministic(2)
+        )
+        inviter._invite()
+        self.assertEquals(len(client._events), 0)
+
+
+
+class AccepterTests(TestCase):
+    """
+    Tests for loadtest.profiles.Accepter.
+    """
+    def setUp(self):
+        self.sim = CalendarClientSimulator(
+            AnyUser(), Populator(None), None, None, None, None, None)
+
+
+    def test_enabled(self):
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+
+        accepter = Accepter(None, self.sim, client, userNumber, **{"enabled": False})
+        self.assertEqual(accepter.enabled, False)
+
+        accepter = Accepter(None, self.sim, client, userNumber, **{"enabled": True})
+        self.assertEqual(accepter.enabled, True)
+
+
+    def test_ignoreEventOnUnknownCalendar(self):
+        """
+        If an event on an unknown calendar changes, it is ignored.
+        """
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+        accepter = Accepter(None, self.sim, client, userNumber)
+        accepter.eventChanged('/some/calendar/1234.ics')
+
+
+    def test_ignoreNonCalendar(self):
+        """
+        If an event is on a calendar which is not of type
+        {CALDAV:}calendar, it is ignored.
+        """
+        userNumber = 14
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            csxml.dropbox_home, set(), u'notification', calendarURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[calendarURL] = calendar
+        accepter = Accepter(None, self.sim, client, userNumber)
+        accepter.eventChanged(calendarURL + '1234.ics')
+
+
+    def test_ignoreAccepted(self):
+        """
+        If the client is an attendee on an event but the PARTSTAT is
+        not NEEDS-ACTION, the event is ignored.
+        """
+        vevent = Component.fromString(ACCEPTED_EVENT)
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        userNumber = int(attendees[1].parameterValue('CN').split(None, 1)[1])
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', calendarURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[calendarURL] = calendar
+        event = Event(client.serializeLocation(), calendarURL + u'1234.ics', None, vevent)
+        client._events[event.url] = event
+        accepter = Accepter(None, self.sim, client, userNumber)
+        accepter.eventChanged(event.url)
+
+
+    def test_ignoreAlreadyAccepting(self):
+        """
+        If the client sees an event change a second time before
+        responding to an invitation found on it during the first
+        change notification, the second change notification does not
+        generate another accept attempt.
+        """
+        clock = Clock()
+        randomDelay = 7
+        vevent = Component.fromString(INVITED_EVENT)
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        userNumber = int(attendees[1].parameterValue('CN').split(None, 1)[1])
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', calendarURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[calendarURL] = calendar
+        event = Event(client.serializeLocation(), calendarURL + u'1234.ics', None, vevent)
+        client._events[event.url] = event
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.random = Deterministic()
+
+        def _gauss(mu, sigma):
+            return randomDelay
+        accepter.random.gauss = _gauss
+        accepter.eventChanged(event.url)
+        accepter.eventChanged(event.url)
+        clock.advance(randomDelay)
+
+
+    def test_inboxReply(self):
+        """
+        When an inbox item that contains a reply is seen by the client, it
+        deletes it immediately.
+        """
+        userNumber = 1
+        clock = Clock()
+        inboxURL = '/some/inbox/'
+        vevent = Component.fromString(INBOX_REPLY)
+        inbox = Calendar(
+            caldavxml.schedule_inbox, set(), u'the inbox', inboxURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[inboxURL] = inbox
+
+        inboxEvent = Event(client.serializeLocation(), inboxURL + u'4321.ics', None, vevent)
+        client._setEvent(inboxEvent.url, inboxEvent)
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.eventChanged(inboxEvent.url)
+        clock.advance(3)
+        self.assertNotIn(inboxEvent.url, client._events)
+        self.assertNotIn('4321.ics', inbox.events)
+
+
+    def test_inboxReplyFailedDelete(self):
+        """
+        When an inbox item that contains a reply is seen by the client, it
+        deletes it immediately.  If the delete fails, the appropriate response
+        code is returned.
+        """
+        userNumber = 1
+        clock = Clock()
+        inboxURL = '/some/inbox/'
+        vevent = Component.fromString(INBOX_REPLY)
+        inbox = Calendar(
+            caldavxml.schedule_inbox, set(), u'the inbox', inboxURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[inboxURL] = inbox
+
+        inboxEvent = Event(client.serializeLocation(), inboxURL + u'4321.ics', None, vevent)
+        client._setEvent(inboxEvent.url, inboxEvent)
+        client._failDeleteWithObject(inboxEvent.url, IncorrectResponseCode(
+            NO_CONTENT,
+            Response(
+                ('HTTP', 1, 1), PRECONDITION_FAILED,
+                'Precondition Failed', None, None))
+        )
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.eventChanged(inboxEvent.url)
+        clock.advance(3)
+        self.assertNotIn(inboxEvent.url, client._events)
+        self.assertNotIn('4321.ics', inbox.events)
+
+
+    def test_acceptInvitation(self):
+        """
+        If the client is an attendee on an event and the PARTSTAT is
+        NEEDS-ACTION, a response is generated which accepts the
+        invitation and the corresponding event in the
+        I{schedule-inbox} is deleted.
+        """
+        clock = Clock()
+        randomDelay = 7
+        vevent = Component.fromString(INVITED_EVENT)
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        userNumber = int(attendees[1].parameterValue('CN').split(None, 1)[1])
+        client = StubClient(userNumber, self.mktemp())
+
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', calendarURL, None)
+        client._calendars[calendarURL] = calendar
+
+        inboxURL = '/some/inbox/'
+        inbox = Calendar(
+            caldavxml.schedule_inbox, set(), u'the inbox', inboxURL, None)
+        client._calendars[inboxURL] = inbox
+
+        event = Event(client.serializeLocation(), calendarURL + u'1234.ics', None, vevent)
+        client._setEvent(event.url, event)
+
+        inboxEvent = Event(client.serializeLocation(), inboxURL + u'4321.ics', None, vevent)
+        client._setEvent(inboxEvent.url, inboxEvent)
+
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.setParameters(acceptDelayDistribution=Deterministic(randomDelay))
+        accepter.eventChanged(event.url)
+        clock.advance(randomDelay)
+
+        vevent = client._events[event.url].component
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 2)
+        self.assertEquals(
+            attendees[1].parameterValue('CN'), 'User %02d' % (userNumber,))
+        self.assertEquals(
+            attendees[1].parameterValue('PARTSTAT'), 'ACCEPTED')
+        self.assertFalse(attendees[1].hasParameter('RSVP'))
+
+        self.assertNotIn(inboxEvent.url, client._events)
+        self.assertNotIn('4321.ics', inbox.events)
+
+
+    def test_reacceptInvitation(self):
+        """
+        If a client accepts an invitation on an event and then is
+        later re-invited to the same event, the invitation is again
+        accepted.
+        """
+        clock = Clock()
+        randomDelay = 7
+        vevent = Component.fromString(INVITED_EVENT)
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        userNumber = int(attendees[1].parameterValue('CN').split(None, 1)[1])
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', calendarURL, None)
+        client = StubClient(userNumber, self.mktemp())
+        client._calendars[calendarURL] = calendar
+        event = Event(client.serializeLocation(), calendarURL + u'1234.ics', None, vevent)
+        client._events[event.url] = event
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.setParameters(acceptDelayDistribution=Deterministic(randomDelay))
+        accepter.eventChanged(event.url)
+        clock.advance(randomDelay)
+
+        # Now re-set the event so it has to be accepted again
+        event.component = Component.fromString(INVITED_EVENT)
+
+        # And now re-deliver it
+        accepter.eventChanged(event.url)
+        clock.advance(randomDelay)
+
+        # And ensure that it was accepted again
+        vevent = client._events[event.url].component
+        attendees = tuple(vevent.mainComponent().properties('ATTENDEE'))
+        self.assertEquals(len(attendees), 2)
+        self.assertEquals(
+            attendees[1].parameterValue('CN'), 'User %02d' % (userNumber,))
+        self.assertEquals(
+            attendees[1].parameterValue('PARTSTAT'), 'ACCEPTED')
+        self.assertFalse(attendees[1].hasParameter('RSVP'))
+
+
+    def test_changeEventAttendeePreconditionFailed(self):
+        """
+        If the attempt to accept an invitation fails because of an
+        unmet precondition (412), the event is re-retrieved and the
+        PUT is re-issued with the new data.
+        """
+        clock = Clock()
+        userNumber = 2
+        client = StubClient(userNumber, self.mktemp())
+        randomDelay = 3
+
+        calendarURL = '/some/calendar/'
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'calendar', calendarURL, None)
+        client._calendars[calendarURL] = calendar
+
+        vevent = Component.fromString(INVITED_EVENT)
+        event = Event(client.serializeLocation(), calendarURL + u'1234.ics', None, vevent)
+        client._setEvent(event.url, event)
+
+        accepter = Accepter(clock, self.sim, client, userNumber)
+        accepter.setParameters(acceptDelayDistribution=Deterministic(randomDelay))
+
+        client.rescheduled.add(event.url)
+
+        accepter.eventChanged(event.url)
+        clock.advance(randomDelay)
+
+
+
+class EventerTests(TestCase):
+    """
+    Tests for loadtest.profiles.Eventer, a profile which adds new
+    events on calendars.
+    """
+    def setUp(self):
+        self.sim = CalendarClientSimulator(
+            AnyUser(), Populator(None), None, None, None, None, None)
+
+
+    def test_enabled(self):
+        userNumber = 13
+        client = StubClient(userNumber, self.mktemp())
+
+        eventer = Eventer(None, self.sim, client, None, **{"enabled": False})
+        self.assertEqual(eventer.enabled, False)
+
+        eventer = Eventer(None, self.sim, client, None, **{"enabled": True})
+        self.assertEqual(eventer.enabled, True)
+
+
+    def test_doNotAddEventOnInbox(self):
+        """
+        When the only calendar is a schedule inbox, no attempt is made
+        to add events on it.
+        """
+        calendar = Calendar(
+            caldavxml.schedule_inbox, set(), u'inbox', u'/sched/inbox', None)
+        client = StubClient(21, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+
+        eventer = Eventer(None, self.sim, client, None)
+        eventer._addEvent()
+
+        self.assertEquals(client._events, {})
+
+
+    def test_addEvent(self):
+        """
+        When there is a normal calendar to add events to,
+        L{Eventer._addEvent} adds an event to it.
+        """
+        calendar = Calendar(
+            caldavxml.calendar, set(('VEVENT',)), u'personal stuff', u'/cals/personal', None)
+        client = StubClient(31, self.mktemp())
+        client._calendars.update({calendar.url: calendar})
+
+        eventer = Eventer(Clock(), self.sim, client, None)
+        eventer._addEvent()
+
+        self.assertEquals(len(client._events), 1)
+
+        # XXX Vary the event period/interval and the uid

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_sim.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_sim.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_sim.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,593 @@
+##
+# Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+from plistlib import writePlistToString
+from cStringIO import StringIO
+
+from twisted.python.log import msg
+from twisted.python.usage import UsageError
+from twisted.python.filepath import FilePath
+from twisted.internet.defer import Deferred, succeed
+from twisted.trial.unittest import TestCase
+
+from contrib.performance.stats import NormalDistribution
+from contrib.performance.loadtest.ical import OS_X_10_6
+from contrib.performance.loadtest.profiles import Eventer, Inviter, Accepter
+from contrib.performance.loadtest.population import (
+    SmoothRampUp, ClientType, PopulationParameters, Populator, CalendarClientSimulator,
+    ProfileType
+)
+from contrib.performance.loadtest.logger import SimpleStatistics
+from contrib.performance.loadtest.sim import (
+    Arrival, SimOptions, LoadSimulator, LagTrackingReactor,
+    _DirectoryRecord
+)
+
+
+VALID_CONFIG = {
+    'server': 'tcp:127.0.0.1:8008',
+    'webadmin': {
+        'enabled': True,
+        'HTTPPort': 8080,
+    },
+    'arrival': {
+        'factory': 'contrib.performance.loadtest.population.SmoothRampUp',
+        'params': {
+            'groups': 10,
+            'groupSize': 1,
+            'interval': 3,
+        },
+    },
+}
+
+VALID_CONFIG_PLIST = writePlistToString(VALID_CONFIG)
+
+
+
+class SimOptionsTests(TestCase):
+    def test_defaultConfig(self):
+        """
+        If the I{config} option is not specified, the default config.plist in
+        the source tree is used.
+        """
+        options = SimOptions()
+        self.assertEqual(options['config'], FilePath(__file__).sibling('config.plist'))
+
+
+    def test_configFileNotFound(self):
+        """
+        If the filename given to the I{config} option is not found,
+        L{SimOptions.parseOptions} raises a L{UsageError} indicating
+        this.
+        """
+        name = FilePath(self.mktemp())
+        options = SimOptions()
+        exc = self.assertRaises(
+            UsageError, options.parseOptions, ['--config', name.path])
+        self.assertEquals(
+            str(exc), "--config %s: No such file or directory" % (name.path,))
+
+
+    def test_configFileNotParseable(self):
+        """
+        If the contents of the file given to the I{config} option
+        cannot be parsed by L{ConfigParser},
+        L{SimOptions.parseOptions} raises a L{UsageError} indicating
+        this.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent("some random junk")
+        options = SimOptions()
+        exc = self.assertRaises(
+            UsageError, options.parseOptions, ['--config', config.path])
+        self.assertEquals(
+            str(exc),
+            "--config %s: syntax error: line 1, column 0" % (config.path,))
+
+
+
+class CalendarClientSimulatorTests(TestCase):
+    """
+    Tests for L{CalendarClientSimulator} which adds running clients to
+    a simulation.
+    """
+    realmName = 'stub'
+
+    def _user(self, name):
+        password = 'password-' + name
+        email = name + "@example.com"
+        record = _DirectoryRecord(name, password, name, email, name)
+        return record
+
+
+    def test_createUser(self):
+        """
+        Subsequent calls to L{CalendarClientSimulator._createUser}
+        with different user numbers return user details from different
+        directory records.
+        """
+        calsim = CalendarClientSimulator(
+            [self._user('alice'), self._user('bob'), self._user('carol')],
+            Populator(None), None, None, 'http://example.org:1234/', None, None)
+        users = sorted([
+            calsim._createUser(0)[0],
+            calsim._createUser(1)[0],
+            calsim._createUser(2)[0],
+        ])
+        self.assertEqual(['alice', 'bob', 'carol'], users)
+
+
+    def test_createUserAuthInfo(self):
+        """
+        The auth handler returned by L{CalendarClientSimulator._createUser}
+        includes the password taken from user's directory record.
+        """
+        calsim = CalendarClientSimulator(
+            [self._user('alice')],
+            Populator(None), None, None, 'http://example.org:1234/', None, None)
+        user, auth = calsim._createUser(0)
+        self.assertEqual(
+            auth['basic'].passwd.find_user_password('Test Realm', 'http://example.org:1234/')[1],
+            'password-' + user)
+        self.assertEqual(
+            auth['digest'].passwd.find_user_password('Test Realm', 'http://example.org:1234/')[1],
+            'password-' + user)
+
+
+    def test_stop(self):
+        """
+        After L{CalendarClientSimulator.stop} is called, failed clients and
+        profiles are not logged.
+        """
+        class BrokenClient(object):
+            def __init__(self, reactor, serverAddress, serializationPath, userInfo, auth, runResult):
+                self._runResult = runResult
+
+            def run(self):
+                return self._runResult
+
+            def stop(self):
+                return succeed(None)
+
+        class BrokenProfile(object):
+            def __init__(self, reactor, simulator, client, userNumber, runResult):
+                self._runResult = runResult
+                self.enabled = True
+
+            def initialize(self):
+                return succeed(None)
+
+            def run(self):
+                return self._runResult
+
+        clientRunResult = Deferred()
+        profileRunResult = Deferred()
+
+        params = PopulationParameters()
+        params.addClient(1, ClientType(
+            BrokenClient, {'runResult': clientRunResult},
+            [ProfileType(BrokenProfile, {'runResult': profileRunResult})])
+        )
+        sim = CalendarClientSimulator(
+            [self._user('alice')], Populator(None), params, None, 'http://example.com:1234/', None, None)
+        sim.add(1, 1)
+        sim.stop()
+        clientRunResult.errback(RuntimeError("Some fictional client problem"))
+        profileRunResult.errback(RuntimeError("Some fictional profile problem"))
+
+        self.assertEqual([], self.flushLoggedErrors())
+
+
+
+class Reactor(object):
+    message = "some event to be observed"
+
+    def __init__(self):
+        self._triggers = []
+        self._whenRunning = []
+
+
+    def run(self):
+        for thunk in self._whenRunning:
+            thunk()
+        msg(thingo=self.message)
+        for _ignore_phase, event, thunk in self._triggers:
+            if event == 'shutdown':
+                thunk()
+
+
+    def callWhenRunning(self, thunk):
+        self._whenRunning.append(thunk)
+
+
+    def addSystemEventTrigger(self, phase, event, thunk):
+        self._triggers.append((phase, event, thunk))
+
+
+
+class Observer(object):
+    def __init__(self):
+        self.reported = False
+        self.events = []
+
+
+    def observe(self, event):
+        self.events.append(event)
+
+
+    def report(self, output):
+        self.reported = True
+
+
+    def failures(self):
+        return []
+
+
+
+class NullArrival(object):
+    def run(self, sim):
+        pass
+
+
+
+class StubSimulator(LoadSimulator):
+    def run(self):
+        return 3
+
+
+
+class LoadSimulatorTests(TestCase):
+    def test_main(self):
+        """
+        L{LoadSimulator.main} raises L{SystemExit} with the result of
+        L{LoadSimulator.run}.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(VALID_CONFIG_PLIST)
+
+        exc = self.assertRaises(
+            SystemExit, StubSimulator.main, ['--config', config.path])
+        self.assertEquals(
+            exc.args, (StubSimulator(None, None, None, None, None, None, None).run(),))
+
+
+    def test_createSimulator(self):
+        """
+        L{LoadSimulator.createSimulator} creates a L{CalendarClientSimulator}
+        with its own reactor and host and port information from the
+        configuration file.
+        """
+        server = 'http://127.0.0.7:1243/'
+        reactor = object()
+        sim = LoadSimulator(server, None, None, None, None, None, None, reactor=reactor)
+        calsim = sim.createSimulator()
+        self.assertIsInstance(calsim, CalendarClientSimulator)
+        self.assertIsInstance(calsim.reactor, LagTrackingReactor)
+        self.assertIdentical(calsim.reactor._reactor, reactor)
+        self.assertEquals(calsim.server, server)
+
+
+    def test_loadAccountsFromFile(self):
+        """
+        L{LoadSimulator.fromCommandLine} takes an account loader from the
+        config file and uses it to create user records for use in the
+        simulation.
+        """
+        accounts = FilePath(self.mktemp())
+        accounts.setContent("foo,bar,baz,quux,goo\nfoo2,bar2,baz2,quux2,goo2\n")
+        config = VALID_CONFIG.copy()
+        config["accounts"] = {
+            "loader": "contrib.performance.loadtest.sim.recordsFromCSVFile",
+            "params": {
+                "path": accounts.path
+            },
+        }
+        configpath = FilePath(self.mktemp())
+        configpath.setContent(writePlistToString(config))
+        io = StringIO()
+        sim = LoadSimulator.fromCommandLine(['--config', configpath.path], io)
+        self.assertEquals(io.getvalue(), "Loaded 2 accounts.\n")
+        self.assertEqual(2, len(sim.records))
+        self.assertEqual(sim.records[0].uid, 'foo')
+        self.assertEqual(sim.records[0].password, 'bar')
+        self.assertEqual(sim.records[0].commonName, 'baz')
+        self.assertEqual(sim.records[0].email, 'quux')
+        self.assertEqual(sim.records[1].uid, 'foo2')
+        self.assertEqual(sim.records[1].password, 'bar2')
+        self.assertEqual(sim.records[1].commonName, 'baz2')
+        self.assertEqual(sim.records[1].email, 'quux2')
+
+
+    def test_loadDefaultAccountsFromFile(self):
+        """
+        L{LoadSimulator.fromCommandLine} takes an account loader (with
+        empty path)from the config file and uses it to create user
+        records for use in the simulation.
+        """
+        config = VALID_CONFIG.copy()
+        config["accounts"] = {
+            "loader": "contrib.performance.loadtest.sim.recordsFromCSVFile",
+            "params": {
+                "path": ""
+            },
+        }
+        configpath = FilePath(self.mktemp())
+        configpath.setContent(writePlistToString(config))
+        sim = LoadSimulator.fromCommandLine(['--config', configpath.path],
+                                            StringIO())
+        self.assertEqual(99, len(sim.records))
+        self.assertEqual(sim.records[0].uid, 'user01')
+        self.assertEqual(sim.records[0].password, 'user01')
+        self.assertEqual(sim.records[0].commonName, 'User 01')
+        self.assertEqual(sim.records[0].email, 'user01 at example.com')
+        self.assertEqual(sim.records[98].uid, 'user99')
+        self.assertEqual(sim.records[98].password, 'user99')
+        self.assertEqual(sim.records[98].commonName, 'User 99')
+        self.assertEqual(sim.records[98].email, 'user99 at example.com')
+
+
+    def test_generateRecordsDefaultPatterns(self):
+        """
+        L{LoadSimulator.fromCommandLine} takes an account loader from the
+        config file and uses it to generate user records for use in the
+        simulation.
+        """
+        config = VALID_CONFIG.copy()
+        config["accounts"] = {
+            "loader": "contrib.performance.loadtest.sim.generateRecords",
+            "params": {
+                "count": 2
+            },
+        }
+        configpath = FilePath(self.mktemp())
+        configpath.setContent(writePlistToString(config))
+        sim = LoadSimulator.fromCommandLine(['--config', configpath.path],
+                                            StringIO())
+        self.assertEqual(2, len(sim.records))
+        self.assertEqual(sim.records[0].uid, 'user1')
+        self.assertEqual(sim.records[0].password, 'user1')
+        self.assertEqual(sim.records[0].commonName, 'User 1')
+        self.assertEqual(sim.records[0].email, 'user1 at example.com')
+        self.assertEqual(sim.records[1].uid, 'user2')
+        self.assertEqual(sim.records[1].password, 'user2')
+        self.assertEqual(sim.records[1].commonName, 'User 2')
+        self.assertEqual(sim.records[1].email, 'user2 at example.com')
+
+
+    def test_generateRecordsNonDefaultPatterns(self):
+        """
+        L{LoadSimulator.fromCommandLine} takes an account loader from the
+        config file and uses it to generate user records for use in the
+        simulation.
+        """
+        config = VALID_CONFIG.copy()
+        config["accounts"] = {
+            "loader": "contrib.performance.loadtest.sim.generateRecords",
+            "params": {
+                "count": 3,
+                "uidPattern": "USER%03d",
+                "passwordPattern": "PASSWORD%03d",
+                "namePattern": "Test User %03d",
+                "emailPattern": "USER%03d at example2.com",
+            },
+        }
+        configpath = FilePath(self.mktemp())
+        configpath.setContent(writePlistToString(config))
+        sim = LoadSimulator.fromCommandLine(['--config', configpath.path],
+                                            StringIO())
+        self.assertEqual(3, len(sim.records))
+        self.assertEqual(sim.records[0].uid, 'USER001')
+        self.assertEqual(sim.records[0].password, 'PASSWORD001')
+        self.assertEqual(sim.records[0].commonName, 'Test User 001')
+        self.assertEqual(sim.records[0].email, 'USER001 at example2.com')
+        self.assertEqual(sim.records[2].uid, 'USER003')
+        self.assertEqual(sim.records[2].password, 'PASSWORD003')
+        self.assertEqual(sim.records[2].commonName, 'Test User 003')
+        self.assertEqual(sim.records[2].email, 'USER003 at example2.com')
+
+
+    def test_specifyRuntime(self):
+        """
+        L{LoadSimulator.fromCommandLine} recognizes the I{--runtime} option to
+        specify a limit on how long the simulation will run.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(VALID_CONFIG_PLIST)
+        sim = LoadSimulator.fromCommandLine(['--config', config.path, '--runtime', '123'])
+        self.assertEqual(123, sim.runtime)
+
+
+    def test_loadServerConfig(self):
+        """
+        The Calendar Server host and port are loaded from the [server]
+        section of the configuration file specified.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(
+            writePlistToString({"server": "https://127.0.0.3:8432/"})
+        )
+        sim = LoadSimulator.fromCommandLine(['--config', config.path])
+        self.assertEquals(sim.server, "https://127.0.0.3:8432/")
+
+
+    def test_loadArrivalConfig(self):
+        """
+        The arrival policy type and arguments are loaded from the
+        [arrival] section of the configuration file specified.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(
+            writePlistToString({
+                "arrival": {
+                    "factory": "contrib.performance.loadtest.population.SmoothRampUp",
+                    "params": {
+                        "groups": 10,
+                        "groupSize": 1,
+                        "interval": 3,
+                    },
+                },
+            })
+        )
+        sim = LoadSimulator.fromCommandLine(['--config', config.path])
+        self.assertEquals(
+            sim.arrival,
+            Arrival(SmoothRampUp, dict(groups=10, groupSize=1, interval=3)))
+
+
+    def test_createArrivalPolicy(self):
+        """
+        L{LoadSimulator.createArrivalPolicy} creates an arrival
+        policy based on the L{Arrival} passed to its initializer.
+        """
+        class FakeArrival(object):
+            def __init__(self, reactor, x, y):
+                self.reactor = reactor
+                self.x = x
+                self.y = y
+
+        reactor = object()
+        sim = LoadSimulator(
+            None, None, None, None, None, Arrival(FakeArrival, {'x': 3, 'y': 2}), None, reactor=reactor)
+        arrival = sim.createArrivalPolicy()
+        self.assertIsInstance(arrival, FakeArrival)
+        self.assertIdentical(arrival.reactor, sim.reactor)
+        self.assertEquals(arrival.x, 3)
+        self.assertEquals(arrival.y, 2)
+
+
+    def test_loadPopulationParameters(self):
+        """
+        Client weights and profiles are loaded from the [clients]
+        section of the configuration file specified.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(
+            writePlistToString(
+                {
+                    "clients": [
+                        {
+                            "software": "contrib.performance.loadtest.ical.OS_X_10_6",
+                            "params": {
+                                "foo": "bar"
+                            },
+                            "profiles": [
+                                {
+                                    "params": {
+                                        "interval": 25,
+                                        "eventStartDistribution": {
+                                            "type": "contrib.performance.stats.NormalDistribution",
+                                            "params": {
+                                                "mu": 123,
+                                                "sigma": 456,
+                                            }
+                                        }
+                                    },
+                                    "class": "contrib.performance.loadtest.profiles.Eventer"
+                                }
+                            ],
+                            "weight": 3,
+                        }
+                    ]
+                }
+            )
+        )
+
+        sim = LoadSimulator.fromCommandLine(
+            ['--config', config.path, '--clients', config.path]
+        )
+        expectedParameters = PopulationParameters()
+        expectedParameters.addClient(
+            3,
+            ClientType(
+                OS_X_10_6,
+                {"foo": "bar"},
+                [
+                    ProfileType(
+                        Eventer, {
+                            "interval": 25,
+                            "eventStartDistribution": NormalDistribution(123, 456)
+                        }
+                    )
+                ]
+            )
+        )
+        self.assertEquals(sim.parameters, expectedParameters)
+
+
+    def test_requireClient(self):
+        """
+        At least one client is required, so if a configuration with an
+        empty clients array is specified, a single default client type
+        is used.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(writePlistToString({"clients": []}))
+        sim = LoadSimulator.fromCommandLine(
+            ['--config', config.path, '--clients', config.path]
+        )
+        expectedParameters = PopulationParameters()
+        expectedParameters.addClient(
+            1, ClientType(OS_X_10_6, {}, [Eventer, Inviter, Accepter]))
+        self.assertEquals(sim.parameters, expectedParameters)
+
+
+    def test_loadLogObservers(self):
+        """
+        Log observers specified in the [observers] section of the
+        configuration file are added to the logging system.
+        """
+        config = FilePath(self.mktemp())
+        config.setContent(
+            writePlistToString(
+                {
+                    "observers": [
+                        {
+                            "type": "contrib.performance.loadtest.logger.SimpleStatistics",
+                            "params": {},
+                        },
+                    ]
+                }
+            )
+        )
+        sim = LoadSimulator.fromCommandLine(['--config', config.path])
+        self.assertEquals(len(sim.observers), 1)
+        self.assertIsInstance(sim.observers[0], SimpleStatistics)
+
+
+    def test_observeRunReport(self):
+        """
+        Each log observer is added to the log publisher before the
+        simulation run is started and has its C{report} method called
+        after the simulation run completes.
+        """
+        observers = [Observer()]
+        sim = LoadSimulator(
+            "http://example.com:123/",
+            "/principals/users/%s/",
+            None,
+            None,
+            None,
+            Arrival(lambda reactor: NullArrival(), {}),
+            None, observers, reactor=Reactor())
+        io = StringIO()
+        sim.run(io)
+        self.assertEquals(io.getvalue(), "\n*** PASS\n")
+        self.assertTrue(observers[0].reported)
+        self.assertEquals(
+            [e for e in observers[0].events if "thingo" in e][0]["thingo"],
+            Reactor.message
+        )

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_trafficlogger.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_trafficlogger.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_trafficlogger.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,210 @@
+##
+# Copyright (c) 2011-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+from zope.interface import Interface, implements
+
+from twisted.internet.protocol import ClientFactory, Protocol
+from twisted.trial.unittest import TestCase
+from twisted.test.proto_helpers import StringTransport, MemoryReactor
+from twisted.protocols.wire import Discard
+
+from contrib.performance.loadtest.trafficlogger import _TrafficLoggingFactory, loggedReactor
+
+
+class IProbe(Interface):
+    """
+    An interface which can be used to verify some interface-related behavior of
+    L{loggedReactor}.
+    """
+    def probe(): #@NoSelf
+        pass
+
+
+
+class Probe(object):
+    implements(IProbe)
+
+    _probed = False
+
+    def __init__(self, result=None):
+        self._result = result
+
+
+    def probe(self):
+        self._probed = True
+        return self._result
+
+
+
+class TrafficLoggingReactorTests(TestCase):
+    """
+    Tests for L{loggedReactor}.
+    """
+    def test_nothing(self):
+        """
+        L{loggedReactor} returns the object passed to it, if the object passed
+        to it doesn't provide any interfaces.  This is mostly for testing
+        convenience rather than a particularly useful feature.
+        """
+        probe = object()
+        self.assertIdentical(probe, loggedReactor(probe))
+
+
+    def test_interfaces(self):
+        """
+        The object returned by L{loggedReactor} provides all of the interfaces
+        provided by the object passed to it.
+        """
+        probe = Probe()
+        reactor = loggedReactor(probe)
+        self.assertTrue(IProbe.providedBy(reactor))
+
+
+    def test_passthrough(self):
+        """
+        Methods on interfaces on the object passed to L{loggedReactor} can be
+        called by calling them on the object returned by L{loggedReactor}.
+        """
+        expected = object()
+        probe = Probe(expected)
+        reactor = loggedReactor(probe)
+        result = reactor.probe()
+        self.assertTrue(probe._probed)
+        self.assertIdentical(expected, result)
+
+
+    def test_connectTCP(self):
+        """
+        Called on the object returned by L{loggedReactor}, C{connectTCP} calls
+        the wrapped reactor's C{connectTCP} method with the original factory
+        wrapped in a L{_TrafficLoggingFactory}.
+        """
+        class RecordDataProtocol(Protocol):
+            def dataReceived(self, data):
+                self.data = data
+        proto = RecordDataProtocol()
+        factory = ClientFactory()
+        factory.protocol = lambda: proto
+        reactor = MemoryReactor()
+        logged = loggedReactor(reactor)
+        logged.connectTCP('192.168.1.2', 1234, factory, 21, '127.0.0.2')
+        [(host, port, factory, timeout, bindAddress)] = reactor.tcpClients
+        self.assertEqual('192.168.1.2', host)
+        self.assertEqual(1234, port)
+        self.assertIsInstance(factory, _TrafficLoggingFactory)
+        self.assertEqual(21, timeout)
+        self.assertEqual('127.0.0.2', bindAddress)
+
+        # Verify that the factory and protocol specified are really being used
+        protocol = factory.buildProtocol(None)
+        protocol.makeConnection(None)
+        protocol.dataReceived("foo")
+        self.assertEqual(proto.data, "foo")
+
+
+    def test_getLogFiles(self):
+        """
+        The reactor returned by L{loggedReactor} has a C{getLogFiles} method
+        which returns a L{logstate} instance containing the active and
+        completed log files tracked by the logging wrapper.
+        """
+        wrapped = ClientFactory()
+        wrapped.protocol = Discard
+        reactor = MemoryReactor()
+        logged = loggedReactor(reactor)
+        logged.connectTCP('127.0.0.1', 1234, wrapped)
+        factory = reactor.tcpClients[0][2]
+
+        finished = factory.buildProtocol(None)
+        finished.makeConnection(StringTransport())
+        finished.dataReceived('finished')
+        finished.connectionLost(None)
+
+        active = factory.buildProtocol(None)
+        active.makeConnection(StringTransport())
+        active.dataReceived('active')
+
+        logs = logged.getLogFiles()
+        self.assertEqual(1, len(logs.finished))
+        self.assertIn('finished', logs.finished[0].getvalue())
+        self.assertEqual(1, len(logs.active))
+        self.assertIn('active', logs.active[0].getvalue())
+
+
+
+class TrafficLoggingFactoryTests(TestCase):
+    """
+    Tests for L{_TrafficLoggingFactory}.
+    """
+    def setUp(self):
+        self.wrapped = ClientFactory()
+        self.wrapped.protocol = Discard
+        self.factory = _TrafficLoggingFactory(self.wrapped)
+
+
+    def test_receivedBytesLogged(self):
+        """
+        When bytes are delivered through a protocol created by
+        L{_TrafficLoggingFactory}, they are added to a log kept on that
+        factory.
+        """
+        protocol = self.factory.buildProtocol(None)
+
+        # The factory should now have a new StringIO log file
+        self.assertEqual(1, len(self.factory.logs))
+
+        transport = StringTransport()
+        protocol.makeConnection(transport)
+
+        protocol.dataReceived("hello, world")
+        self.assertEqual(
+            "*\nC 0: 'hello, world'\n", self.factory.logs[0].getvalue())
+
+
+    def test_finishedLogs(self):
+        """
+        When connections are lost, the corresponding log files are moved into
+        C{_TrafficLoggingFactory.finishedLogs}.
+        """
+        protocol = self.factory.buildProtocol(None)
+        transport = StringTransport()
+        protocol.makeConnection(transport)
+        logfile = self.factory.logs[0]
+        protocol.connectionLost(None)
+        self.assertEqual(0, len(self.factory.logs))
+        self.assertEqual([logfile], self.factory.finishedLogs)
+
+
+    def test_finishedLogsLimit(self):
+        """
+        Only the most recent C{_TrafficLoggingFactory.LOGFILE_LIMIT} logfiles
+        are kept in C{_TrafficLoggingFactory.finishedLogs}.
+        """
+        self.factory.LOGFILE_LIMIT = 2
+        first = self.factory.buildProtocol(None)
+        first.makeConnection(StringTransport())
+        second = self.factory.buildProtocol(None)
+        second.makeConnection(StringTransport())
+        third = self.factory.buildProtocol(None)
+        third.makeConnection(StringTransport())
+
+        second.connectionLost(None)
+        first.connectionLost(None)
+        third.connectionLost(None)
+
+        self.assertEqual(
+            [first.logfile, third.logfile], self.factory.finishedLogs)

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_webadmin.py
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_webadmin.py	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/tests/test_webadmin.py	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,144 @@
+##
+# Copyright (c) 2012-2015 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+##
+
+from twisted.trial.unittest import TestCase
+from contrib.performance.loadtest.webadmin import LoadSimAdminResource
+
+class WebAdminTests(TestCase):
+    """
+    Tests for L{LoadSimAdminResource}.
+    """
+
+    class FakeReporter(object):
+
+        def generateReport(self, output):
+            output.write("FakeReporter")
+
+
+    class FakeReactor(object):
+
+        def __init__(self):
+            self.running = True
+
+        def stop(self):
+            self.running = False
+
+
+    class FakeLoadSim(object):
+
+        def __init__(self):
+            self.reactor = WebAdminTests.FakeReactor()
+            self.reporter = WebAdminTests.FakeReporter()
+            self.running = True
+
+        def stop(self):
+            self.running = False
+
+
+    class FakeRequest(object):
+
+        def __init__(self, **kwargs):
+            self.args = kwargs
+
+
+    def test_resourceGET(self):
+        """
+        Test render_GET
+        """
+
+        loadsim = WebAdminTests.FakeLoadSim()
+        resource = LoadSimAdminResource(loadsim)
+
+        response = resource.render_GET(WebAdminTests.FakeRequest())
+        self.assertTrue(response.startswith("<html>"))
+        self.assertTrue(response.find(resource.token) != -1)
+
+
+    def test_resourcePOST_Stop(self):
+        """
+        Test render_POST when Stop button is clicked
+        """
+
+        loadsim = WebAdminTests.FakeLoadSim()
+        resource = LoadSimAdminResource(loadsim)
+        self.assertTrue(loadsim.reactor.running)
+
+        response = resource.render_POST(WebAdminTests.FakeRequest(
+            token=(resource.token,),
+            stop=None,
+        ))
+        self.assertTrue(response.startswith("<html>"))
+        self.assertTrue(response.find(resource.token) == -1)
+        self.assertTrue(response.find("FakeReporter") != -1)
+        self.assertFalse(loadsim.running)
+
+
+    def test_resourcePOST_Stop_BadToken(self):
+        """
+        Test render_POST when Stop button is clicked but token is wrong
+        """
+
+        loadsim = WebAdminTests.FakeLoadSim()
+        resource = LoadSimAdminResource(loadsim)
+        self.assertTrue(loadsim.reactor.running)
+
+        response = resource.render_POST(WebAdminTests.FakeRequest(
+            token=("xyz",),
+            stop=None,
+        ))
+        self.assertTrue(response.startswith("<html>"))
+        self.assertTrue(response.find(resource.token) != -1)
+        self.assertTrue(response.find("FakeReporter") == -1)
+        self.assertTrue(loadsim.running)
+
+
+    def test_resourcePOST_Results(self):
+        """
+        Test render_POST when Results button is clicked
+        """
+
+        loadsim = WebAdminTests.FakeLoadSim()
+        resource = LoadSimAdminResource(loadsim)
+        self.assertTrue(loadsim.reactor.running)
+
+        response = resource.render_POST(WebAdminTests.FakeRequest(
+            token=(resource.token,),
+            results=None,
+        ))
+        self.assertTrue(response.startswith("<html>"))
+        self.assertTrue(response.find(resource.token) != -1)
+        self.assertTrue(response.find("FakeReporter") != -1)
+        self.assertTrue(loadsim.running)
+
+
+    def test_resourcePOST_Results_BadToken(self):
+        """
+        Test render_POST when Results button is clicked and token is wrong
+        """
+
+        loadsim = WebAdminTests.FakeLoadSim()
+        resource = LoadSimAdminResource(loadsim)
+        self.assertTrue(loadsim.reactor.running)
+
+        response = resource.render_POST(WebAdminTests.FakeRequest(
+            token=("xyz",),
+            results=None,
+        ))
+        self.assertTrue(response.startswith("<html>"))
+        self.assertTrue(response.find(resource.token) != -1)
+        self.assertTrue(response.find("FakeReporter") == -1)
+        self.assertTrue(loadsim.running)

Added: CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/www/layout.html
===================================================================
--- CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/www/layout.html	                        (rev 0)
+++ CalendarServer/branches/users/sredmond/clientsim/contrib/performance/loadtest/www/layout.html	2015-08-17 20:38:08 UTC (rev 15047)
@@ -0,0 +1,28 @@
+<html>
+<head>
+    <style type="text/css">
+        body {color:#000000;}
+        h1 h2 h3 {color:#333333;}
+        td {text-align: center; padding: 5px;}
+        pre.light {color:#CCCCCC; font-size:12px;}
+        table.rounded-corners {
+            border: 1px solid #000000; background-color:#cccccc;
+            -moz-border-radius: 5px;
+            -webkit-border-radius: 5px;
+            -khtml-border-radius: 5px;
+            border-radius: 5px;
+        }
+    </style>
+</head>
+<body>
+    <h1>Load Simulator Web Admin</h1>
+    <form method="POST">
+        <input name="token" type="hidden" value="%s" />
+        <table class="rounded-corners">
+            <tr><td><input name="results" type="submit" value="Refresh" /></td></tr>
+            <tr><td><input name="stop" type="submit" value="Stop Sim" /></td></tr>
+        </table>
+    </form>
+    {% block results %}
+</body>
+</html>
\ No newline at end of file
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20150817/2ad74099/attachment-0001.html>


More information about the calendarserver-changes mailing list