Repository: nephila/python-taiga
Pull request #66 (2018-01-31 22:41:32)

Epic listing and instance support added
Thank you for your great job.
Please give me advices on how to complete the PR for inclusion in main project, if useful

yakky: @morlandi nice! I will happily review this and will provide help and hints in the next few days
I am wondering if this can be integrated with #51 
coveralls: 
[![Coverage Status](https://coveralls.io/builds/15316435/badge)](https://coveralls.io/builds/15316435)

Coverage decreased (-1.0%) to 98.253% when pulling **3de88d518f56af268a0fcad0f63d0a70cb60523b on morlandi:master** into **a03b1c483f05cb15ba1b3615112f7335d937b748 on nephila:master**.

codecov[bot]: # [Codecov](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=h1) Report
> Merging [#66](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=desc) into [master](https://codecov.io/gh/nephila/python-taiga/commit/a03b1c483f05cb15ba1b3615112f7335d937b748?src=pr&el=desc) will **decrease** coverage by `0.94%`.
> The diff coverage is `52.63%`.

[![Impacted file tree graph](https://codecov.io/gh/nephila/python-taiga/pull/66/graphs/tree.svg?height=150&width=650&token=6FY1d0GEVv&src=pr)](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #66      +/-   ##
==========================================
- Coverage   97.86%   96.92%   -0.95%     
==========================================
  Files           7        7              
  Lines         891      910      +19     
  Branches       66       66              
==========================================
+ Hits          872      882      +10     
- Misses          7       16       +9     
  Partials       12       12
```


| [Impacted Files](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [taiga/models/models.py](https://codecov.io/gh/nephila/python-taiga/pull/66/diff?src=pr&el=tree#diff-dGFpZ2EvbW9kZWxzL21vZGVscy5weQ==) | `97.18% <52.63%> (-1.92%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=footer). Last update [a03b1c4...3de88d5](https://codecov.io/gh/nephila/python-taiga/pull/66?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

morlandi: @yakky I did notice the open PR #51 before starting my customization, and had a quick look at it.
However, as no support was available for listing, I decided to rather start by cloning a model already existing in the main branch.
By the way, I'm currently using my fork in a small script used to extract a printable HTML document from a Taiga project: https://github.com/morlandi/taiga-print-report
Eventually, I would rather refer to the mail python-taiga project for better sharing.

morlandi: @yakky or @fmarco ... any suggestion on I could obtain a sort of UserStory.list_comments() functionality ? Should I lookup in Issues, IssueAttributes, History or what ?
Thanks in advance for attention
yakky: I am currently AFK, I'll post here some hints as soon as I can
morlandi: No hurry, take your time
morlandi: knock knock 😀 
morlandi: Thank you @fmarco , sooner or later I will try this 😀 
yakky: @marco-silva0000 nice! Please check with @morlandi which is also at the djangocon eu sprints venue
morlandi: @marco-silva0000 time is running out, must catch my flight back to Italy ... let's' keep in touch ;)
yakky: @rafleze @morlandi is there a way we can merge #77 and #66 they both needs the improvements of the other
Let's talk about the way to cooperate to get both merged
morlandi: @yakky and @rafleze, I manually merged PR #77 into #66 

Also, I adjusted test_epics.py contributed by @msilva ... this is something I'm not totally comfortable with ... just made sure all test are running.

Code coverage is still suffering, I'm afraid.

@yakky could you review the updated PR ?

Patch:
diff --git a/taiga/client.py b/taiga/client.py
index a433da2..b339f24 100644
--- a/taiga/client.py
+++ b/taiga/client.py
@@ -6,8 +6,8 @@ from requests.packages.urllib3.exceptions import InsecureRequestWarning
 
 from . import exceptions, utils
 from .models import (
-    History, IssueAttachments, IssueAttributes, Issues, IssueStatuses, IssueTypes, Milestones, Points, Priorities,
-    Projects, Roles, Severities, TaskAttachments, TaskAttributes, Tasks, TaskStatuses, Users, UserStories,
+    Epics, History, IssueAttachments, IssueAttributes, Issues, IssueStatuses, IssueTypes, Milestones, Points,
+    Priorities, Projects, Roles, Severities, TaskAttachments, TaskAttributes, Tasks, TaskStatuses, Users, UserStories,
     UserStoryAttachments, UserStoryAttributes, UserStoryStatuses, Webhooks, WikiLinks, WikiPages,
 )
 from .requestmaker import RequestMaker
@@ -71,6 +71,7 @@ class TaigaAPI:
         self.wikilinks = WikiLinks(self.raw_request)
         self.history = History(self.raw_request)
         self.webhooks = Webhooks(self.raw_request)
+        self.epics = Epics(self.raw_request)
 
     def me(self):
         """
diff --git a/taiga/models/__init__.py b/taiga/models/__init__.py
index f25e534..e45ce83 100644
--- a/taiga/models/__init__.py
+++ b/taiga/models/__init__.py
@@ -1,14 +1,14 @@
 from .models import (
-    History, Issue, IssueAttachment, IssueAttachments, IssueAttribute, IssueAttributes, Issues, IssueStatus,
-    IssueStatuses, IssueType, IssueTypes, Membership, Memberships, Milestone, Milestones, Point, Points, Priorities,
-    Priority, Project, Projects, Role, Roles, Severities, Severity, Task, TaskAttachment, TaskAttachments,
+    Epic, Epics, History, Issue, IssueAttachment, IssueAttachments, IssueAttribute, IssueAttributes, Issues,
+    IssueStatus, IssueStatuses, IssueType, IssueTypes, Membership, Memberships, Milestone, Milestones, Point, Points,
+    Priorities, Priority, Project, Projects, Role, Roles, Severities, Severity, Task, TaskAttachment, TaskAttachments,
     TaskAttribute, TaskAttributes, Tasks, TaskStatus, TaskStatuses, User, Users, UserStories, UserStory,
     UserStoryAttachment, UserStoryAttachments, UserStoryAttribute, UserStoryAttributes, UserStoryStatus,
     UserStoryStatuses, Webhook, Webhooks, WikiLink, WikiLinks, WikiPage, WikiPages,
 )
 
 __all__ = [
-    'User', 'Users', 'Project', 'Projects', 'UserStory', 'UserStories',
+    'Epic', 'Epics', 'User', 'Users', 'Project', 'Projects', 'UserStory', 'UserStories',
     'UserStoryAttachment', 'UserStoryAttachments', 'Task', 'Tasks',
     'TaskAttachment', 'TaskAttachments', 'Issue', 'Issues', 'IssueAttachment',
     'IssueAttachments', 'Milestone', 'Milestones', 'Point', 'Points',
@@ -18,5 +18,5 @@ __all__ = [
     'History', 'IssueAttribute', 'IssueAttributes', 'TaskAttribute',
     'TaskAttributes', 'UserStoryAttribute', 'UserStoryAttributes',
     'Membership', 'Memberships', 'Role', 'Roles', 'IssueType', 'IssueTypes',
-    'Webhook', 'Webhooks'
+    'Webhook', 'Webhooks',
 ]
diff --git a/taiga/models/base.py b/taiga/models/base.py
index cd6b4eb..90fa1d0 100644
--- a/taiga/models/base.py
+++ b/taiga/models/base.py
@@ -232,7 +232,10 @@ class InstanceResource(Resource):
         return cls(requester, **entry)
 
     def __repr__(self):
-        return '{0}({1})'.format(self.__class__.__name__, self.id)
+        try:
+            return '{0}({1})'.format(self.__class__.__name__, self.id)
+        except AttributeError:
+            return '{0}({1})'.format(self.__class__.__name__, id(self))
 
     def __str__(self):
         return self._rp()
diff --git a/taiga/models/models.py b/taiga/models/models.py
index 20d54cc..f389253 100644
--- a/taiga/models/models.py
+++ b/taiga/models/models.py
@@ -267,6 +267,126 @@ class UserStoryAttachments(Attachments):
     instance = UserStoryAttachment
 
 
+class EpicAttachment(Attachment):
+    """
+    EpicAttachment class
+    """
+    endpoint = 'epics/attachments'
+
+
+class EpicAttachments(Attachments):
+    """
+    EpicAttachments factory class
+    """
+    instance = EpicAttachment
+
+
+class Epic(CustomAttributeResource, CommentableResource):
+    """
+    Epic model
+
+    :param assigned_to: assigned to property of the :class:`Epic`
+    :param blocked_note: blocked note of the :class:`Epic`
+    :param description: description of of the :class:`Epic`
+    :param is_blocked: is blocked property of the :class:`Epic`
+    :param is_closed: is closed property of the :class:`Epic`
+    :param color: the color of the :class:`Epic`
+    :param project: the project of the :class:`TaskStatus`
+    :param subject: subject of the :class:`TaskStatus`
+    :param tags: tags of the :class:`TaskStatus`
+    :param watchers: watchers of the :class:`TaskStatus`
+    """
+
+    endpoint = 'epics'
+
+    repr_attribute = 'subject'
+
+    allowed_params = [
+        'assigned_to', 'blocked_note', 'description',
+        'is_blocked', 'is_closed', 'color', 'project',
+        'subject', 'tags', 'watchers'
+    ]
+
+    def list_user_stories(self, **queryparams):
+        """
+        Returns the :class:`UserStory` list of the project.
+        """
+        return UserStories(self.requester).list(epic=self.id, **queryparams)
+
+    def list_attachments(self):
+        """
+        Get a list of :class:`EpicAttachment`.
+        """
+        return EpicAttachments(self.requester).list(object_id=self.id)
+
+    def attach(self, attached_file, **attrs):
+        """
+        Attach a file to the :class:`Epic`
+
+        :param attached_file: file path to attach
+        :param attrs: optional attributes for the attached file
+        """
+        return EpicAttachments(self.requester).create(
+            self.project, self.id,
+            attached_file, **attrs
+        )
+
+
+class Epics(ListResource):
+    """
+    Epics factory class
+    """
+    instance = Epic
+
+    def create(self, project, subject, **attrs):
+        """
+        Create a new :class:`Epic`.
+
+        :param project: :class:`Project` id
+        :param subject: subject of the :class:`Epic`
+        :param attrs: optional attributes of the :class:`Epic`
+        """
+        attrs.update({'project': project, 'subject': subject})
+        return self._new_resource(payload=attrs)
+
+
+class EpicStatus(InstanceResource):
+    """
+    Taiga Epic Status model
+
+    :param color: the color of the :class:`EpicStatus`
+    :param is_closed: closed property of the :class:`EpicStatus`
+    :param name: The name of the :class:`EpicStatus`
+    :param order: order of the :class:`EpicStatus`
+    :param project: the Taiga project of the :class:`EpicStatus`
+    :param slug: the slug of the :class:`EpicStatus`
+    """
+
+    repr_attribute = 'subject'
+
+    endpoint = 'epic-statuses'
+
+    allowed_params = [
+        'color', 'is_closed', 'name', 'order', 'project', 'slug`'
+    ]
+
+
+class EpicStatuses(ListResource):
+
+    instance = EpicStatus
+
+    def create(self, project, name, **attrs):
+        """
+        Create a new :class:`EpicStatus`.
+
+        :param project: :class:`Project` id
+        :param name: name of the :class:`EpicStatus`
+        :param attrs: optional attributes of the :class:`EpicStatus`
+        """
+        attrs.update({'project': project, 'name': name})
+        return self._new_resource(payload=attrs)
+
+
 class UserStory(CustomAttributeResource, CommentableResource):
     """
     User Story model
@@ -1021,6 +1141,20 @@ class Project(InstanceResource):
         )
         return Task.parse(self.requester, response.json())
 
+    def get_epic_by_ref(self, ref):
+        """
+        Get a :class:`Epic` by ref.
+
+        :param ref: :class:`Epic` reference
+        """
+        response = self.requester.get(
+            '/{endpoint}/by_ref?ref={ep_ref}&project={project_id}',
+            endpoint=Epic.endpoint,
+            ep_ref=ref,
+            project_id=self.id
+        )
+        return Epic.parse(self.requester, response.json())
+
     def get_userstory_by_ref(self, ref):
         """
         Get a :class:`UserStory` by ref.
@@ -1269,6 +1403,23 @@ class Project(InstanceResource):
         """
         return Points(self.requester).list(project=self.id)
 
+    def add_epic(self, subject, **attrs):
+        """
+        Adds a :class:`UserStory` and returns a :class:`UserStory` resource.
+
+        :param subject: subject of the :class:`UserStory`
+        :param attrs: other :class:`UserStory` attributes
+        """
+        return Epics(self.requester).create(
+            self.id, subject, **attrs
+        )
+
+    def list_epics(self):
+        """
+        Get the list of :class:`Epic` resources for the project.
+        """
+        return Epics(self.requester).list(project=self.id)
+
     def add_task_status(self, name, **attrs):
         """
         Add a Task status to the project and returns a
@@ -1516,6 +1667,30 @@ class Project(InstanceResource):
         """
         return Webhooks(self.requester).list(project=self.id)
 
+    def add_tag(self, tag, color=None):
+        """
+        Add a new tag and return a response object.
+
+        :param tag: name of the tag
+        :param color: optional color of the tag
+        """
+        attrs = {'tag': tag}
+        if color:
+            attrs['color'] = color
+        response = self.requester.post(
+            "/{}/{}/create_tag".format(self.endpoint, self.id), payload=attrs
+        )
+        return response
+
+    def list_tags(self):
+        """
+        Get the list of tags for the project.
+        """
+        response = self.requester.get(
+            "/{}/{}/tags_colors".format(self.endpoint, self.id)
+        )
+        return response.json()
+
 
 class Projects(ListResource):
     """


Test patch:
diff --git a/tests/resources/epic_details_success.json b/tests/resources/epic_details_success.json
new file mode 100644
index 0000000..f726e66
--- /dev/null
+++ b/tests/resources/epic_details_success.json
@@ -0,0 +1,78 @@
+{
+    "assigned_to": null,
+    "assigned_to_extra_info": null,
+    "attachments": [],
+    "blocked_note": "",
+    "blocked_note_html": "",
+    "client_requirement": false,
+    "color": "#a40000",
+    "comment": "",
+    "created_date": "2017-06-28T14:56:50.437Z",
+    "description": "Description of the epic",
+    "description_html": "<p>Placeat ipsa quaerat consequuntur animi, dignissimos fugit perferendis quibusdam, eos autem unde deserunt alias voluptatum nisi ducimus ipsum in sed consequuntur? Repudiandae corporis soluta, aut aperiam saepe est atque alias nihil consequuntur adipisci odio aspernatur, iure alias atque, asperiores natus beatae. Cum harum beatae facilis nostrum quibusdam sapiente, deleniti aperiam aliquam ad eaque praesentium maiores iure repudiandae pariatur, eligendi quisquam eaque dignissimos atque doloremque totam quod blanditiis earum, atque praesentium hic nisi facere cum libero unde laboriosam, obcaecati minus pariatur ad vel iure ipsa. Dolore modi at consequatur autem excepturi inventore vel est aliquam, blanditiis laborum nam delectus quas assumenda necessitatibus, repellat nesciunt non laborum totam voluptatum, perferendis debitis similique nemo ullam iusto beatae nesciunt.</p>",
+    "epics_order": 1498661810437,
+    "id": 1,
+    "is_blocked": false,
+    "is_closed": false,
+    "is_voter": true,
+    "is_watcher": false,
+    "modified_date": "2017-06-28T14:56:50.649Z",
+    "neighbors": {
+        "next": {
+            "id": 2,
+            "ref": 107,
+            "subject": "Add setting to allow regular users to create folders at the root level."
+        },
+        "previous": {
+            "id": 32,
+            "ref": 110,
+            "subject": "New epic"
+        }
+    },
+    "owner": 12,
+    "owner_extra_info": {
+        "big_photo": null,
+        "full_name_display": "Vanesa Garcia",
+        "gravatar_id": "74cb769a5e64d445b8550789e1553502",
+        "id": 12,
+        "is_active": true,
+        "photo": null,
+        "username": "user6"
+    },
+    "project": 1,
+    "project_extra_info": {
+        "id": 1,
+        "logo_small_url": null,
+        "name": "Project Example 0",
+        "slug": "project-0"
+    },
+    "ref": 106,
+    "status": 4,
+    "status_extra_info": {
+        "color": "#fcc000",
+        "is_closed": false,
+        "name": "Ready for test"
+    },
+    "subject": "Added file copying and processing of images (resizing)",
+    "tags": [
+        [
+            "reprehenderit",
+            null
+        ],
+        [
+            "pariatur",
+            null
+        ]
+    ],
+    "team_requirement": false,
+    "total_voters": 6,
+    "total_watchers": 1,
+    "user_stories_counts": {
+        "progress": 0.4,
+        "total": 3
+    },
+    "version": 1,
+    "watchers": [
+        11
+    ]
+}
diff --git a/tests/resources/epics_list_success.json b/tests/resources/epics_list_success.json
new file mode 100644
index 0000000..01408b8
--- /dev/null
+++ b/tests/resources/epics_list_success.json
@@ -0,0 +1,64 @@
+[
+    {
+        "assigned_to": null,
+        "assigned_to_extra_info": null,
+        "attachments": [],
+        "blocked_note": "",
+        "client_requirement": false,
+        "color": "#a40000",
+        "created_date": "2017-06-28T14:56:50.437Z",
+        "epics_order": 1498661810437,
+        "id": 1,
+        "is_blocked": false,
+        "is_closed": false,
+        "is_voter": true,
+        "is_watcher": false,
+        "modified_date": "2017-06-28T14:56:50.649Z",
+        "owner": 12,
+        "owner_extra_info": {
+            "big_photo": null,
+            "full_name_display": "Vanesa Garcia",
+            "gravatar_id": "74cb769a5e64d445b8550789e1553502",
+            "id": 12,
+            "is_active": true,
+            "photo": null,
+            "username": "user6"
+        },
+        "project": 1,
+        "project_extra_info": {
+            "id": 1,
+            "logo_small_url": null,
+            "name": "Project Example 0",
+            "slug": "project-0"
+        },
+        "ref": 106,
+        "status": 4,
+        "status_extra_info": {
+            "color": "#fcc000",
+            "is_closed": false,
+            "name": "Ready for test"
+        },
+        "subject": "Added file copying and processing of images (resizing)",
+        "tags": [
+            [
+                "reprehenderit",
+                null
+            ],
+            [
+                "pariatur",
+                null
+            ]
+        ],
+        "team_requirement": false,
+        "total_voters": 6,
+        "total_watchers": 1,
+        "user_stories_counts": {
+            "progress": 0.4,
+            "total": 3
+        },
+        "version": 1,
+        "watchers": [
+            11
+        ]
+    }
+]
diff --git a/tests/test_epics.py b/tests/test_epics.py
new file mode 100644
index 0000000..e9b8adf
--- /dev/null
+++ b/tests/test_epics.py
@@ -0,0 +1,108 @@
+import unittest
+
+import six
+from mock import patch
+
+from taiga import TaigaAPI
+from taiga.exceptions import TaigaException
+from taiga.models import Epic, Epics
+from taiga.requestmaker import RequestMaker
+
+from .tools import MockResponse, create_mock_json
+
+if six.PY2:
+    import_open = '__builtin__.open'
+else:
+    import_open = 'builtins.open'
+
+
+class TestEpics(unittest.TestCase):
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    def test_list_attachments(self, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(
+            200,
+            create_mock_json('tests/resources/epics_list_success.json')
+        )
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        Epic(rm, id=1).list_attachments()
+        mock_requestmaker_get.assert_called_with(
+            'epics/attachments',
+            query={"object_id": 1},
+            paginate=True
+        )
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    def test_single_epic_parsing(self, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(
+            200,
+            create_mock_json('tests/resources/epic_details_success.json')
+        )
+        api = TaigaAPI(token='f4k3')
+        epic = api.epics.get(1)
+        self.assertEqual(epic.description, 'Description of the epic')
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    def test_list_epics_parsing(self, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(
+            200,
+            create_mock_json('tests/resources/epics_list_success.json')
+        )
+        api = TaigaAPI(token='f4k3')
+        epics = api.epics.list()
+        print(epics)
+        # TODO: check this:
+        # self.assertEqual(epics[0].description, 'Description of the Epic')
+        self.assertEqual(len(epics), 1)
+
+    @patch(import_open)
+    @patch('taiga.models.base.ListResource._new_resource')
+    def test_file_attach(self, mock_new_resource, mock_open):
+        fd = open('tests/resources/tasks_list_success.json')
+        mock_open.return_value = fd
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        epic = Epic(rm, id=1, project=1)
+        epic.attach('tests/resources/tasks_list_success.json')
+        mock_new_resource.assert_called_with(
+            files={'attached_file': fd},
+            payload={'project': 1, 'object_id': 1}
+        )
+
+    @patch('taiga.models.base.ListResource._new_resource')
+    def test_open_file_attach(self, mock_new_resource):
+        fd = open('tests/resources/tasks_list_success.json')
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        epic = Epic(rm, id=1, project=1)
+        epic.attach(fd)
+        mock_new_resource.assert_called_with(
+            files={'attached_file': fd},
+            payload={'project': 1, 'object_id': 1}
+        )
+
+    def test_not_existing_file_attach(self):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        epic = Epic(rm, id=1, project=1)
+        self.assertRaises(TaigaException, epic.attach, 'not-existing-file')
+
+    def test_not_valid_type_file_attach(self):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        epic = Epic(rm, id=1, project=1)
+        self.assertRaises(TaigaException, epic.attach, 4)
+
+    @patch('taiga.models.base.ListResource._new_resource')
+    def test_create_epic(self, mock_new_resource):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        mock_new_resource.return_value = Epic(rm)
+        Epics(rm).create(1, 'Epic 1')
+        mock_new_resource.assert_called_with(
+            payload={'project': 1, 'subject': 'Epic 1'}
+        )
+
+    @patch('taiga.models.base.InstanceResource.update')
+    def test_add_comment(self, mock_update):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        epic = Epic(rm, id=1)
+        epic.add_comment('hola')
+        mock_update.assert_called_with(
+            comment='hola'
+        )
diff --git a/tests/test_history.py b/tests/test_history.py
index 175000b..3ebb1f8 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -9,6 +9,16 @@ from .tools import MockResponse, create_mock_json
 
 class TestHistory(unittest.TestCase):
 
+    @patch('taiga.requestmaker.RequestMaker.get')
+    def test_history_repr(self, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(
+            200,
+            create_mock_json('tests/resources/history_success.json')
+        )
+        api = TaigaAPI(token='f4k3')
+        api.history.issue.get(1)
+        self.assertTrue('History' in str(api.history))
+
     @patch('taiga.requestmaker.RequestMaker.get')
     def test_issue(self, mock_requestmaker_get):
         mock_requestmaker_get.return_value = MockResponse(
diff --git a/tests/test_projects.py b/tests/test_projects.py
index d667e17..ae573a1 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -497,3 +497,17 @@ class TestProjects(unittest.TestCase):
         project = Project(rm, id=1)
         project.list_webhooks()
         mock_list_webhooks.assert_called_with(project=1)
+
+    @patch('taiga.models.Epics.create')
+    def test_add_epic(self, mock_new_epic):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.add_epic('Epic 1')
+        mock_new_epic.assert_called_with(1, 'Epic 1')
+
+    @patch('taiga.models.Epics.list')
+    def test_list_epics(self, mock_list_epics):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.list_epics()
+        mock_list_epics.assert_called_with(project=1)