Repository: Sage-Bionetworks/challengeutils

Issue:
Refactor mirrorwiki
Add tests
implement a 'dry run' for mirroring the wiki
It would be nice to have a 'test' run of mirroring to indicate what would happen without actually making any changes.

Discussion:
pep8speaks: Hello @thomasyu888! Thanks for updating this PR. We checked the lines you've touched for [PEP 8](https://www.python.org/dev/peps/pep-0008) issues, and found:







* In the file [`tests/test_mirrorwiki.py`](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py):

> [Line 18:1](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L18): [W293](https://duckduckgo.com/?q=pep8%20W293) blank line contains whitespace
> [Line 60:1](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L60): [W293](https://duckduckgo.com/?q=pep8%20W293) blank line contains whitespace
> [Line 69:14](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L69): [E127](https://duckduckgo.com/?q=pep8%20E127) continuation line over-indented for visual indent
> [Line 103:14](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L103): [E127](https://duckduckgo.com/?q=pep8%20E127) continuation line over-indented for visual indent
> [Line 107:1](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L107): [W293](https://duckduckgo.com/?q=pep8%20W293) blank line contains whitespace
> [Line 107:1](https://github.com/Sage-Bionetworks/challengeutils/blob/f03a8dc4aeef2cbff33f092bcacda5b03ff2694f/tests/test_mirrorwiki.py#L107): [W391](https://duckduckgo.com/?q=pep8%20W391) blank line at end of file



Patch:
diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml
index 5db2751..6589b67 100644
--- a/.github/workflows/pythonapp.yml
+++ b/.github/workflows/pythonapp.yml
@@ -1,7 +1,7 @@
 # This workflow will install Python dependencies, run tests and lint with a single version of Python
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
 
-name: challengeutils test
+name: build
 
 on: [pull_request]
 
@@ -21,15 +21,22 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
+        pip install flake8 pytest pytest-cov
         pip install .
         if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-   # - name: Lint with flake8
-   #   run: |
+    - name: Lint with flake8
+      run: |
         # stop the build if there are Python syntax errors or undefined names
-        # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
     - name: Test with pytest
       run: |
-        pip install pytest
-        pytest
+        pytest tests/ --doctest-modules --cov=challengeutils --cov-report=html
+    - name: Upload pytest test results
+      uses: actions/upload-artifact@master
+      with:
+        name: pytest-results-${{ matrix.python-version }}
+        path: htmlcov
+      # Use always() to always run this step to publish test results when there are test failures
+      if: ${{ always() }}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 261eeb9..8b9a0c6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright [yyyy] [name of copyright owner]
+   Copyright 2017 Sage Bionetworks
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/challengeutils/__main__.py b/challengeutils/__main__.py
index 0d08e0f..6e65dc8 100644
--- a/challengeutils/__main__.py
+++ b/challengeutils/__main__.py
@@ -21,17 +21,20 @@ logger = logging.getLogger(__name__)
 
 
 def command_mirrorwiki(syn, args):
-    """For all challenges, you should be editting the staging site and then
-    using the merge script to mirror staging to live site.  The script will
-    compare wiki titles between the staging and live site and update the live
-    site with respect to what has changed on the staging site.  Note, this is
-    different from copying the wikis. To copy the wikis, please look at
-    synapseutils.
+    """Mirrors (sync) wiki pages by using the wikipage titles between two
+    Synapse Entities.  This function only works if `entity` and `destination`
+    are the same type and both must have wiki pages.  Only wiki pages with the
+    same titles will be copied from `entity` to `destination` - if there is
+    a wiki page that you want to add, you will have to create a wiki page
+    first in the `destination` with the same name.
+
+    Note: this is different from copying a wiki - to copy a wiki, please look
+    at `synapseutils.copyWiki`.
 
     >>> challengeutils mirrorwiki syn12345 syn23456
     """
-    mirrorwiki.mirrorwiki(syn, args.entityid, args.destinationid,
-                          args.forceupdate)
+    mirrorwiki.mirror(syn, args.entityid, args.destinationid,
+                      force=args.force, dryrun=args.dryrun)
 
 
 def command_createchallenge(syn, args):
@@ -278,27 +281,39 @@ def build_parser():
               " there is already a live site"))
     parser_createChallenge.set_defaults(func=command_createchallenge)
 
-    parser_mirrorWiki = subparsers.add_parser(
+    parser_mirrorwiki = subparsers.add_parser(
         'mirrorwiki',
-        help=("This command mirrors wiki pages. It relies on the wiki titles "
-              "between two Synapse Projects to be the same and will merge the "
-              "updates from entity's wikis to destination's wikis. "
-              "Do not confuse this function with copy wiki."))
+        help="Mirrors (sync) wiki pages by using the wikipage titles between "
+             "two Synapse Entities. This function only works if `entity` and "
+             "`destination`are the same type and both must have wiki pages. "
+             "Only wiki pages with the same titles will be copied from "
+             "`entity` to `destination` - if there is a wiki page that you "
+             "want to add, you will have to create a wiki page first in the "
+             "`destination` with the same name."
+    )
 
-    parser_mirrorWiki.add_argument(
+    parser_mirrorwiki.add_argument(
         "entityid",
         type=str,
-        help="Synapse Id of the project's wiki changes you have staged")
-    parser_mirrorWiki.add_argument(
+        help="Synapse Id of the project's wiki changes you have staged"
+    )
+    parser_mirrorwiki.add_argument(
         "destinationid",
         type=str,
-        help=('Synapse Id of project whose wiki you want to update'
-              ' with the entityid'))
-    parser_mirrorWiki.add_argument(
-        "--forceupdate",
+        help="Synapse Id of project whose wiki you want to update "
+             "with the entityid"
+    )
+    parser_mirrorwiki.add_argument(
+        "-f", "--force",
+        action='store_true',
+        help='Update the wikipages even if they are the same'
+    )
+    parser_mirrorwiki.add_argument(
+        "--dryrun",
         action='store_true',
-        help='Update the wikipages even if they are the same')
-    parser_mirrorWiki.set_defaults(func=command_mirrorwiki)
+        help="Show the pages that have changed but don't update."
+    )
+    parser_mirrorwiki.set_defaults(func=command_mirrorwiki)
 
     parser_query = subparsers.add_parser(
         'query',
diff --git a/challengeutils/challenge.py b/challengeutils/challenge.py
index bb18b9a..4041071 100644
--- a/challengeutils/challenge.py
+++ b/challengeutils/challenge.py
@@ -3,11 +3,7 @@ import json
 from typing import Union, Iterator
 
 from synapseclient import Project, Synapse, Team
-try:
-    from synapseclient.core.utils import id_of
-except ModuleNotFoundError:
-    # For synapseclient < v2.0
-    from synapseclient.utils import id_of
+from synapseclient.core.utils import id_of
 
 from .synapseservices.challenge import Challenge
 
@@ -36,10 +32,10 @@ class ChallengeApi:
             A synapseservices.Challenge
 
         """
-        new_challenge = Challenge(participantTeamId=teamid,
-                                  projectId=projectid)
+        challenge_object = {'participantTeamId': teamid,
+                            'projectId': projectid}
         challenge = self.syn.restPOST('/challenge',
-                                      str(new_challenge))
+                                      json.dumps(challenge_object))
         return Challenge(**challenge)
 
     def get_registered_challenges(self,
@@ -93,11 +89,11 @@ class ChallengeApi:
             A synapseservices.Challenge
 
         """
-        new_challenge = Challenge(id=challengeid,
-                                  participantTeamId=teamid,
-                                  projectId=projectid)
+        challenge_object = {'id': challengeid,
+                            'participantTeamId': teamid,
+                            'projectId': projectid}
         challenge = self.syn.restPUT(f'/challenge/{challengeid}',
-                                     str(new_challenge))
+                                     json.dumps(challenge_object))
         return Challenge(**challenge)
 
     def delete_challenge(self, challengeid: str):
diff --git a/challengeutils/createchallenge.py b/challengeutils/createchallenge.py
index 8595694..83b4ca6 100644
--- a/challengeutils/createchallenge.py
+++ b/challengeutils/createchallenge.py
@@ -281,8 +281,8 @@ def main(syn, challenge_name, live_site=None):
     else:
         project_live = syn.get(live_site)
 
-    challenge = create_challenge_widget(syn, project_live,
-                                        teams['team_part_id'])
+    challenge_obj = create_challenge_widget(syn, project_live,
+                                            teams['team_part_id'])
     create_evaluation_queue(syn, '%s Project Submission' % challenge_name,
                             'Project Submission',
                             project_live.id)
@@ -304,7 +304,7 @@ def main(syn, challenge_name, live_site=None):
     for page in new_wikiids:
         wikipage = syn.getWiki(project_staging, page['id'])
         wikipage.markdown = _update_wikipage_string(wikipage.markdown,
-                                                    challenge.id,
+                                                    challenge_obj.id,
                                                     teams['team_part_id'],
                                                     challenge_name,
                                                     project_live.id)
diff --git a/challengeutils/discussion.py b/challengeutils/discussion.py
index 1a32828..46b507e 100644
--- a/challengeutils/discussion.py
+++ b/challengeutils/discussion.py
@@ -357,6 +357,23 @@ def create_thread_reply(syn, threadid, message):
 
 
 def copy_thread(syn, thread, project):
+    """Copies a discussion thread and its replies to a project
+
+    Args:
+        syn: synapse object
+        thread: Synapse Thread
+        project: Synapse Project or its id to copy thread to
+
+    Returns:
+        dict: Thread bundle
+    """
+    new_thread_obj = _copy_thread(syn, thread, project)
+    thread_replies = get_thread_replies(syn, thread['id'])
+    for reply in thread_replies:
+        copy_reply(syn, reply, new_thread_obj['id'])
+
+
+def _copy_thread(syn, thread, project):
     """Copies a discussion thread to a project
 
     Args:
@@ -374,8 +391,9 @@ def copy_thread(syn, thread, project):
     on_behalf_of = "On behalf of @{user}\n\n".format(user=username)
     text = get_thread_text(syn, thread['messageKey'])
     new_thread_text = on_behalf_of + text
+    new_thread_obj = create_thread(syn, projectid, title, new_thread_text)
 
-    return create_thread(syn, projectid, title, new_thread_text)
+    return new_thread_obj
 
 
 def copy_reply(syn, reply, thread):
@@ -408,7 +426,4 @@ def copy_forum(syn, project, new_project):
     """
     threads = get_forum_threads(syn, project)
     for thread in threads:
-        new_thread_obj = copy_thread(syn, thread, new_project)
-        thread_replies = get_thread_replies(syn, thread['id'])
-        for reply in thread_replies:
-            copy_reply(syn, reply, new_thread_obj['id'])
+        copy_thread(syn, thread, new_project)
diff --git a/challengeutils/evaluation_queue.py b/challengeutils/evaluation_queue.py
index de14d67..fdc28e2 100644
--- a/challengeutils/evaluation_queue.py
+++ b/challengeutils/evaluation_queue.py
@@ -1,6 +1,8 @@
 """Evaluation Queue helper functions"""
 import time
 
+from synapseclient import Synapse
+
 
 def _convert_date_to_epoch(date_string):
     """Converts local time dates in YEAR-MM-DDTHH:MM:SS format
@@ -83,7 +85,7 @@ def _create_quota(round_start: str = None, round_end: str = None,
     return quota
 
 
-def set_evaluation_quota(syn: 'Synapse', evalid: int, **kwargs):
+def set_evaluation_quota(syn: Synapse, evalid: int, **kwargs):
     """Sets evaluation submission limit quota.  This WILL erase any old quota
     you had previously set. Note - round_start must be specified with either
     round_end or round_duration and number_of_rounds must be defined for the
diff --git a/challengeutils/mirrorwiki.py b/challengeutils/mirrorwiki.py
index e901aef..c62d93d 100644
--- a/challengeutils/mirrorwiki.py
+++ b/challengeutils/mirrorwiki.py
@@ -1,6 +1,8 @@
 import logging
 import re
+from typing import Union, List, Dict
 
+from synapseclient import File, Folder, Project, Wiki, Synapse
 from synapseclient.core.exceptions import SynapseHTTPError
 import synapseutils
 
@@ -8,100 +10,229 @@ logging.basicConfig()
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.INFO)
 
+PREVIEW_FILE_HANDLE = "org.sagebionetworks.repo.model.file.PreviewFileHandle"
+
+
+def _replace_wiki_text(markdown: str, wiki_mapping: dict,
+                       entity: Union[File, Folder, Project],
+                       destination: Union[File, Folder, Project]) -> str:
+    """Remap wiki text with correct synapse links
+
+    Args:
+        markdown: Markdown text
+        wiki_mapping: mapping of old to new wiki pages
+        entity: Synapse entity with wiki
+        dest: Synapse entity with wiki
+
+    Returns:
+        Remapped markdown string
+
+    """
+    for entity_page_id in wiki_mapping:
+        dest_subwiki = wiki_mapping[entity_page_id]
+        # Replace the wiki
+        orig_text = f"{entity.id}/wiki/{entity_page_id}"
+        map_to_text = f"{destination.id}/wiki/{dest_subwiki}"
+        markdown = re.sub(orig_text, map_to_text, markdown)
+        # Some widgets that you fill in with synapse links
+        # are auto encoded. / -> %2F
+        orig_text = f"{entity.id}%2Fwiki%2F{entity_page_id}"
+        map_to_text = f"{destination.id}%2Fwiki%2F{dest_subwiki}"
+        markdown = re.sub(orig_text, map_to_text, markdown)
+    markdown = re.sub(entity.id, destination.id, markdown)
+    return markdown
+
+
+def _copy_attachments(syn: Synapse, entity_wiki: Wiki) -> list:
+    """Copy wiki attachments
+
+    Args:
+        syn: Synapse connection
+        entity_wiki: Wiki you are copying
+
+    Returns:
+        Synapse Attachment filehandleids
 
-def mirrorwiki(syn, entity, destination, force_merge=False):
     """
-    This script is responsible for mirroring wiki pages
-    It relies on the wiki titles between two Synapse Projects to be
-    The same and will merge the updates from entity's wikis to
-    destination's wikis
+    # All attachments must be updated
+    if entity_wiki['attachmentFileHandleIds']:
+        attachments = [
+            syn._getFileHandleDownload(filehandleid,
+                                       entity_wiki.id,
+                                       objectType='WikiAttachment')
+            for filehandleid in entity_wiki['attachmentFileHandleIds']
+        ]
+        # Remove preview attachments
+        no_previews = [
+            attachment['fileHandle'] for attachment in attachments
+            if attachment['fileHandle']['concreteType'] != PREVIEW_FILE_HANDLE
+        ]
+        content_types = [attachment['contentType']
+                         for attachment in no_previews]
+        file_names = [attachment['fileName'] for attachment in no_previews]
+        copied_filehandles = synapseutils.copyFileHandles(
+            syn, no_previews, ["WikiAttachment"]*len(no_previews),
+            [entity_wiki.id]*len(no_previews),
+            content_types, file_names
+        )
+        new_attachments = [filehandle['newFileHandle']['id']
+                           for filehandle in copied_filehandles]
+    else:
+        new_attachments = []
+    return new_attachments
+
+
+def _get_headers(syn: Synapse,
+                 entity: Union[File, Folder, Project]) -> List[dict]:
+    """Get wiki headers.
 
     Args:
+        syn: Synapse connection
+        entity: A Synapse Entity
+
+    Returns:
+        List of wiki headers
+
+    """
+
+    try:
+        wiki_headers = syn.getWikiHeaders(entity)
+    except SynapseHTTPError:
+        raise ValueError(f"{entity.name} has no Wiki. Mirroring wikis "
+                         "require that both `entity` and `destination` "
+                         "have the same wiki structure. If you want to copy "
+                         "a wiki page from `entity` to `destination`, you may "
+                         "want to use `synapseutils.copyWiki`")
+    return wiki_headers
+
+
+def _update_wiki(syn: Synapse, entity_wiki_pages: Dict[str, Wiki],
+                 destination_wiki_pages: Dict[str, Wiki],
+                 force: bool = False, dryrun: bool = False,
+                 **kwargs) -> Dict[str, Wiki]:
+    """Updates wiki pages.
+
+    Args:
+        entity_wiki_pages: Mapping between wiki title and synapseclient.Wiki
+        destination_wiki_pages: Mapping between wiki title and
+                                synapseclient.Wiki
+        force: This will update a page even if its the same. Default is False.
+
+        **kwargs: Same parameters as mirrorwiki.replace_wiki_text
+
+    """
+    mirrored_wiki = []
+    for title in entity_wiki_pages:
+        # If destination wiki does not have the title page, do not update
+        if destination_wiki_pages.get(title) is None:
+            logger.info(f"Title doesn't exist at destination: {title}")
+            continue
+
+        # Generate new markdown text
+        entity_wiki = entity_wiki_pages[title]
+        destination_wiki = destination_wiki_pages[title]
+        markdown = _replace_wiki_text(markdown=entity_wiki.markdown,
+                                      **kwargs)
+
+        if destination_wiki.markdown == markdown and not force:
+            logger.info(f"No page updates: {title}")
+        else:
+            logger.info(f"Updating: {title}")
+            destination_wiki.markdown = markdown
+            mirrored_wiki.append(destination_wiki)
+
+        # Should copy over the attachments every time because
+        # someone could name attachments with the same name
+        new_attachments = _copy_attachments(syn, entity_wiki)
+
+        destination_wiki.update(
+            {'attachmentFileHandleIds': new_attachments}
+        )
+        if not dryrun:
+            destination_wiki = syn.store(destination_wiki)
+
+    return mirrored_wiki
+
+
+def _get_wikipages_and_mapping(syn: Synapse,
+                               entity: Union[File, Folder, Project],
+                               destination: Union[File, Folder, Project]
+                               ) -> dict:
+    """Get entity/destination pages and mapping of wiki pages
+
+    Args:
+        syn: Synapse connection
         entity: Synapse File, Project, Folder Entity or Id with
                 Wiki you want to copy
         destination: Synapse File, Project, Folder Entity or Id
                      with Wiki that matches entity
-        force_merge: this will update a page even if its the same
 
     Returns:
-        nothing
+        {'entity_wiki_pages': {'title': synapseclient.Wiki}
+         'destination_wiki_pages': {'title': synapseclient.Wiki}
+         'wiki_mapping': {'wiki_id': 'dest_wiki_id'}}
+
     """
-    entity = syn.get(entity, downloadFile=False)
-    destination = syn.get(destination, downloadFile=False)
-    # TODO: getWikiHeaders fails when there is no wiki
-    entity_wiki = syn.getWikiHeaders(entity)
-    try:
-        destination_wiki = syn.getWikiHeaders(destination)
-    except SynapseHTTPError:
-        raise ValueError("The destination Project has no Wiki. Mirroring a"
-                         " Wiki requires that the source and destination "
-                         "Projects have the same structure. You may want "
-                         "to use the copy wiki functionality provided "
-                         "by 'synapseutils.copyWiki'")
+    entity_wiki = _get_headers(syn, entity)
+    destination_wiki = _get_headers(syn, destination)
 
-    # Get mapping of wiki pages
     entity_wiki_pages = {}
     for wiki in entity_wiki:
-        entity_wiki_pages[wiki['title']] = wiki['id']
-    destination_wiki_pages = {}
+        entity_wiki = syn.getWiki(entity, wiki['id'])
+        entity_wiki_pages[wiki['title']] = entity_wiki
+
     # Mapping dictionary containing wiki page mapping between
     # entity and destination
     wiki_mapping = {}
+    destination_wiki_pages = {}
     for wiki in destination_wiki:
-        destination_wiki_pages[wiki['title']] = wiki['id']
-        # Account for pages that exist in the new page that
-        # don't exist in the old page
+        destination_wiki = syn.getWiki(destination, wiki['id'])
+        destination_wiki_pages[wiki['title']] = destination_wiki
+        # Only map wiki pages that exist in `entity` (source)
         if entity_wiki_pages.get(wiki['title']) is not None:
-            wiki_mapping[entity_wiki_pages[wiki['title']]] = wiki['id']
-    # TODO: Need to account for new pages ###
-    for title in entity_wiki_pages:
-        entity_wiki = syn.getWiki(entity, entity_wiki_pages[title])
-        # If destination wiki does not have the title page, do not update
-        if destination_wiki_pages.get(title) is not None:
-            destination_wiki = syn.getWiki(destination,
-                                           destination_wiki_pages[title])
-            if destination_wiki.markdown == entity_wiki.markdown and not force_merge:
-                logger.info("Skipping page update: {}".format(title))
-            else:
-                logger.info("Updating: {}".format(title))
-                destination_wiki.markdown = entity_wiki.markdown
-                for entity_page_id in wiki_mapping:
-                    entity_project_and_wiki_id = "{}/wiki/{}".format(
-                        entity.id,
-                        entity_page_id)
-                    destination_project_and_wiki_id = "{}/wiki/{}".format(
-                        destination.id,
-                        wiki_mapping[entity_page_id])
-                    destination_wiki.markdown = re.sub(entity_project_and_wiki_id,
-                                                       destination_project_and_wiki_id,
-                                                       destination_wiki.markdown)
-                    # Some widgets that you fill in with synapse links are auto encoded. / -> %2F
-                    encoded_entity_project_and_wiki_id = "{}%2Fwiki%2F{}".format(entity.id, entity_page_id)
-                    encoded_destination_project_and_wiki_id = "{}%2Fwiki%2F{}".format(destination.id, wiki_mapping[entity_page_id])
-                    destination_wiki.markdown = re.sub(encoded_entity_project_and_wiki_id,
-                                                       encoded_destination_project_and_wiki_id,
-                                                       destination_wiki.markdown)
-                destination_wiki.markdown = re.sub(entity.id,
-                                                   destination.id,
-                                                   destination_wiki.markdown)
-            # All attachments must be updated
-            if len(entity_wiki['attachmentFileHandleIds']) > 0:
-                attachments = [syn._getFileHandleDownload(filehandleid, entity_wiki.id, objectType='WikiAttachment') for filehandleid in entity_wiki['attachmentFileHandleIds']]
-                # Remove preview attachments
-                no_previews = [attachment['fileHandle'] for attachment in attachments if attachment['fileHandle']['concreteType'] != "org.sagebionetworks.repo.model.file.PreviewFileHandle"]
-                content_types = [attachment['contentType'] for attachment in no_previews]
-                file_names = [attachment['fileName'] for attachment in no_previews]
-                copied_filehandles = synapseutils.copyFileHandles(syn,
-                                                                  no_previews,
-                                                                  ["WikiAttachment"]*len(no_previews),
-                                                                  [entity_wiki.id]*len(no_previews),
-                                                                  content_types,
-                                                                  file_names)
-                new_attachments = [filehandle['newFileHandle']['id']
-                                   for filehandle in copied_filehandles]
-            else:
-                new_attachments = []
-            destination_wiki.update({'attachmentFileHandleIds': new_attachments})
-            destination_wiki = syn.store(destination_wiki)
+            wiki_mapping[entity_wiki_pages[wiki['title']].id] = wiki['id']
         else:
-            logger.info("{}: title not existent in destination wikis".format(title))
+            logger.info("Title exists at destination but not in "
+                        f"entity: {wiki['title']}")
+
+    return {'entity_wiki_pages': entity_wiki_pages,
+            'destination_wiki_pages': destination_wiki_pages,
+            'wiki_mapping': wiki_mapping}
+
+
+def mirror(syn: Synapse, entity: Union[File, Folder, Project],
+           destination: Union[File, Folder, Project], force: bool = False,
+           dryrun: bool = False):
+    """Mirrors (sync) wiki pages by using the wikipage titles between two
+    Synapse Entities.  This function only works if `entity` and `destination`
+    are the same type and both must have wiki pages.  Only wiki pages with the
+    same titles will be copied from `entity` to `destination` - if there is
+    a wiki page that you want to add, you will have to create a wiki page
+    first in the `destination` with the same name.
+
+    Args:
+        entity: Synapse File, Project, Folder Entity or Id with
+                Wiki you want to copy
+        destination: Synapse File, Project, Folder Entity or Id
+                     with Wiki that matches entity
+        force: Update a page even if its the same. Default to False.
+        dryrun: Show the pages that have changed but don't update. Default
+                is False.
+
+    """
+    entity = syn.get(entity, downloadFile=False)
+    destination = syn.get(destination, downloadFile=False)
+    if type(entity) is not type(destination):
+        raise ValueError("Can only mirror wiki pages between similar "
+                         "entity types")
+
+    # Get entity/destination pages and mapping of wiki pages
+    pages_and_mappings = _get_wikipages_and_mapping(syn, entity,
+                                                    destination)
+
+    if dryrun:
+        logger.info("Your wiki pages will not be mirrored. `dryrun` is True")
+    _update_wiki(syn, **pages_and_mappings,
+                 force=force, dryrun=dryrun,
+                 entity=entity,
+                 destination=destination)
diff --git a/challengeutils/utils.py b/challengeutils/utils.py
index fd71c2b..d10615e 100644
--- a/challengeutils/utils.py
+++ b/challengeutils/utils.py
@@ -244,44 +244,6 @@ def update_all_submissions_annotation_acl(syn, evaluationid, annotations,
         syn.store(new_status)
 
 
-def invite_member_to_team(syn, team, user=None, email=None, message=None):
-    """
-    Invite members to a team
-
-    Args:
-        syn: Synapse object
-        team: Synapse Team id or name
-        user: Synapse username or profile id
-        email: Email of user, do not specify both email and user,
-               but must specify one
-        message: Message for people getting invited to the team
-    """
-    teamid = syn.getTeam(team)['id']
-    is_member = False
-    invite = {'teamId': str(teamid)}
-
-    if email is None:
-        userid = syn.getUserProfile(user)['ownerId']
-        request = \
-            "/team/{teamId}/member/{individualId}/membershipStatus".format(
-                teamId=str(teamid),
-                individualId=str(userid))
-        membership_status = syn.restGET(request)
-        is_member = membership_status['isMember']
-        invite['inviteeId'] = str(userid)
-    else:
-        invite['inviteeEmail'] = email
-
-    if message is not None:
-        invite['message'] = message
-
-    if not is_member:
-        invite = syn.restPOST("/membershipInvitation", body=json.dumps(invite))
-        return invite
-
-    return None
-
-
 def change_submission_status(syn, submissionid, status='RECEIVED'):
     '''
     Function to change a submission status


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/synapseservices/test_challenge_obj.py b/tests/synapseservices/test_challenge_obj.py
new file mode 100644
index 0000000..dbcc459
--- /dev/null
+++ b/tests/synapseservices/test_challenge_obj.py
@@ -0,0 +1,12 @@
+"""Test challeng service"""
+from challengeutils.synapseservices.challenge import Challenge
+
+
+def test_challenge_obj():
+    """Tests that a challenge object can be instantiated"""
+    challenge_dict = {'id': 'challenge_1',
+                      'projectId': 'project_1',
+                      'participantTeamId': 'team_1',
+                      'etag': 'etag_1'}
+    challenge_obj = Challenge(**challenge_dict)
+    assert challenge_obj.to_dict() == challenge_dict
diff --git a/tests/test_challenge.py b/tests/test_challenge.py
index 882a569..4ba86d5 100644
--- a/tests/test_challenge.py
+++ b/tests/test_challenge.py
@@ -11,16 +11,6 @@ from challengeutils.synapseservices.challenge import Challenge
 from challengeutils.challenge import ChallengeApi
 
 
-def test_challenge_obj():
-    """Tests that a challenge object can be instantiated"""
-    challenge_dict = {'id': 'challenge_1',
-                      'projectId': 'project_1',
-                      'participantTeamId': 'team_1',
-                      'etag': 'etag_1'}
-    challenge_obj = Challenge(**challenge_dict)
-    assert challenge_obj.to_dict() == challenge_dict
-
-
 class TestChallengeApi:
     """Tests ChallengeApi class"""
     def setup(self):
@@ -56,6 +46,8 @@ class TestChallengeApi:
         """Tests that a challenge object can be instantiated"""
         new_challenge = Challenge(projectId=self.projectid,
                                   participantTeamId=self.teamid)
+        challenge_object = {'participantTeamId': self.teamid,
+                            'projectId': self.projectid}
         with patch.object(self.syn, "restPOST",
                           return_value=self.challenge_dict) as patch_restpost:
             challenge_obj = self.challenge_api.create_challenge(
@@ -63,8 +55,9 @@ class TestChallengeApi:
                 projectid=self.projectid
             )
             assert challenge_obj == self.expected
-            patch_restpost.assert_called_once_with('/challenge',
-                                                   str(new_challenge))
+            patch_restpost.assert_called_once_with(
+                '/challenge', json.dumps(challenge_object)
+            )
 
     def test_get_challenge__with_id(self):
         """Tests getting challenge with challenge id"""
@@ -92,6 +85,9 @@ class TestChallengeApi:
 
     def test_update_challenge(self):
         """Tests updating Challenge"""
+        challenge_dict = {"id": self.challengeid,
+                          "participantTeamId": self.teamid,
+                          "projectId": self.projectid}
         with patch.object(self.syn, "restPUT",
                           return_value=self.challenge_dict) as patch_restput:
             challenge_obj = self.challenge_api.update_challenge(
@@ -102,7 +98,7 @@ class TestChallengeApi:
             assert challenge_obj == self.expected
             patch_restput.assert_called_once_with(
                 f'/challenge/{self.challengeid}',
-                str(self.input)
+                json.dumps(challenge_dict)
             )
 
     def test_delete_challenge(self):
diff --git a/tests/test_discussion.py b/tests/test_discussion.py
index 917b231..ad2e42a 100644
--- a/tests/test_discussion.py
+++ b/tests/test_discussion.py
@@ -3,6 +3,7 @@ Test challengeutils.discussion functions
 '''
 import json
 from unittest import mock
+from unittest.mock import patch
 import uuid
 
 import requests
@@ -211,7 +212,7 @@ def test_get_entity_threads():
         assert entity_threads == response
 
 
-def test_copy_thread():
+def test__copy_thread():
     """Tests copying of threads"""
     profile = synapseclient.UserProfile(ownerId="test",
                                         userName="foobar")
@@ -225,7 +226,7 @@ def test_copy_thread():
                            return_value=thread_text) as patch_thread_text,\
          mock.patch.object(discussion, "create_thread",
                            return_value=THREAD_OBJ) as patch_create_thread:
-        thread = discussion.copy_thread(syn, THREAD_OBJ, PROJECTID)
+        thread = discussion._copy_thread(syn, THREAD_OBJ, PROJECTID)
         patch_getuserprofile.assert_called_once_with(THREAD_OBJ['createdBy'])
         patch_thread_text.assert_called_once_with(syn, THREAD_OBJ['messageKey'])
         patch_create_thread.assert_called_once_with(syn, PROJECTID,
@@ -234,6 +235,20 @@ def test_copy_thread():
         assert thread == THREAD_OBJ
 
 
+def test_copy_thread():
+    """Tests copying thread and replies"""
+    with patch.object(discussion, "_copy_thread",
+                      return_value=THREAD_OBJ) as patch_copy_threads,\
+         patch.object(discussion, "get_thread_replies",
+                      return_value=[REPLY_OBJ]) as patch_thread_replies,\
+         patch.object(discussion, "copy_reply") as patch_copy_reply:
+        discussion.copy_thread(syn, THREAD_OBJ, PROJECTID)
+        patch_copy_threads.assert_called_once_with(syn, THREAD_OBJ, PROJECTID)
+        patch_thread_replies.assert_called_once_with(syn, THREAD_OBJ['id'])
+        patch_copy_reply.assert_called_once_with(syn, REPLY_OBJ,
+                                                 THREAD_OBJ['id'])
+
+
 def test_copy_reply():
     """Tests copying of replies"""
     profile = synapseclient.UserProfile(ownerId="test",
@@ -264,14 +279,8 @@ def test_copy_forum():
     with mock.patch.object(discussion, "get_forum_threads",
                            return_value=[THREAD_OBJ]) as patch_get_threads,\
          mock.patch.object(discussion, "copy_thread",
-                           return_value=new_thread) as patch_copy_thread,\
-         mock.patch.object(discussion, "get_thread_replies",
-                           return_value=[REPLY_OBJ]) as patch_thread_replies,\
-         mock.patch.object(discussion, "copy_reply") as patch_copy_reply:
+                           return_value=new_thread) as patch_copy_thread:
         discussion.copy_forum(syn, PROJECTID, new_projectid)
         patch_get_threads.assert_called_once_with(syn, PROJECTID)
         patch_copy_thread.assert_called_once_with(syn, THREAD_OBJ,
                                                   new_projectid)
-        patch_thread_replies.assert_called_once_with(syn, THREAD_OBJ['id'])
-        patch_copy_reply.assert_called_once_with(syn, REPLY_OBJ,
-                                                 new_thread['id'])
diff --git a/tests/test_mirrorwiki.py b/tests/test_mirrorwiki.py
new file mode 100644
index 0000000..1808537
--- /dev/null
+++ b/tests/test_mirrorwiki.py
@@ -0,0 +1,285 @@
+"""Test mirrorwiki"""
+from unittest import mock
+from unittest.mock import patch
+
+import pytest
+from synapseclient import Project, Wiki, Synapse
+from synapseclient.core.exceptions import SynapseHTTPError
+import synapseutils
+
+from challengeutils import mirrorwiki
+
+
+class TestMirrorWiki:
+    """Tests mirroring wiki"""
+
+    def setup(self):
+        """Setting up all variables for tests"""
+        self.markdown = "test\nsyn123/wiki/2222\nsyn123%2Fwiki%2F2222\nsyn123"
+        self.wiki_mapping = {'2222': '5555'}
+        self.entity = Project(name="foo", id="syn123")
+        self.destination = Project(name="test", id="syn555")
+        self.expected_markdown = (
+            "test\nsyn555/wiki/5555\nsyn555%2Fwiki%2F5555\nsyn555"
+        )
+        self.syn = mock.create_autospec(Synapse)
+        self.entity_wiki = Wiki(markdown=self.markdown, id="2222",
+                                owner="syn123",
+                                attachmentFileHandleIds=['322', '333'])
+        self.filehandles = [
+            {
+                'fileHandle': {
+                    "concreteType": mirrorwiki.PREVIEW_FILE_HANDLE,
+                    "contentType": "contenttype",
+                    "fileName": "name"
+                }
+
+            },
+            {
+                'fileHandle': {
+                    "concreteType": "not_preview",
+                    "contentType": "testing",
+                    "fileName": "foobar"
+                }
+
+            }
+        ]
+        self.new_filehandle = [{'newFileHandle': {"id": "12356"}}]
+        # wiki page mapping {'title': Wiki}
+        self.entity_wiki_pages = {'test': self.entity_wiki}
+        self.destination_wiki_pages = {'test': self.entity_wiki}
+
+        # wiki headers
+        self.entity_wiki_headers = [{"id": "2222", "title": "test"}]
+        self.destination_wiki_headers = [{"id": "5555", "title": "test"}]
+
+    def test__replace_wiki_text(self):
+        """Tests replacing of wiki text"""
+        new_markdown = mirrorwiki._replace_wiki_text(
+            markdown=self.markdown,
+            wiki_mapping=self.wiki_mapping,
+            entity=self.entity,
+            destination=self.destination
+        )
+        assert new_markdown == self.expected_markdown
+
+    def test__copy_attachments(self):
+        """Test copying attachments. Test preview filehandles aren't copied"""
+        get_filehandle_calls = [mock.call("322", self.entity_wiki.id,
+                                          objectType='WikiAttachment'),
+                                mock.call("333", self.entity_wiki.id,
+                                          objectType='WikiAttachment')]
+        with patch.object(self.syn, "_getFileHandleDownload",
+                          side_effect=self.filehandles) as patch_get_handle,\
+             patch.object(synapseutils, "copyFileHandles",
+                          return_value=self.new_filehandle) as patch_copy:
+            attachments = mirrorwiki._copy_attachments(self.syn,
+                                                       self.entity_wiki)
+            assert attachments == ["12356"]
+            patch_get_handle.assert_has_calls(get_filehandle_calls)
+            patch_copy.assert_called_once_with(self.syn,
+                                               [{"concreteType": "not_preview",
+                                                 "contentType": "testing",
+                                                 "fileName": "foobar"}],
+                                               ["WikiAttachment"],
+                                               [self.entity_wiki.id],
+                                               ['testing'],
+                                               ['foobar'])
+
+    def test__copy_attachments_none(self):
+        """Test no attachments are returned when there are no attachments"""
+        self.entity_wiki.attachmentFileHandleIds = []
+        attachments = mirrorwiki._copy_attachments(self.syn,
+                                                   self.entity_wiki)
+        assert attachments == []
+
+    def test__get_headers(self):
+        """Test getting headers"""
+        with patch.object(self.syn, "getWikiHeaders",
+                          return_value="test") as patch_get:
+            headers = mirrorwiki._get_headers(self.syn, self.entity)
+            assert headers == "test"
+            patch_get.assert_called_once_with(self.entity)
+
+    def test__get_headers_raiseerror(self):
+        """Test correct error is raised"""
+        with patch.object(self.syn, "getWikiHeaders",
+                          side_effect=SynapseHTTPError),\
+             pytest.raises(ValueError,
+                           match="foo has no Wiki. Mirroring wikis "
+                                 "require that both `entity` .*"):
+            mirrorwiki._get_headers(self.syn, self.entity)
+
+    def test__update_wiki_no_title(self):
+        """Test that nothing is updated when title doesnt exist in
+        destination
+        """
+        mirrored = mirrorwiki._update_wiki(self.syn, self.entity_wiki_pages,
+                                           {'dne': "foo"})
+        assert mirrored == []
+
+    def test__update_wiki_samemarkdown_force(self):
+        """Test that page is updated when markdown is the same between
+        entity and destination with force=True
+        """
+        with patch.object(mirrorwiki, "_replace_wiki_text",
+                          return_value=self.markdown),\
+             patch.object(mirrorwiki, "_copy_attachments",
+                          return_value=[]),\
+             patch.object(self.syn, "store"):
+            mirrored = mirrorwiki._update_wiki(self.syn,
+                                               self.entity_wiki_pages,
+                                               self.destination_wiki_pages,
+                                               entity=self.entity,
+                                               destination=self.destination,
+                                               wiki_mapping=self.wiki_mapping,
+                                               force=True)
+            assert mirrored == [self.entity_wiki]
+
+    def test__update_wiki_samemarkdown(self):
+        """Test that nothing is updated when markdown is the same between
+        entity and destination
+        """
+        with patch.object(mirrorwiki, "_replace_wiki_text",
+                          return_value=self.markdown),\
+             patch.object(mirrorwiki, "_copy_attachments",
+                          return_value=[]):
+            mirrored = mirrorwiki._update_wiki(self.syn,
+                                               self.entity_wiki_pages,
+                                               self.destination_wiki_pages,
+                                               entity=self.entity,
+                                               destination=self.destination,
+                                               wiki_mapping=self.wiki_mapping)
+            assert mirrored == []
+
+    def test__update_wiki_differentmarkdown(self):
+        """Test that page is updated when markdown is different between
+        entity and destination
+        """
+        with patch.object(mirrorwiki, "_replace_wiki_text",
+                          return_value=self.expected_markdown),\
+             patch.object(mirrorwiki, "_copy_attachments",
+                          return_value=[]),\
+             patch.object(self.syn, "store"):
+            mirrored = mirrorwiki._update_wiki(self.syn,
+                                               self.entity_wiki_pages,
+                                               self.destination_wiki_pages,
+                                               entity=self.entity,
+                                               destination=self.destination,
+                                               wiki_mapping=self.wiki_mapping)
+            assert mirrored == [self.entity_wiki]
+
+    def test__update_wiki_dryrun(self):
+        """Test that page is updated when markdown is different between
+        entity and destination
+        """
+        with patch.object(mirrorwiki, "_replace_wiki_text",
+                          return_value=self.expected_markdown),\
+             patch.object(mirrorwiki, "_copy_attachments",
+                          return_value=[]),\
+             patch.object(self.syn, "store") as patch_store:
+            mirrored = mirrorwiki._update_wiki(self.syn,
+                                               self.entity_wiki_pages,
+                                               self.destination_wiki_pages,
+                                               entity=self.entity,
+                                               destination=self.destination,
+                                               wiki_mapping=self.wiki_mapping,
+                                               dryrun=True)
+            assert mirrored == [self.entity_wiki]
+            patch_store.assert_not_called()
+
+    def test__update_wiki_integration(self):
+        """Make sure correct parameters are called"""
+        with patch.object(mirrorwiki, "_replace_wiki_text",
+                          return_value=self.expected_markdown) as patch_rep,\
+             patch.object(mirrorwiki, "_copy_attachments",
+                          return_value=[]) as patch_copy,\
+             patch.object(self.syn, "store"):
+            mirrored = mirrorwiki._update_wiki(self.syn,
+                                               self.entity_wiki_pages,
+                                               self.destination_wiki_pages,
+                                               entity=self.entity,
+                                               destination=self.destination,
+                                               wiki_mapping=self.wiki_mapping,
+                                               dryrun=True)
+            patch_rep.assert_called_once_with(markdown=self.markdown,
+                                              wiki_mapping=self.wiki_mapping,
+                                              entity=self.entity,
+                                              destination=self.destination)
+            patch_copy.assert_called_once_with(self.syn, self.entity_wiki)
+
+    def test__get_wikipages_and_mapping(self):
+        """Test that page is updated when markdown is different between
+        entity and destination
+        """
+        headers = [self.entity_wiki_headers, self.destination_wiki_headers]
+        wikis = [self.entity_wiki, self.entity_wiki]
+        with patch.object(mirrorwiki, "_get_headers",
+                          side_effect=headers),\
+             patch.object(self.syn, "getWiki", side_effect=wikis):
+            wiki_dict = mirrorwiki._get_wikipages_and_mapping(
+                self.syn, self.entity, self.destination
+            )
+            print(wiki_dict['destination_wiki_pages'])
+        assert wiki_dict == {'entity_wiki_pages': self.entity_wiki_pages,
+                             'destination_wiki_pages': self.entity_wiki_pages,
+                             'wiki_mapping': self.wiki_mapping}
+
+    def test__get_wikipages_and_mapping_none(self):
+        """Test that wiki pages that exist in the destination also exist
+        in the old page
+        """
+        headers = [[], self.destination_wiki_headers]
+        with patch.object(mirrorwiki, "_get_headers",
+                          side_effect=headers),\
+             patch.object(self.syn, "getWiki",
+                          return_value=self.entity_wiki):
+            wiki_dict = mirrorwiki._get_wikipages_and_mapping(
+                self.syn, self.entity, self.destination
+            )
+        assert wiki_dict == {'entity_wiki_pages': {},
+                             'destination_wiki_pages': self.entity_wiki_pages,
+                             'wiki_mapping': {}}
+
+    def test__get_wikipages_and_mapping_integration(self):
+        """Integration test: make sure right parameters are callled
+        """
+        get_header_calls = [mock.call(self.syn, self.entity),
+                            mock.call(self.syn, self.destination)]
+        get_wiki_calls = [mock.call(self.entity, '2222'),
+                          mock.call(self.destination, '5555')]
+        headers = [self.entity_wiki_headers, self.destination_wiki_headers]
+        wikis = [self.entity_wiki, self.entity_wiki]
+        with patch.object(mirrorwiki, "_get_headers",
+                          side_effect=headers) as patch_get_headers,\
+             patch.object(self.syn, "getWiki",
+                          side_effect=wikis) as patch_wiki:
+            wiki_dict = mirrorwiki._get_wikipages_and_mapping(
+                self.syn, self.entity, self.destination
+            )
+            patch_get_headers.assert_has_calls(get_header_calls)
+            patch_wiki.assert_has_calls(get_wiki_calls)
+
+    def test_mirror(self):
+        """Integration test: Tests all parameters are called correctly"""
+        entities = [self.entity, self.destination]
+        mappings = {'entity_wiki_pages': self.entity_wiki_pages,
+                    'destination_wiki_pages': self.entity_wiki_pages,
+                    'wiki_mapping': self.wiki_mapping}
+        get_calls = [mock.call(self.entity, downloadFile=False),
+                     mock.call(self.destination, downloadFile=False)]
+        with patch.object(self.syn, "get",
+                          side_effect=entities) as patch_get,\
+             patch.object(mirrorwiki, "_get_wikipages_and_mapping",
+                          return_value=mappings) as patch_get_mapping,\
+             patch.object(mirrorwiki, "_update_wiki") as patch_update:
+            mirrorwiki.mirror(self.syn, entity=self.entity,
+                              destination=self.destination,
+                              force=True, dryrun=True)
+            patch_get.assert_has_calls(get_calls)
+            patch_get_mapping.assert_called_once_with(self.syn, self.entity,
+                                                      self.destination)
+            patch_update.assert_called_once_with(self.syn, **mappings,
+                                                 force=True, dryrun=True,
+                                                 entity=self.entity,
+                                                 destination=self.destination)