← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/async-person-merge-162510 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/async-person-merge-162510 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #162510 Person:+delete timeouts : Person merging needs to be done asynchronously
  https://bugs.launchpad.net/bugs/162510

For more details, see:
https://code.launchpad.net/~allenap/launchpad/async-person-merge-162510/+merge/50919

This branch adds a new PersonMergeJob, so that person (and team)
merges can be done asynchronously, and adds a convenient API for them,
IPersonSet.mergeAsync(), that parallels the existing synchronous API,
IPersonSet.merge().

It also:

- Adds a context manager to _LogWrapper, which makes it easier to test
  the output of code that logs with canonical.launchpad.scripts.log.

- Documents several of the IPersonSet.merge() call-sites (or, rather,
  the methods containing those call-sites) to explain what pre-merge
  checks they perform.

  These checks should probably be consolidated into some sort of
  "pre-flight" checks that can be done before calling merge() or
  mergeAsync().

- Adds a new property, IPerson.is_merge_pending, which indicates if
  the person or team is due to be merged into another.

- Fixes the __repr__() for MembershipNotificationJob.

  The __repr__() for PersonTransferJobDerived, its superclass, was
  part nonsense, and was misleading (as I experienced when looking at
  the output of failing tests). I removed the one-size-fits-none
  __repr__() from the superclass and just provided a decent one for
  MembershipNotificationJob.

-- 
https://code.launchpad.net/~allenap/launchpad/async-person-merge-162510/+merge/50919
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/async-person-merge-162510 into lp:launchpad.
=== modified file 'lib/canonical/config/schema-lazr.conf'
--- lib/canonical/config/schema-lazr.conf	2011-02-21 18:54:53 +0000
+++ lib/canonical/config/schema-lazr.conf	2011-02-23 13:46:27 +0000
@@ -2122,10 +2122,16 @@
 # Each job source class also needs its own config section to specify the
 # dbuser, the crontab_group, and the module that the job source class
 # can be loaded from.
-job_sources: IMembershipNotificationJobSource
+job_sources: IMembershipNotificationJobSource, IPersonMergeJobSource
 
 [IMembershipNotificationJobSource]
 # This section is used by cronscripts/process-job-source.py.
 module: lp.registry.interfaces.persontransferjob
 dbuser: person-transfer-job
 crontab_group: MAIN
+
+[IPersonMergeJobSource]
+# This section is used by cronscripts/process-job-source.py.
+module: lp.registry.interfaces.persontransferjob
+dbuser: person-transfer-job
+crontab_group: MAIN

=== modified file 'lib/canonical/launchpad/browser/logintoken.py'
--- lib/canonical/launchpad/browser/logintoken.py	2011-02-02 15:43:31 +0000
+++ lib/canonical/launchpad/browser/logintoken.py	2011-02-23 13:46:27 +0000
@@ -572,6 +572,17 @@
         self.context.consume()
 
     def _doMerge(self):
+        """Merges a duplicate person into a target person.
+
+        - Reassigns the duplicate user's primary email address to the
+          requesting user.
+
+        - Ensures that the requesting user has a preferred email address, and
+          uses the newly acquired one if not.
+
+        - If the duplicate user has no other email addresses, does the merge.
+
+        """
         # The user proved that he has access to this email address of the
         # dupe account, so we can assign it to him.
         requester = self.context.requester

=== modified file 'lib/canonical/launchpad/scripts/logger.py'
--- lib/canonical/launchpad/scripts/logger.py	2011-02-17 16:15:50 +0000
+++ lib/canonical/launchpad/scripts/logger.py	2011-02-23 13:46:27 +0000
@@ -31,6 +31,7 @@
     ]
 
 
+from contextlib import contextmanager
 from cStringIO import StringIO
 from datetime import (
     datetime,
@@ -410,6 +411,15 @@
         else:
             return setattr(self._log, key, value)
 
+    @contextmanager
+    def use(self, log):
+        """Temporarily use a different `log`."""
+        self._log, log = log, self._log
+        try:
+            yield
+        finally:
+            self._log = log
+
     def shortException(self, msg, *args):
         """Like Logger.exception, but does not print a traceback."""
         exctype, value = sys.exc_info()[:2]

=== modified file 'lib/lp/registry/browser/peoplemerge.py'
--- lib/lp/registry/browser/peoplemerge.py	2011-01-07 15:16:52 +0000
+++ lib/lp/registry/browser/peoplemerge.py	2011-02-23 13:46:27 +0000
@@ -137,7 +137,11 @@
         self.dupe_person_emails = emailset.getByPerson(self.dupe_person)
 
     def doMerge(self, data):
-        """Merge the two person/team entries specified in the form."""
+        """Merge the two person/team entries specified in the form.
+
+        Before merging this moves each email address of the duplicate person
+        to the target person, and resets them to `NEW`.
+        """
         for email in self.dupe_person_emails:
             email = IMasterObject(email)
             # EmailAddress.person and EmailAddress.account are readonly
@@ -215,7 +219,21 @@
         return getUtility(ILaunchpadCelebrities).registry_experts
 
     def doMerge(self, data):
-        """Purge the non-transferable team data and merge."""
+        """Purge the non-transferable team data and merge.
+
+        For the duplicate team:
+
+        - If a mailing list exists, and is REGISTERED, DECLINED, FAILED or
+          INACTIVE, it is purged.
+
+        - Unsets the contact address.
+
+        If the target team is the Registry Experts:
+
+        - The duplicate team is withdrawn from all teams that it is itself a
+          member of.
+
+        """
         # A team cannot have more than one mailing list. The old list will
         # remain in the archive.
         purge_list = (self.dupe_person.mailing_list is not None

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-01-21 08:12:29 +0000
+++ lib/lp/registry/configure.zcml	2011-02-23 13:46:27 +0000
@@ -87,19 +87,27 @@
 
     <!-- IPersonTransferJob -->
     <securedutility
-        component="lp.registry.model.persontransferjob.MembershipNotificationJob"
-        provides="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource">
-        <allow
-            interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource"/>
-    </securedutility>
-
-    <class class="lp.registry.model.persontransferjob.PersonTransferJob">
-        <allow interface="lp.registry.interfaces.persontransferjob.IPersonTransferJob"/>
-    </class>
-
-    <class class="lp.registry.model.persontransferjob.MembershipNotificationJob">
-        <allow
-            interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJob"/>
+        component=".model.persontransferjob.MembershipNotificationJob"
+        provides=".interfaces.persontransferjob.IMembershipNotificationJobSource">
+      <allow interface=".interfaces.persontransferjob.IMembershipNotificationJobSource"/>
+    </securedutility>
+
+    <securedutility
+        component=".model.persontransferjob.PersonMergeJob"
+        provides=".interfaces.persontransferjob.IPersonMergeJobSource">
+      <allow interface=".interfaces.persontransferjob.IPersonMergeJobSource"/>
+    </securedutility>
+
+    <class class=".model.persontransferjob.PersonTransferJob">
+        <allow interface=".interfaces.persontransferjob.IPersonTransferJob"/>
+    </class>
+
+    <class class=".model.persontransferjob.MembershipNotificationJob">
+      <allow interface=".interfaces.persontransferjob.IMembershipNotificationJob"/>
+    </class>
+
+    <class class=".model.persontransferjob.PersonMergeJob">
+      <allow interface=".interfaces.persontransferjob.IPersonMergeJob"/>
     </class>
 
     <!-- INameBlacklist -->

=== modified file 'lib/lp/registry/enum.py'
--- lib/lp/registry/enum.py	2011-02-01 04:57:42 +0000
+++ lib/lp/registry/enum.py	2011-02-23 13:46:27 +0000
@@ -80,3 +80,9 @@
 
         Notify affected users of new team membership.
         """)
+
+    MERGE = DBItem(1, """
+        Person merge
+
+        Merge one person or team into another person or team.
+        """)

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2011-02-16 11:03:04 +0000
+++ lib/lp/registry/interfaces/person.py	2011-02-23 13:46:27 +0000
@@ -974,6 +974,10 @@
             description=_("Private teams are visible only to "
                           "their members.")))
 
+    is_merge_pending = Bool(
+        title=_("Is this person due to be merged into another?"),
+        required=False, default=False)
+
     @invariant
     def personCannotHaveIcon(person):
         """Only Persons can have icons."""
@@ -2150,6 +2154,17 @@
     def latest_teams(limit=5):
         """Return the latest teams registered, up to the limit specified."""
 
+    def mergeAsync(from_person, to_person):
+        """Merge a person/team into another asynchronously.
+
+        This schedules a call to `merge()` to happen outside of the current
+        context/request. The intention is that it is called soon after this
+        method is called but there is no guarantee, nor is it not guaranteed
+        to succeed.
+
+        :return: A `PersonMergeJob`.
+        """
+
     def merge(from_person, to_person):
         """Merge a person/team into another.
 

=== modified file 'lib/lp/registry/interfaces/persontransferjob.py'
--- lib/lp/registry/interfaces/persontransferjob.py	2010-09-30 20:39:02 +0000
+++ lib/lp/registry/interfaces/persontransferjob.py	2011-02-23 13:46:27 +0000
@@ -7,6 +7,8 @@
 __all__ = [
     'IMembershipNotificationJob',
     'IMembershipNotificationJobSource',
+    'IPersonMergeJob',
+    'IPersonMergeJobSource',
     'IPersonTransferJob',
     'IPersonTransferJobSource',
     ]
@@ -78,3 +80,37 @@
     def create(member, team, reviewer, old_status, new_status,
                last_change_comment=None):
         """Create a new IMembershipNotificationJob."""
+
+
+class IPersonMergeJob(IPersonTransferJob):
+    """A Job that merges one person or team into another."""
+
+    from_person = PublicPersonChoice(
+        title=_('Alias for minor_person attribute'),
+        vocabulary='ValidPersonOrTeam',
+        required=True)
+
+    to_person = PublicPersonChoice(
+        title=_('Alias for major_person attribute'),
+        vocabulary='ValidPersonOrTeam',
+        required=True)
+
+
+class IPersonMergeJobSource(IJobSource):
+    """An interface for acquiring IMembershipNotificationJobs."""
+
+    def create(from_person, to_person):
+        """Create a new IMembershipNotificationJob."""
+
+    def find(from_person=None, to_person=None):
+        """Finds pending or running merge jobs.
+
+        :param from_person: Match jobs on `from_person`, or `None` to ignore
+            `from_person`.
+        :param to_person: Match jobs on `to_person`, or `None` to ignore
+            `from_person`.
+        :return: A `ResultSet` yielding `IPersonMergeJob`.
+
+        If both `from_person` and `to_person` is supplied, only jobs where
+        both match are returned.
+        """

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2011-02-16 11:03:04 +0000
+++ lib/lp/registry/model/person.py	2011-02-23 13:46:27 +0000
@@ -235,6 +235,7 @@
     validate_public_person,
     )
 from lp.registry.interfaces.personnotification import IPersonNotificationSet
+from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
 from lp.registry.interfaces.pillar import IPillarNameSet
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
@@ -2133,6 +2134,12 @@
         else:
             return True
 
+    @property
+    def is_merge_pending(self):
+        """See `IPublicPerson`."""
+        return not getUtility(
+            IPersonMergeJobSource).find(from_person=self).is_empty()
+
     def visibilityConsistencyWarning(self, new_value):
         """Warning used when changing the team's visibility.
 
@@ -3859,6 +3866,11 @@
             WHERE id = %(to_id)d
             ''' % vars())
 
+    def mergeAsync(self, from_person, to_person):
+        """See `IPersonSet`."""
+        return getUtility(IPersonMergeJobSource).create(
+            from_person=from_person, to_person=to_person)
+
     def merge(self, from_person, to_person):
         """See `IPersonSet`."""
         # Sanity checks

=== modified file 'lib/lp/registry/model/persontransferjob.py'
--- lib/lp/registry/model/persontransferjob.py	2011-01-24 20:10:41 +0000
+++ lib/lp/registry/model/persontransferjob.py	2011-02-23 13:46:27 +0000
@@ -25,11 +25,17 @@
 
 from canonical.config import config
 from canonical.database.enumcol import EnumCol
+from canonical.launchpad.components.decoratedresultset import (
+    DecoratedResultSet,
+    )
 from canonical.launchpad.helpers import (
     get_contact_email_addresses,
     get_email_template,
     )
-from canonical.launchpad.interfaces.lpstorm import IMasterStore
+from canonical.launchpad.interfaces.lpstorm import (
+    IMasterStore,
+    IStore,
+    )
 from canonical.launchpad.mail import (
     format_address,
     simple_sendmail,
@@ -45,14 +51,17 @@
 from lp.registry.interfaces.persontransferjob import (
     IMembershipNotificationJob,
     IMembershipNotificationJobSource,
+    IPersonMergeJob,
+    IPersonMergeJobSource,
     IPersonTransferJob,
     IPersonTransferJobSource,
     )
 from lp.registry.interfaces.teammembership import TeamMembershipStatus
 from lp.registry.model.person import Person
+from lp.services.database.stormbase import StormBase
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
 from lp.services.job.runner import BaseRunnableJob
-from lp.services.database.stormbase import StormBase
 
 
 class PersonTransferJob(StormBase):
@@ -120,17 +129,6 @@
     def __init__(self, job):
         self.context = job
 
-    def __repr__(self):
-        return (
-            '<%(job_type)s branch job (%(id)s) for %(minor_person)s '
-            'as part of %(major_person)s. status=%(status)s>' % {
-                'job_type': self.context.job_type.name,
-                'id': self.context.id,
-                'minor_person': self.minor_person.name,
-                'major_person': self.major_person.name,
-                'status': self.job.status,
-                })
-
     @classmethod
     def create(cls, minor_person, major_person, metadata):
         """See `IPersonTransferJob`."""
@@ -321,3 +319,79 @@
                     self.member_template % replacements, force_wrap=True)
                 simple_sendmail(from_addr, address, subject, msg)
         log.debug('MembershipNotificationJob sent email')
+
+    def __repr__(self):
+        return (
+            "<{self.__class__.__name__} about "
+            "~{self.minor_person.name} in ~{self.major_person.name}; "
+            "status={self.job.status}>").format(self=self)
+
+
+class PersonMergeJob(PersonTransferJobDerived):
+    """A Job that merges one person or team into another."""
+
+    implements(IPersonMergeJob)
+    classProvides(IPersonMergeJobSource)
+
+    class_job_type = PersonTransferJobType.MERGE
+
+    @classmethod
+    def create(cls, from_person, to_person):
+        """See `IPersonMergeJobSource`."""
+        return super(PersonMergeJob, cls).create(
+            minor_person=from_person, major_person=to_person, metadata={})
+
+    @classmethod
+    def find(cls, from_person=None, to_person=None):
+        """See `IPersonMergeJobSource`."""
+        conditions = [
+            PersonTransferJob.job_type == cls.class_job_type,
+            PersonTransferJob.job_id == Job.id,
+            Job._status.is_in(
+                (JobStatus.WAITING, JobStatus.RUNNING))]
+        if from_person is not None:
+            conditions.append(
+                PersonTransferJob.minor_person == from_person)
+        if to_person is not None:
+            conditions.append(
+                PersonTransferJob.major_person == to_person)
+        return DecoratedResultSet(
+            IStore(PersonTransferJob).find(
+                PersonTransferJob, *conditions), cls)
+
+    @property
+    def from_person(self):
+        """See `IPersonMergeJob`."""
+        return self.minor_person
+
+    @property
+    def to_person(self):
+        """See `IPersonMergeJob`."""
+        return self.major_person
+
+    @property
+    def log_name(self):
+        return self.__class__.__name__
+
+    def run(self):
+        """Perform the merge."""
+        from_person_name = self.from_person.name
+        to_person_name = self.to_person.name
+
+        from canonical.launchpad.scripts import log
+        log.debug(
+            "%s is about to merge ~%s into ~%s", self.log_name,
+            from_person_name, to_person_name)
+
+        getUtility(IPersonSet).merge(
+            from_person=self.from_person, to_person=self.to_person)
+
+        log.debug(
+            "%s has merged ~%s into ~%s", self.log_name,
+            from_person_name, to_person_name)
+
+    def __repr__(self):
+        return (
+            "<{self.__class__.__name__} to merge "
+            "~{self.from_person.name} into ~{self.to_person.name}; "
+            "status={self.job.status}>").format(self=self)

=== renamed file 'lib/lp/registry/tests/test_membership_notification_job_creation.py' => 'lib/lp/registry/tests/test_membership_notification_job.py'
--- lib/lp/registry/tests/test_membership_notification_job_creation.py	2010-09-30 21:30:50 +0000
+++ lib/lp/registry/tests/test_membership_notification_job.py	2011-02-23 13:46:27 +0000
@@ -1,7 +1,7 @@
 # Copyright 2010 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Test that MembershipNotificationJobs are created appropriately."""
+"""Tests of `MembershipNotificationJob`."""
 
 __metaclass__ = type
 
@@ -20,17 +20,18 @@
 from lp.services.job.interfaces.job import JobStatus
 from lp.testing import (
     login_person,
+    person_logged_in,
     TestCaseWithFactory,
     )
 from lp.testing.sampledata import ADMIN_EMAIL
 
 
-class CreateMembershipNotificationJobTest(TestCaseWithFactory):
-    """Test that MembershipNotificationJobs are created appropriately."""
+class MembershipNotificationJobTest(TestCaseWithFactory):
+
     layer = LaunchpadFunctionalLayer
 
     def setUp(self):
-        super(CreateMembershipNotificationJobTest, self).setUp()
+        super(MembershipNotificationJobTest, self).setUp()
         self.person = self.factory.makePerson(name='murdock')
         self.team = self.factory.makeTeam(name='a-team')
         self.job_source = getUtility(IMembershipNotificationJobSource)
@@ -63,3 +64,18 @@
         tm.setStatus(
             TeamMembershipStatus.ADMIN, admin, silent=True)
         self.assertEqual([], list(self.job_source.iterReady()))
+
+    def test_repr(self):
+        # A useful representation is available for MembershipNotificationJob
+        # instances.
+        with person_logged_in(self.team.teamowner):
+            self.team.addMember(self.person, self.team.teamowner)
+            membership = getUtility(ITeamMembershipSet).getByPersonAndTeam(
+                self.person, self.team)
+            membership.setStatus(
+                TeamMembershipStatus.ADMIN, self.team.teamowner)
+        [job] = self.job_source.iterReady()
+        self.assertEqual(
+            ("<MembershipNotificationJob about "
+             "~murdock in ~a-team; status=Waiting>"),
+            repr(job))

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2011-02-18 13:26:58 +0000
+++ lib/lp/registry/tests/test_person.py	2011-02-23 13:46:27 +0000
@@ -41,6 +41,7 @@
 from lp.bugs.interfaces.bugtask import IllegalRelatedBugTasksParams
 from lp.bugs.model.bug import Bug
 from lp.bugs.model.bugtask import get_related_bugtasks_search_params
+from lp.bugs.model.structuralsubscription import StructuralSubscription
 from lp.registry.errors import (
     NameAlreadyTaken,
     PrivatePersonLinkageError,
@@ -277,12 +278,30 @@
             user.getOwnedOrDrivenPillars()]
         self.assertEqual(expected_pillars, received_pillars)
 
+<<<<<<< TREE
     def test_selfgenerated_bugnotifications_none_by_default(self):
         # Default for new accounts is to not get any
         # self-generated bug notifications by default.
         user = self.factory.makePerson()
         self.assertFalse(user.selfgenerated_bugnotifications)
 
+=======
+    def test_no_merge_pending(self):
+        # is_merge_pending returns False when this person is not the "from"
+        # person of an active merge job.
+        person = self.factory.makePerson()
+        self.assertFalse(person.is_merge_pending)
+
+    def test_merge_pending(self):
+        # is_merge_pending returns True when this person is the "from" person
+        # of an active merge job.
+        from_person = self.factory.makePerson()
+        to_person = self.factory.makePerson()
+        getUtility(IPersonSet).mergeAsync(from_person, to_person)
+        self.assertTrue(from_person.is_merge_pending)
+        self.assertFalse(to_person.is_merge_pending)
+
+>>>>>>> MERGE-SOURCE
 
 class TestPersonStates(TestCaseWithFactory):
 
@@ -756,6 +775,15 @@
         self.assertEqual([u'TO', u'FROM'], descriptions)
         self.assertEqual(u'foo-1', recipes[1].name)
 
+    def test_mergeAsync(self):
+        # mergeAsync() creates a new `PersonMergeJob`.
+        from_person = self.factory.makePerson()
+        to_person = self.factory.makePerson()
+        login_person(from_person)
+        job = self.person_set.mergeAsync(from_person, to_person)
+        self.assertEqual(from_person, job.from_person)
+        self.assertEqual(to_person, job.to_person)
+
 
 class TestPersonSetCreateByOpenId(TestCaseWithFactory):
     layer = DatabaseFunctionalLayer

=== added file 'lib/lp/registry/tests/test_person_merge_job.py'
--- lib/lp/registry/tests/test_person_merge_job.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_person_merge_job.py	2011-02-23 13:46:27 +0000
@@ -0,0 +1,99 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests of `PersonMergeJob`."""
+
+__metaclass__ = type
+
+from zope.component import getUtility
+from zope.interface.verify import verifyObject
+from zope.security.proxy import removeSecurityProxy
+
+from canonical.launchpad.scripts import log
+from canonical.testing import DatabaseFunctionalLayer
+from lp.registry.interfaces.persontransferjob import (
+    IPersonMergeJob,
+    IPersonMergeJobSource,
+    )
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.log.logger import BufferLogger
+from lp.testing import TestCaseWithFactory
+
+
+class TestPersonMergeJob(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestPersonMergeJob, self).setUp()
+        self.from_person = self.factory.makePerson(name='void')
+        self.to_person = self.factory.makeTeam(name='gestalt')
+        self.job_source = getUtility(IPersonMergeJobSource)
+        self.job = self.job_source.create(
+            from_person=self.from_person, to_person=self.to_person)
+
+    def test_interface(self):
+        # PersonMergeJob implements IPersonMergeJob.
+        verifyObject(IPersonMergeJob, self.job)
+
+    def test_properties(self):
+        # PersonMergeJobs have a few interesting properties.
+        self.assertEqual(self.from_person, self.job.from_person)
+        self.assertEqual(self.from_person, self.job.minor_person)
+        self.assertEqual(self.to_person, self.job.to_person)
+        self.assertEqual(self.to_person, self.job.major_person)
+        self.assertEqual({}, self.job.metadata)
+
+    def test_enqueue(self):
+        # Newly created jobs are enqueued.
+        self.assertEqual([self.job], list(self.job_source.iterReady()))
+
+    def test_run(self):
+        # When run it merges from_person into to_person. First we need to
+        # reassign from_person's email address over to to_person because
+        # IPersonSet.merge() does not (yet) promise to do that.
+        from_email = self.from_person.preferredemail
+        removeSecurityProxy(from_email).personID = self.to_person.id
+        removeSecurityProxy(from_email).accountID = self.to_person.accountID
+
+        logger = BufferLogger()
+        with log.use(logger):
+            self.job.run()
+
+        self.assertEqual(self.to_person, self.from_person.merged)
+        self.assertEqual(
+            ["DEBUG PersonMergeJob is about to merge ~void into ~gestalt",
+             "DEBUG PersonMergeJob has merged ~void into ~gestalt"],
+            logger.getLogBuffer().splitlines())
+
+    def test_repr(self):
+        # A useful representation is available for PersonMergeJob instances.
+        self.assertEqual(
+            "<PersonMergeJob to merge ~void into ~gestalt; status=Waiting>",
+            repr(self.job))
+
+    def find(self, **kwargs):
+        return list(self.job_source.find(**kwargs))
+
+    def test_find(self):
+        # find() looks for merge jobs.
+        self.assertEqual([self.job], self.find())
+        self.assertEqual(
+            [self.job], self.find(from_person=self.from_person))
+        self.assertEqual(
+            [self.job], self.find(to_person=self.to_person))
+        self.assertEqual(
+            [self.job], self.find(
+                from_person=self.from_person,
+                to_person=self.to_person))
+        self.assertEqual(
+            [], self.find(from_person=self.to_person))
+
+    def test_find_only_pending_or_running(self):
+        # find() only returns jobs that are pending or running.
+        for status in JobStatus.items:
+            removeSecurityProxy(self.job.job)._status = status
+            if status in (JobStatus.WAITING, JobStatus.RUNNING):
+                self.assertEqual([self.job], self.find())
+            else:
+                self.assertEqual([], self.find())