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/challenge.py b/challengeutils/challenge.py
index bb18b9a..a488a3d 100644
--- a/challengeutils/challenge.py
+++ b/challengeutils/challenge.py
@@ -36,10 +36,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 +93,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/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

Add tests to cli
```
---------- coverage: platform darwin, python 3.7.6-final-0 -----------
Name                                                 Stmts   Miss  Cover
------------------------------------------------------------------------
challengeutils/__init__.py                               3      0   100%
challengeutils/__main__.py                             176    176     0%
challengeutils/__version__.py                            1      0   100%
challengeutils/challenge.py                              6      6     0%
challengeutils/createchallenge.py                      100     14    86%
challengeutils/discussion.py                           127     28    78%
challengeutils/download_current_lead_submission.py      35      2    94%
challengeutils/evaluation_queue.py                      34      0   100%
challengeutils/helpers.py                               67     39    42%
challengeutils/mirrorwiki.py                            51     51     0%
challengeutils/permissions.py                           29      4    86%
challengeutils/utils.py                                202     56    72%
challengeutils/writeup_attacher.py                      18     18     0%
------------------------------------------------------------------------
TOTAL                                                  849    394    54%
```
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'])

pip install -e .
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
-e git+https://github.com/Sage-Bionetworks/challengeutils.git@d6b6bcd33637859ca79a14fc56c1f57f1554655f#egg=challengeutils
charset-normalizer==3.4.1
cryptography==44.0.2
Deprecated==1.2.4
entrypoints==0.4
exceptiongroup==1.2.2
idna==3.10
importlib-metadata==6.7.0
iniconfig==2.0.0
keyring==12.0.2
keyrings.alt==3.1
numpy==1.21.6
packaging==24.0
pandas==1.0.1
pluggy==1.2.0
pycparser==2.21
pytest==7.4.4
python-dateutil==2.9.0.post0
pytz==2025.2
requests==2.31.0
SecretStorage==2.3.1
six==1.17.0
synapseclient==2.0.0
tomli==2.0.1
typing_extensions==4.7.1
urllib3==2.0.7
wrapt==1.16.0
zipp==3.15.0

name: challengeutils
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - cffi==1.15.1
      - charset-normalizer==3.4.1
      - cryptography==44.0.2
      - deprecated==1.2.4
      - entrypoints==0.4
      - exceptiongroup==1.2.2
      - idna==3.10
      - importlib-metadata==6.7.0
      - iniconfig==2.0.0
      - keyring==12.0.2
      - keyrings-alt==3.1
      - numpy==1.21.6
      - packaging==24.0
      - pandas==1.0.1
      - pluggy==1.2.0
      - pycparser==2.21
      - pytest==7.4.4
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - requests==2.31.0
      - secretstorage==2.3.1
      - six==1.17.0
      - synapseclient==2.0.0
      - tomli==2.0.1
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - wrapt==1.16.0
      - zipp==3.15.0
prefix: /opt/conda/envs/challengeutils

tests/test_challenge.py::TestChallengeApi::test_create_challenge tests/test_challenge.py::TestChallengeApi::test_update_challenge tests/test_discussion.py::test__copy_thread tests/test_discussion.py::test_copy_thread
tests/test_challenge.py::TestChallengeApi::test_get_registered_challenges tests/test_challenge.py::TestChallengeApi::test_get_challenge__with_id tests/test_challenge.py::TestChallengeApi::test_get_challenge__with_project tests/test_challenge.py::TestChallengeApi::test_delete_challenge tests/test_challenge.py::TestChallengeApi::test_get_registered_participants tests/test_challenge.py::TestChallengeApi::test_get_registered_teams tests/test_challenge.py::TestChallengeApi::test_register_team tests/test_challenge.py::TestChallenge::test_get_challenge tests/test_challenge.py::TestChallenge::test_create_challenge tests/test_challenge.py::TestChallenge::test_get_registered_challenges tests/synapseservices/test_challenge_obj.py::test_challenge_obj tests/test_discussion.py::test__get_project_forum tests/test_discussion.py::test_get_forum_threads tests/test_discussion.py::test_get_thread_replies tests/test_discussion.py::test__get_text tests/test_discussion.py::test_get_thread_text tests/test_discussion.py::test_get_thread_reply_text tests/test_discussion.py::test_get_forum_participants tests/test_discussion.py::test_create_thread tests/test_discussion.py::test_create_thread_reply tests/test_discussion.py::test_get_entity_threads tests/test_discussion.py::test_copy_reply tests/test_discussion.py::test_copy_forum
challengeutils/challenge.py .github/workflows/pythonapp.yml challengeutils/discussion.py challengeutils/createchallenge.py challengeutils/evaluation_queue.py challengeutils/utils.py LICENSE
challengeutils/challenge.py .github/workflows/pythonapp.yml challengeutils/discussion.py challengeutils/createchallenge.py challengeutils/evaluation_queue.py challengeutils/utils.py LICENSE