Repository: line/line-bot-sdk-python
Pull request #184 (2019-06-20 06:27:18)

Impl delivery/{push, multicast, reply}
#183


Patch:
diff --git a/examples/flask-kitchensink/app.py b/examples/flask-kitchensink/app.py
index 48395d5..c11d447 100644
--- a/examples/flask-kitchensink/app.py
+++ b/examples/flask-kitchensink/app.py
@@ -52,11 +52,8 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1, x_proto=1)
 # get channel_secret and channel_access_token from your environment variable
 channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
 channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
-if channel_secret is None:
-    print('Specify LINE_CHANNEL_SECRET as environment variable.')
-    sys.exit(1)
-if channel_access_token is None:
-    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
+if channel_secret is None or channel_access_token is None:
+    print('Specify LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN as environment variables.')
     sys.exit(1)
 
 line_bot_api = LineBotApi(channel_access_token)
@@ -379,7 +376,7 @@ def handle_location_message(event):
     line_bot_api.reply_message(
         event.reply_token,
         LocationSendMessage(
-            title=event.message.title, address=event.message.address,
+            title='Location', address=event.message.address,
             latitude=event.message.latitude, longitude=event.message.longitude
         )
     )
@@ -445,13 +442,14 @@ def handle_file_message(event):
 
 @handler.add(FollowEvent)
 def handle_follow(event):
+    app.logger.info("Got Follow event:" + event.source.user_id)
     line_bot_api.reply_message(
         event.reply_token, TextSendMessage(text='Got follow event'))
 
 
 @handler.add(UnfollowEvent)
-def handle_unfollow():
-    app.logger.info("Got Unfollow event")
+def handle_unfollow(event):
+    app.logger.info("Got Unfollow event:" + event.source.user_id)
 
 
 @handler.add(JoinEvent)
diff --git a/linebot/api.py b/linebot/api.py
index c345e75..e5bfa01 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -23,8 +23,9 @@ from .exceptions import LineBotApiError
 from .http_client import HttpClient, RequestsHttpClient
 from .models import (
     Error, Profile, MemberIds, Content, RichMenuResponse, MessageQuotaResponse,
-    MessageQuotaConsumptionResponse, MessageDeliveryBroadcastResponse, IssueLinkTokenResponse,
-    IssueChannelTokenResponse,
+    MessageQuotaConsumptionResponse, IssueLinkTokenResponse, IssueChannelTokenResponse,
+    MessageDeliveryBroadcastResponse, MessageDeliveryMulticastResponse,
+    MessageDeliveryPushResponse, MessageDeliveryReplyResponse,
 )
 
 
@@ -220,6 +221,69 @@ class LineBotApi(object):
 
         return MessageDeliveryBroadcastResponse.new_from_json_dict(response.json)
 
+    def get_message_delivery_reply(self, date, timeout=None):
+        """Get number of sent reply messages.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-number-of-reply-messages
+
+        Gets the number of messages sent with the /bot/message/reply endpoint.
+
+        :param str date: Date the messages were sent. The format is `yyyyMMdd` (Timezone is UTC+9).
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        """
+        response = self._get(
+            '/v2/bot/message/delivery/reply?date={date}'.format(date=date),
+            timeout=timeout
+        )
+
+        return MessageDeliveryReplyResponse.new_from_json_dict(response.json)
+
+    def get_message_delivery_push(self, date, timeout=None):
+        """Get number of sent push messages.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-number-of-push-messages
+
+        Gets the number of messages sent with the /bot/message/push endpoint.
+
+        :param str date: Date the messages were sent. The format is `yyyyMMdd` (Timezone is UTC+9).
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        """
+        response = self._get(
+            '/v2/bot/message/delivery/push?date={date}'.format(date=date),
+            timeout=timeout
+        )
+
+        return MessageDeliveryPushResponse.new_from_json_dict(response.json)
+
+    def get_message_delivery_multicast(self, date, timeout=None):
+        """Get number of sent multicast messages.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-number-of-multicast-messages
+
+        Gets the number of messages sent with the /bot/message/multicast endpoint.
+
+        :param str date: Date the messages were sent. The format is `yyyyMMdd` (Timezone is UTC+9).
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        """
+        response = self._get(
+            '/v2/bot/message/delivery/multicast?date={date}'.format(date=date),
+            timeout=timeout
+        )
+
+        return MessageDeliveryMulticastResponse.new_from_json_dict(response.json)
+
     def get_profile(self, user_id, timeout=None):
         """Call get profile API.
 
diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py
index 1a3346a..ce6ce1d 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -99,6 +99,9 @@ from .responses import (  # noqa
     MessageQuotaResponse,
     MessageQuotaConsumptionResponse,
     MessageDeliveryBroadcastResponse,
+    MessageDeliveryReplyResponse,
+    MessageDeliveryMulticastResponse,
+    MessageDeliveryPushResponse,
     Content as MessageContent,  # backward compatibility,
     IssueLinkTokenResponse,
     IssueChannelTokenResponse,
diff --git a/linebot/models/events.py b/linebot/models/events.py
index 0eb4b98..90bd3ca 100644
--- a/linebot/models/events.py
+++ b/linebot/models/events.py
@@ -36,7 +36,7 @@ from linebot.models.things import (
     DeviceLink,
     ScenarioResult,
 )
-from linebot.models.things import Things  # noqa
+from linebot.models.things import Things  # noqa, backward compatibility
 
 
 class Event(with_metaclass(ABCMeta, Base)):
diff --git a/linebot/models/messages.py b/linebot/models/messages.py
index f541d29..9b719ad 100644
--- a/linebot/models/messages.py
+++ b/linebot/models/messages.py
@@ -74,7 +74,7 @@ class ImageMessage(Message):
         :param str id: Message ID
         :param content_provider: ContentProvider object
         :type content_provider:
-            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
+            :py:class:`linebot.models.messages.ContentProvider`
         :param kwargs:
         """
         super(ImageMessage, self).__init__(id=id, **kwargs)
@@ -101,7 +101,7 @@ class VideoMessage(Message):
         :param long duration: Length of video file (milliseconds)
         :param content_provider: ContentProvider object
         :type content_provider:
-            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
+            :py:class:`linebot.models.messages.ContentProvider`
         :param kwargs:
         """
         super(VideoMessage, self).__init__(id=id, **kwargs)
@@ -129,7 +129,7 @@ class AudioMessage(Message):
         :param long duration: Length of audio file (milliseconds)
         :param content_provider: ContentProvider object
         :type content_provider:
-            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
+            :py:class:`linebot.models.messages.ContentProvider`
         :param kwargs:
         """
         super(AudioMessage, self).__init__(id=id, **kwargs)
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index 19b75f8..dc644e2 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -190,10 +190,7 @@ class MessageQuotaConsumptionResponse(Base):
 
 
 class MessageDeliveryBroadcastResponse(Base):
-    """MessageDeliveryBroadcastResponse.
-
-    https://developers.line.biz/en/reference/messaging-api/#get-number-of-broadcast-messages
-    """
+    """MessageDeliveryBroadcastResponse."""
 
     def __init__(self, status=None, success=None, **kwargs):
         """__init__ method.
@@ -209,6 +206,57 @@ class MessageDeliveryBroadcastResponse(Base):
         self.success = success
 
 
+class MessageDeliveryReplyResponse(Base):
+    """MessageDeliveryReplyResponse."""
+
+    def __init__(self, status=None, success=None, **kwargs):
+        """__init__ method.
+
+        :param str status: Status of the counting process.
+        :param int success: The number of messages sent with the Messaging API on the
+            date specified in date.
+        :param kwargs:
+        """
+        super(MessageDeliveryReplyResponse, self).__init__(**kwargs)
+
+        self.status = status
+        self.success = success
+
+
+class MessageDeliveryPushResponse(Base):
+    """MessageDeliveryPushResponse."""
+
+    def __init__(self, status=None, success=None, **kwargs):
+        """__init__ method.
+
+        :param str status: Status of the counting process.
+        :param int success: The number of messages sent with the Messaging API on the
+            date specified in date.
+        :param kwargs:
+        """
+        super(MessageDeliveryPushResponse, self).__init__(**kwargs)
+
+        self.status = status
+        self.success = success
+
+
+class MessageDeliveryMulticastResponse(Base):
+    """MessageDeliveryMulticastResponse."""
+
+    def __init__(self, status=None, success=None, **kwargs):
+        """__init__ method.
+
+        :param str status: Status of the counting process.
+        :param int success: The number of messages sent with the Messaging API on the
+            date specified in date.
+        :param kwargs:
+        """
+        super(MessageDeliveryMulticastResponse, self).__init__(**kwargs)
+
+        self.status = status
+        self.success = success
+
+
 class IssueLinkTokenResponse(Base):
     """IssueLinkTokenResponse.
 
diff --git a/linebot/models/template.py b/linebot/models/template.py
index 83b93c8..00da4ef 100644
--- a/linebot/models/template.py
+++ b/linebot/models/template.py
@@ -81,7 +81,7 @@ class ButtonsTemplate(Template):
     def __init__(self, text=None, title=None, thumbnail_image_url=None,
                  image_aspect_ratio=None,
                  image_size=None, image_background_color=None,
-                 actions=None, **kwargs):
+                 actions=None, default_action=None, **kwargs):
         """__init__ method.
 
         :param str text: Message text.
@@ -105,6 +105,9 @@ class ButtonsTemplate(Template):
         :param actions: Action when tapped.
             Max: 4
         :type actions: list[T <= :py:class:`linebot.models.actions.Action`]
+        :param default_action: Action when image is tapped;
+            set for the entire image, title, and text area
+        :type default_action: T <= :py:class:`linebot.models.actions.Action`
         :param kwargs:
         """
         super(ButtonsTemplate, self).__init__(**kwargs)
@@ -117,6 +120,7 @@ class ButtonsTemplate(Template):
         self.image_size = image_size
         self.image_background_color = image_background_color
         self.actions = get_actions(actions)
+        self.default_action = get_action(default_action)
 
 
 class ConfirmTemplate(Template):
@@ -214,8 +218,9 @@ class CarouselColumn(Base):
     https://developers.line.biz/en/reference/messaging-api/#column-object
     """
 
-    def __init__(self, text=None, title=None, thumbnail_image_url=None,
-                 image_background_color=None, actions=None, **kwargs):
+    def __init__(self, text=None, title=None,
+                 thumbnail_image_url=None, image_background_color=None,
+                 actions=None, default_action=None, **kwargs):
         """__init__ method.
 
         :param str text: Message text.
@@ -230,6 +235,9 @@ class CarouselColumn(Base):
         :param actions: Action when tapped.
             Max: 3
         :type actions: list[T <= :py:class:`linebot.models.actions.Action`]
+        :param default_action: Action when image is tapped;
+            set for the entire image, title, and text area
+        :type default_action: T <= :py:class:`linebot.models.actions.Action`
         :param kwargs:
         """
         super(CarouselColumn, self).__init__(**kwargs)
@@ -239,6 +247,7 @@ class CarouselColumn(Base):
         self.thumbnail_image_url = thumbnail_image_url
         self.image_background_color = image_background_color
         self.actions = get_actions(actions)
+        self.default_action = get_action(default_action)
 
 
 class ImageCarouselColumn(Base):


Test patch:
diff --git a/tests/api/test_get_delivery.py b/tests/api/test_get_delivery.py
new file mode 100644
index 0000000..ced7eb9
--- /dev/null
+++ b/tests/api/test_get_delivery.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+        self.date = '20190101'
+
+    @responses.activate
+    def test_get_delivery_broadcast(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/delivery/broadcast?date={}'.format(self.date),
+            json={
+                'status': 'ready',
+                'success': 10000
+            },
+            status=200
+        )
+
+        res = self.tested.get_message_delivery_broadcast(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(10000, res.success)
+
+    @responses.activate
+    def test_get_delivery_push(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/delivery/push?date={}'.format(self.date),
+            json={
+                'status': 'ready',
+                'success': 10000
+            },
+            status=200
+        )
+
+        res = self.tested.get_message_delivery_push(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(10000, res.success)
+
+    @responses.activate
+    def test_get_delivery_reply(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/delivery/reply?date={}'.format(self.date),
+            json={
+                'status': 'ready',
+                'success': 10000
+            },
+            status=200
+        )
+
+        res = self.tested.get_message_delivery_reply(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(10000, res.success)
+
+    @responses.activate
+    def test_get_delivery_multicast(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/delivery/multicast?date={}'.format(self.date),
+            json={
+                'status': 'ready',
+                'success': 10000
+            },
+            status=200
+        )
+
+        res = self.tested.get_message_delivery_multicast(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(10000, res.success)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_get_message_quota.py b/tests/api/test_get_message_quota.py
new file mode 100644
index 0000000..8192e9a
--- /dev/null
+++ b/tests/api/test_get_message_quota.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_get_message_quota(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/quota',
+            json={
+                'type': 'limited',
+                'value': 1000
+            },
+            status=200
+        )
+        res = self.tested.get_message_quota()
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('limited', res.type)
+        self.assertEqual(1000, res.value)
+
+    @responses.activate
+    def test_get_message_quota_2(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/quota',
+            json={'type': 'none'},
+            status=200
+        )
+        res = self.tested.get_message_quota()
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('none', res.type)
+
+    @responses.activate
+    def test_get_message_quota_consumption(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/quota/consumption',
+            json={'totalUsage': 500},
+            status=200
+        )
+        res = self.tested.get_message_quota_consumption()
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(500, res.total_usage)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_rich_menu.py b/tests/api/test_rich_menu.py
index 383758f..b770c63 100644
--- a/tests/api/test_rich_menu.py
+++ b/tests/api/test_rich_menu.py
@@ -388,6 +388,56 @@ class TestLineBotApi(unittest.TestCase):
             LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/user/all/richmenu'
         )
 
+    @responses.activate
+    def test_get_rich_menu_image(self):
+        rich_menu_id = '1234'
+        body = b'hogieoidksk'
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/richmenu/{rich_menu_id}/content'.format(rich_menu_id=rich_menu_id),
+            body=body, status=200
+        )
+
+        res = self.tested.get_rich_menu_image(rich_menu_id)
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/richmenu/{rich_menu_id}/content'.format(rich_menu_id=rich_menu_id),
+        )
+        self.assertEqual(
+            body,
+            res.content
+        )
+
+    @responses.activate
+    def test_set_rich_menu_image(self):
+        rich_menu_id = '1234'
+        body = b'hogieoidksk'
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/richmenu/{rich_menu_id}/content'.format(rich_menu_id=rich_menu_id),
+            json={}, status=200
+        )
+
+        self.tested.set_rich_menu_image(
+            rich_menu_id=rich_menu_id,
+            content_type='image/jpeg',
+            content=body
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual('POST', request.method)
+        self.assertEqual(
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/richmenu/{rich_menu_id}/content'.format(rich_menu_id=rich_menu_id),
+            request.url
+        )
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/models/serialize_test_case.py b/tests/models/serialize_test_case.py
new file mode 100644
index 0000000..a9dee1e
--- /dev/null
+++ b/tests/models/serialize_test_case.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import sys
+import unittest
+from numbers import Number
+
+from linebot.models import (
+    Base,
+)
+from linebot.utils import to_camel_case
+
+PY3 = sys.version_info[0] == 3
+
+
+class SerializeTestCase(unittest.TestCase):
+    MESSAGE = 'message'
+    STICKER = 'sticker'
+    POSTBACK = 'postback'
+    CAMERA = 'camera'
+    CAMERA_ROLL = 'cameraRoll'
+    DATETIMEPICKER = 'datetimepicker'
+    URI = 'uri'
+    LOCATION = 'location'
+    FLEX = 'flex'
+    SPACER = 'spacer'
+    BUBBLE = 'bubble'
+    CAROUSEL = 'carousel'
+    BOX = 'box'
+    BUTTON = 'button'
+    FILLER = 'filler'
+    ICON = 'icon'
+    TEXT = 'text'
+    IMAGE = 'image'
+    VIDEO = 'video'
+    AUDIO = 'audio'
+    SEPARATOR = 'separator'
+    IMAGEMAP = 'imagemap'
+    ACTION = 'action'
+    TEMPLATE = 'template'
+    BUTTONS = 'buttons'
+    CONFIRM = 'confirm'
+    IMAGE_CAROUSEL = 'image_carousel'
+
+    def serialize_as_dict(self, obj, type=None):
+        if isinstance(obj, Base):
+            return obj.as_json_dict()
+        elif isinstance(obj, dict):
+            ret = {to_camel_case(k): self.serialize_as_dict(v) for k, v in obj.items()}
+            if type is not None:
+                ret['type'] = type
+            return ret
+        elif isinstance(obj, list):
+            return [self.serialize_as_dict(elem) for elem in obj]
+        else:
+            if PY3:
+                self.assertIsInstance(obj, (str, bool, Number))
+            else:
+                self.assertIsInstance(obj, (basestring, bool, Number))  # noqa
+            return obj
+
+    class ConstError(TypeError):
+        pass
+
+    def __setattr__(self, name, value):
+        if name in SerializeTestCase.__dict__:
+            raise self.ConstError("Can't rebind const (%s)" % name)
+        self.__dict__[name] = value
diff --git a/tests/models/test_actions.py b/tests/models/test_actions.py
new file mode 100644
index 0000000..6305488
--- /dev/null
+++ b/tests/models/test_actions.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+from linebot.models import (
+    PostbackAction,
+    MessageAction,
+    CameraRollAction,
+    CameraAction,
+    URIAction,
+    LocationAction,
+    DatetimePickerAction,
+    AltUri,
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestActions(SerializeTestCase):
+    def test_postback(self):
+        arg = {
+            'label': 'Buy',
+            'data': 'action=buy&id=1',
+            'display_text': 'buy'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.POSTBACK),
+            PostbackAction(**arg).as_json_dict()
+        )
+
+    def test_message(self):
+        arg = {
+            'label': 'Yes',
+            'text': 'yes'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.MESSAGE),
+            MessageAction(**arg).as_json_dict()
+        )
+
+    def test_camera(self):
+        arg = {
+            'label': 'camera'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.CAMERA),
+            CameraAction(**arg).as_json_dict()
+        )
+
+    def test_camera_roll(self):
+        arg = {
+            'label': 'camera roll'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.CAMERA_ROLL),
+            CameraRollAction(**arg).as_json_dict()
+        )
+
+    def test_datetime_picker(self):
+        arg = {
+            'label': 'Select date',
+            'data': 'storeId=12345',
+            'mode': 'datetime',
+            'initial': '2017-12-25t00:00',
+            'max': '2018-01-24t23:59',
+            'min': '2017-12-25t00:00'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.DATETIMEPICKER),
+            DatetimePickerAction(**arg).as_json_dict()
+        )
+
+    def test_uri(self):
+        arg = {
+            'label': 'View detail',
+            'uri': 'https://example.com',
+            'alt_uri': AltUri(desktop='https://example.com')
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.URI),
+            URIAction(**arg).as_json_dict()
+        )
+
+    def test_location(self):
+        arg = {
+            'label': 'Location'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.LOCATION),
+            LocationAction(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/models/test_flex_message.py b/tests/models/test_flex_message.py
new file mode 100644
index 0000000..ac01108
--- /dev/null
+++ b/tests/models/test_flex_message.py
@@ -0,0 +1,225 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the 'License'); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+from linebot.models import (
+    FlexSendMessage,
+    BlockStyle,
+    BubbleStyle,
+    BubbleContainer,
+    CarouselContainer,
+    BoxComponent,
+    TextComponent,
+    SeparatorComponent,
+    ImageComponent,
+    ButtonComponent,
+    FillerComponent,
+    IconComponent,
+    SpacerComponent,
+    URIAction,
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestFlexMessage(SerializeTestCase):
+    def test_flex_message(self):
+        arg = {
+            'alt_text': 'this is a flex message',
+            'contents':
+                BubbleContainer(
+                    body=BoxComponent(
+                        layout='vertical',
+                        contents=[
+                            TextComponent(text='hello'),
+                            TextComponent(text='world')
+                        ]
+                    )
+                )
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.FLEX),
+            FlexSendMessage(**arg).as_json_dict()
+        )
+
+    def test_bubble_container(self):
+        arg = {
+            'header':
+                BoxComponent(layout='vertical',
+                             contents=[TextComponent(text='Header text')]),
+            'hero':
+                ImageComponent(uri='https://example.com/flex/images/image.jpg'),
+            'body':
+                BoxComponent(layout='vertical',
+                             contents=[TextComponent(text='Body text')]),
+            'footer':
+                BoxComponent(layout='vertical',
+                             contents=[TextComponent(text='Footer text')]),
+            'styles':
+                BubbleStyle(
+                    header=BlockStyle(background_color='#00ffff'),
+                    hero=BlockStyle(background_color='#00ffff',
+                                    separator=True),
+                    footer=BlockStyle(background_color='#00ffff',
+                                      separator=True,
+                                      separator_color='#00ffff')
+                )
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.BUBBLE),
+            BubbleContainer(**arg).as_json_dict()
+        )
+
+    def test_bubble_style(self):
+        arg = {
+            'header':
+                BlockStyle(background_color='#00ffff'),
+            'hero':
+                BlockStyle(background_color='#00ffff',
+                           separator=True),
+            'footer':
+                BlockStyle(background_color='#00ffff',
+                           separator=True,
+                           separator_color='#00ffff')
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            BubbleStyle(**arg).as_json_dict()
+        )
+
+    def test_block_style(self):
+        arg = {
+            'background_color': '#00ffff',
+            'separator': True,
+            'separator_color': '#000000'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            BlockStyle(**arg).as_json_dict()
+        )
+
+    def test_carousel_container(self):
+        arg = {
+            'contents': [
+                BubbleContainer(
+                    body=BoxComponent(
+                        layout='vertical',
+                        contents=[TextComponent(text='Hey')]
+                    )
+                ),
+                BubbleContainer(
+                    body=BoxComponent(
+                        layout='vertical',
+                        contents=[TextComponent(text='Foo')]
+                    )
+                ),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.CAROUSEL),
+            CarouselContainer(**arg).as_json_dict()
+        )
+
+    def test_box_component(self):
+        arg = {
+            'layout': 'vertical',
+            'contents': [
+                ImageComponent(url='https://example.com/flex/images/image.jpg'),
+                SeparatorComponent(),
+                TextComponent(text='Text in the box'),
+            ]
+        }
+
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.BOX),
+            BoxComponent(**arg).as_json_dict()
+        )
+
+    def test_button_component(self):
+        arg = {
+            'action':
+                URIAction(label='Tap me',
+                          uri='https://example.com'),
+            'style': 'primary',
+            'color': '#0000ff'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.BUTTON),
+            ButtonComponent(**arg).as_json_dict()
+        )
+
+    def test_filler_component(self):
+        arg = {}
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.FILLER),
+            FillerComponent(**arg).as_json_dict()
+        )
+
+    def test_icon_component(self):
+        arg = {
+            'url': 'https://example.com/icon/png/caution.png',
+            'size': 'lg',
+            'aspect_ratio': '1.91:1'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.ICON),
+            IconComponent(**arg).as_json_dict()
+        )
+
+    def test_image_component(self):
+        arg = {
+            'url': 'https://example.com/flex/images/image.jpg',
+            'size': 'full',
+            'aspect_ratio': '1.91:1'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.IMAGE),
+            ImageComponent(**arg).as_json_dict()
+        )
+
+    def test_separator_component(self):
+        arg = {
+            'color': '#000000'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.SEPARATOR),
+            SeparatorComponent(**arg).as_json_dict()
+        )
+
+    def test_spacer_component(self):
+        arg = {
+            'size': 'md'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.SPACER),
+            SpacerComponent(**arg).as_json_dict()
+        )
+
+    def test_text_component(self):
+        arg = {
+            'text': 'Hello, World!',
+            'size': 'xl',
+            'weight': 'bold',
+            'color': '#0000ff'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.TEXT),
+            TextComponent(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/models/test_imagemap.py b/tests/models/test_imagemap.py
new file mode 100644
index 0000000..814df0e
--- /dev/null
+++ b/tests/models/test_imagemap.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the 'License'); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+from linebot.models import (
+    ImagemapArea,
+    ImagemapSendMessage,
+    URIImagemapAction,
+    MessageImagemapAction,
+    Video, BaseSize, ExternalLink,
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestImageMap(SerializeTestCase):
+    def test_image_map(self):
+        arg = {
+            'base_url': 'https://example.com/bot/images/rm001',
+            'alt_text': 'This is an imagemap',
+            'base_size': BaseSize(width=1040, height=1040),
+            'video':
+                Video(
+                    original_content_url='https://example.com/video.mp4',
+                    preview_image_url='https://example.com/video_preview.jpg',
+                    area=ImagemapArea(x=0, y=0, width=1040, height=585),
+                    external_link=ExternalLink(label='See more',
+                                               link_uri='https://example.com/see_more.html')
+                ),
+            'actions': [
+                URIImagemapAction(
+                    link_uri='https://example.com/',
+                    area=ImagemapArea(x=0, y=585, width=520, height=454)
+                ),
+                MessageImagemapAction(
+                    text='Hey',
+                    area=ImagemapArea(x=0, y=58, width=52, height=40)
+                )
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.IMAGEMAP),
+            ImagemapSendMessage(**arg).as_json_dict()
+        )
+
+    def test_video(self):
+        arg = {
+            'original_content_url': 'https://example.com/video.mp4',
+            'preview_image_url': 'https://example.com/video_preview.jpg',
+            'area': ImagemapArea(x=0, y=0, width=1040, height=585),
+            'external_link': ExternalLink(label='See more',
+                                          link_uri='https://example.com/see_more.html')
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            Video(**arg).as_json_dict()
+        )
+
+    def test_message_actions(self):
+        arg = {
+            'text': 'Hey',
+            'area': ImagemapArea(x=0, y=58, width=52, height=40)
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.MESSAGE),
+            MessageImagemapAction(**arg).as_json_dict()
+        )
+
+    def test_uri_actions(self):
+        arg = {
+            'link_uri': 'https://example.com/',
+            'area': ImagemapArea(x=0, y=585, width=520, height=454)
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, self.URI),
+            URIImagemapAction(**arg).as_json_dict()
+        )
+
+    def test_base_size(self):
+        arg = {
+            'width': 1040,
+            'height': 1040
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            BaseSize(**arg).as_json_dict()
+        )
+
+    def test_external_link(self):
+        arg = {
+            'label': 'Hey link',
+            'link_uri': 'https://example.com/see_more.html'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            ExternalLink(**arg).as_json_dict()
+        )
+
+    def test_imagemap_area(self):
+        arg = {
+            'x': 111,
+            'y': 33,
+            'width': 1040,
+            'height': 1040
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            ImagemapArea(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/models/test_send_messages.py b/tests/models/test_send_messages.py
new file mode 100644
index 0000000..0e89911
--- /dev/null
+++ b/tests/models/test_send_messages.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the 'License'); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+from linebot.models import (
+    TextSendMessage,
+    StickerSendMessage,
+    VideoSendMessage,
+    AudioSendMessage,
+    LocationSendMessage,
+    ImageSendMessage,
+    QuickReplyButton,
+    QuickReply,
+    CameraRollAction,
+    CameraAction
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestSendMessages(SerializeTestCase):
+    def test_text_message(self):
+        arg = {
+            'text': 'Hello, world'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.TEXT),
+            TextSendMessage(**arg).as_json_dict()
+        )
+
+    def test_sticker_message(self):
+        arg = {
+            'package_id': '1',
+            'sticker_id': '1'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.STICKER),
+            StickerSendMessage(**arg).as_json_dict()
+        )
+
+    def test_image_message(self):
+        arg = {
+            'original_content_url': 'https://example.com/original.jpg',
+            'preview_image_url': 'https://example.com/preview.jpg'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.IMAGE),
+            ImageSendMessage(**arg).as_json_dict()
+        )
+
+    def test_video_message(self):
+        arg = {
+            'type': 'video',
+            'original_content_url': 'https://example.com/original.mp4',
+            'preview_image_url': 'https://example.com/preview.jpg'
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.VIDEO),
+            VideoSendMessage(**arg).as_json_dict()
+        )
+
+    def test_audio_message(self):
+        arg = {
+            'original_content_url': 'https://example.com/original.m4a',
+            'duration': 60000
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.AUDIO),
+            AudioSendMessage(**arg).as_json_dict()
+        )
+
+    def test_location_message(self):
+        arg = {
+            'title': 'my location',
+            'address': '〒150-0002 東京都渋谷区渋谷２丁目２１−１',
+            'latitude': 35.65910807942215,
+            'longitude': 139.70372892916203
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.LOCATION),
+            LocationSendMessage(**arg).as_json_dict()
+        )
+
+    def test_quick_reply_button(self):
+        arg = {
+            'action': CameraRollAction(label='Send photo')
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.ACTION),
+            QuickReplyButton(**arg).as_json_dict()
+        )
+
+    def test_quick_reply(self):
+        arg = {
+            'items': [
+                QuickReplyButton(action=CameraRollAction(label='Send photo')),
+                QuickReplyButton(action=CameraAction(label='Open camera')),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            QuickReply(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/models/test_template.py b/tests/models/test_template.py
new file mode 100644
index 0000000..0e029d3
--- /dev/null
+++ b/tests/models/test_template.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the 'License'); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+from linebot.models import (
+    TemplateSendMessage,
+    CarouselTemplate,
+    CarouselColumn,
+    ImageCarouselTemplate,
+    ImageCarouselColumn,
+    ConfirmTemplate,
+    ButtonsTemplate,
+    PostbackAction,
+    URIAction,
+    MessageAction,
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestTemplate(SerializeTestCase):
+    def test_template(self):
+        arg = {
+            'type': 'template',
+            'alt_text': 'This is a buttons template',
+            'template':
+                ButtonsTemplate(
+                    thumbnail_image_url='https=//example.com/bot/images/image.jpg',
+                    image_aspect_ratio='rectangle',
+                    image_size='cover',
+                    image_background_color='#FFFFFF',
+                    title='Menu',
+                    text='Please select',
+                    default_action=URIAction(label='View detail',
+                                             uri='http://example.com/page/123'),
+                    actions=[
+                        PostbackAction(label='Buy', data='action=buy&itemid=155'),
+                        URIAction(label='View detail', uri='http://example.com/page/155'),
+                    ]
+                )
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.TEMPLATE),
+            TemplateSendMessage(**arg).as_json_dict()
+        )
+
+    def test_button_template(self):
+        arg = {
+            'type': 'buttons',
+            'thumbnail_image_url': 'https=//example.com/bot/images/image.jpg',
+            'image_aspect_ratio': 'rectangle',
+            'image_size': 'cover',
+            'image_background_color': '#FFFFFF',
+            'title': 'Menu',
+            'text': 'Please select',
+            'default_action': URIAction(label='View detail',
+                                        uri='http://example.com/page/123'),
+            'actions': [
+                PostbackAction(label='Buy', data='action=buy&itemid=155'),
+                URIAction(label='View detail', uri='http://example.com/page/155'),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.BUTTONS),
+            ButtonsTemplate(**arg).as_json_dict()
+        )
+
+    def test_confirm_template(self):
+        arg = {
+            'type': 'confirm',
+            'text': 'Are you sure?',
+            'actions': [
+                MessageAction(label='Yes', text='yes...'),
+                MessageAction(label='No', text='no...'),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.CONFIRM),
+            ConfirmTemplate(**arg).as_json_dict()
+        )
+
+    def test_carousel_template(self):
+        arg = {
+            'image_aspect_ratio': 'rectangle',
+            'image_size': 'cover',
+            'columns': [
+                CarouselColumn(
+                    thumbnail_image_url='https://example.com/bot/images/item1.jpg',
+                    image_background_color='#FFFFFF',
+                    title='this is menu',
+                    text='description',
+                    default_action=URIAction(label='View detail',
+                                             uri='http://example.com/page/123'),
+                    actions=[
+                        PostbackAction(label='Buy', data='action=buy&itemid=155'),
+                        URIAction(label='View detail', uri='http://example.com/page/155'),
+                    ]
+                ),
+                CarouselColumn(
+                    thumbnail_image_url='https://example.com/bot/images/item2.jpg',
+                    image_background_color='#000000',
+                    title='this is menu',
+                    text='description',
+                    default_action=URIAction(label='View detail',
+                                             uri='http://example.com/page/222'),
+                    actions=[
+                        PostbackAction(label='Buy', data='action=buy&itemid=555'),
+                        URIAction(label='View detail', uri='http://example.com/page/555'),
+                    ]
+                )
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.CAROUSEL),
+            CarouselTemplate(**arg).as_json_dict()
+        )
+
+    def test_carousel_column(self):
+        arg = {
+            'thumbnail_image_url': 'https://example.com/bot/images/item1.jpg',
+            'image_background_color': '#FFFFFF',
+            'title': 'this is menu',
+            'text': 'description',
+            'default_action': URIAction(label='View detail',
+                                        uri='http://example.com/page/123'),
+            'actions': [
+                PostbackAction(label='Buy', data='action=buy&itemid=155'),
+                URIAction(label='View detail', uri='http://example.com/page/155'),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            CarouselColumn(**arg).as_json_dict()
+        )
+
+    def test_image_carousel_template(self):
+        arg = {
+            'columns': [
+                ImageCarouselColumn(
+                    image_url='https://example.com/bot/images/item1.jpg',
+                    action=PostbackAction(label='Buy', data='action=buy&itemid=555')
+                ),
+                ImageCarouselColumn(
+                    image_url='https://example.com/bot/images/item2.jpg',
+                    action=MessageAction(label='Yes', text='yes')
+                ),
+            ]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.IMAGE_CAROUSEL),
+            ImageCarouselTemplate(**arg).as_json_dict()
+        )
+
+    def test_image_carousel_column(self):
+        arg = {
+            'image_url': 'https://example.com/bot/images/item1.jpg',
+            'action': PostbackAction(label='Buy', data='action=buy&itemid=555')
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg),
+            ImageCarouselColumn(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()