Repos / pytaku / 18bf3eea7b
commit 18bf3eea7b8f262dfa21cb6a6a21499b15629536
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Wed Aug 12 19:57:16 2020 +0700

    add simple tests

diff --git a/.builds/ubuntu.yml b/.builds/ubuntu.yml
index f30e4ef..effecf0 100644
--- a/.builds/ubuntu.yml
+++ b/.builds/ubuntu.yml
@@ -7,6 +7,8 @@ secrets:
   - 8c42b8a6-d1b7-4af7-82f2-b8f1b6e085e2
   # ssh key for dev.pytaku.com:
   - 2d6e3246-5adc-41c2-bebe-01dacda9d0c8
+  # ~/pytaku.conf.json:
+  - d18b6657-ac13-4413-8349-8ef262142545
 
 environment:
   # Ugly hack to prepend to PATH:
@@ -34,6 +36,17 @@ tasks:
       source ~/venv/bin/activate
       curl https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -o get-poetry.py
       python get-poetry.py -y
+      cd pytaku
+      poetry install
+      pip install https://github.com/rogerbinns/apsw/releases/download/3.32.2-r1/apsw-3.32.2-r1.zip \
+            --global-option=fetch --global-option=--version --global-option=3.32.2 --global-option=--all \
+            --global-option=build --global-option=--enable-all-extensions
+
+  - test: |
+      cd pytaku
+      source ~/venv/bin/activate
+      mv ~/pytaku.conf.json ./
+      pytest
 
   - build: |
       cd pytaku
diff --git a/.gitignore b/.gitignore
index fd80dcd..5a972bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 /debug.py
+/test_secrets.json
 __pycache__
 *.pyc
 *.json
diff --git a/README.md b/README.md
index b7669df..8d993ff 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,10 @@ # run 2 processes:
 pytaku-scheduler  # scheduled tasks e.g. update titles
 ```
 
+## Tests
+
+Can be run with just `pytest`. It needs a pytaku.conf.json as well.
+
 # Production
 
 ```sh
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..fce0f6c
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,3 @@
+from pytaku.conf import config
+
+config.load()
diff --git a/poetry.lock b/poetry.lock
index 76c4725..6264af2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,12 @@
+[[package]]
+name = "appnope"
+version = "0.1.0"
+description = "Disable App Nap on OS X 10.9"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform == \"darwin\""
+
 [[package]]
 name = "argon2-cffi"
 version = "20.1.0"
@@ -15,6 +24,38 @@ tests = ["coverage (>=5.0.2)", "hypothesis", "pytest"]
 cffi = ">=1.0.0"
 six = "*"
 
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+marker = "sys_platform == \"win32\""
+
+[[package]]
+name = "attrs"
+version = "19.3.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
+dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
+docs = ["sphinx", "zope.interface"]
+tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
 [[package]]
 name = "certifi"
 version = "2020.6.20"
@@ -50,6 +91,24 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "colorama"
+version = "0.4.3"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+marker = "python_version >= \"3.4\" and sys_platform == \"win32\" or sys_platform == \"win32\""
+
+[[package]]
+name = "decorator"
+version = "4.4.2"
+description = "Decorators for Humans"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*"
+marker = "python_version >= \"3.4\""
+
 [[package]]
 name = "flask"
 version = "1.1.2"
@@ -107,6 +166,87 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "importlib-metadata"
+version = "1.7.0"
+description = "Read metadata from Python packages"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+marker = "python_version < \"3.8\""
+
+[package.extras]
+docs = ["sphinx", "rst.linker"]
+testing = ["packaging", "pep517", "importlib-resources (>=1.3)"]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[[package]]
+name = "iniconfig"
+version = "1.0.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "ipdb"
+version = "0.13.3"
+description = "IPython-enabled pdb"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+
+[package.dependencies]
+setuptools = "*"
+
+[package.dependencies.ipython]
+version = ">=5.1.0"
+python = ">=3.4"
+
+[[package]]
+name = "ipython"
+version = "7.17.0"
+description = "IPython: Productive Interactive Computing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"]
+doc = ["Sphinx (>=1.3)"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["notebook", "ipywidgets"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"]
+
+[package.dependencies]
+appnope = "*"
+backcall = "*"
+colorama = "*"
+decorator = "*"
+jedi = ">=0.10"
+pexpect = "*"
+pickleshare = "*"
+prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
+pygments = "*"
+setuptools = ">=18.5"
+traitlets = ">=4.2"
+
+[[package]]
+name = "ipython-genutils"
+version = "0.2.0"
+description = "Vestigial utilities from IPython"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
 [[package]]
 name = "itsdangerous"
 version = "1.1.0"
@@ -115,6 +255,22 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "jedi"
+version = "0.17.2"
+description = "An autocompletion tool for Python that can be used for text editors."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+qa = ["flake8 (3.7.9)"]
+testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"]
+
+[package.dependencies]
+parso = ">=0.7.0,<0.8.0"
+
 [[package]]
 name = "jinja2"
 version = "2.11.2"
@@ -137,6 +293,104 @@ category = "main"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
 
+[[package]]
+name = "more-itertools"
+version = "8.4.0"
+description = "More routines for operating on iterables, beyond itertools"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "packaging"
+version = "20.4"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+six = "*"
+
+[[package]]
+name = "parso"
+version = "0.7.1"
+description = "A Python Parser"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+testing = ["docopt", "pytest (>=3.0.7)"]
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[package.dependencies]
+[package.dependencies.importlib-metadata]
+version = ">=0.12"
+python = "<3.8"
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.6"
+description = "Library for building powerful interactive command lines in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+marker = "python_version >= \"3.4\""
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.6.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
+
+[[package]]
+name = "py"
+version = "1.9.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "pycparser"
 version = "2.20"
@@ -145,6 +399,50 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "pygments"
+version = "2.6.1"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "pytest"
+version = "6.0.1"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+checkqa_mypy = ["mypy (0.780)"]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[package.dependencies]
+atomicwrites = ">=1.0"
+attrs = ">=17.4.0"
+colorama = "*"
+iniconfig = "*"
+more-itertools = ">=4.0.0"
+packaging = "*"
+pluggy = ">=0.12,<1.0"
+py = ">=1.8.2"
+toml = "*"
+
+[package.dependencies.importlib-metadata]
+version = ">=0.12"
+python = "<3.8"
+
 [[package]]
 name = "requests"
 version = "2.24.0"
@@ -171,6 +469,31 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "toml"
+version = "0.10.1"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "traitlets"
+version = "4.3.3"
+description = "Traitlets Python config system"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+test = ["pytest", "mock"]
+
+[package.dependencies]
+decorator = "*"
+ipython-genutils = "*"
+six = "*"
+
 [[package]]
 name = "urllib3"
 version = "1.25.10"
@@ -184,6 +507,15 @@ brotli = ["brotlipy (>=0.6.0)"]
 secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"]
 socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
 
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
 [[package]]
 name = "werkzeug"
 version = "1.0.1"
@@ -196,12 +528,29 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"]
 watchdog = ["watchdog"]
 
+[[package]]
+name = "zipp"
+version = "3.1.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+marker = "python_version < \"3.8\""
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
+testing = ["jaraco.itertools", "func-timeout"]
+
 [metadata]
 lock-version = "1.0"
 python-versions = "^3.7"
-content-hash = "5efa8d3aa125259ad269866dce833dcfd765044a16533d68c65897d73d9eaff4"
+content-hash = "3cfb3337e3938185bd5cf650d8258403f4e2227115904da0972cba109818ebf5"
 
 [metadata.files]
+appnope = [
+    {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"},
+    {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"},
+]
 argon2-cffi = [
     {file = "argon2-cffi-20.1.0.tar.gz", hash = "sha256:d8029b2d3e4b4cea770e9e5a0104dd8fa185c1724a0f01528ae4826a6d25f97d"},
     {file = "argon2_cffi-20.1.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:6ea92c980586931a816d61e4faf6c192b4abce89aa767ff6581e6ddc985ed003"},
@@ -220,6 +569,18 @@ argon2-cffi = [
     {file = "argon2_cffi-20.1.0-cp38-cp38-win32.whl", hash = "sha256:77e909cc756ef81d6abb60524d259d959bab384832f0c651ed7dcb6e5ccdbb78"},
     {file = "argon2_cffi-20.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:9dfd5197852530294ecb5795c97a823839258dfd5eb9420233c7cfedec2058f2"},
 ]
+atomicwrites = [
+    {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+    {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+    {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
+    {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
+]
+backcall = [
+    {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+    {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
+]
 certifi = [
     {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
     {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
@@ -262,6 +623,14 @@ click = [
     {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
     {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
 ]
+colorama = [
+    {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
+    {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
+]
+decorator = [
+    {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"},
+    {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"},
+]
 flask = [
     {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
     {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
@@ -278,10 +647,33 @@ idna = [
     {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
     {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
 ]
+importlib-metadata = [
+    {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
+    {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
+]
+iniconfig = [
+    {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"},
+    {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"},
+]
+ipdb = [
+    {file = "ipdb-0.13.3.tar.gz", hash = "sha256:d6f46d261c45a65e65a2f7ec69288a1c511e16206edb2875e7ec6b2f66997e78"},
+]
+ipython = [
+    {file = "ipython-7.17.0-py3-none-any.whl", hash = "sha256:5a8f159ca8b22b9a0a1f2a28befe5ad2b703339afb58c2ffe0d7c8d7a3af5999"},
+    {file = "ipython-7.17.0.tar.gz", hash = "sha256:b70974aaa2674b05eb86a910c02ed09956a33f2dd6c71afc60f0b128a77e7f28"},
+]
+ipython-genutils = [
+    {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
+    {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
+]
 itsdangerous = [
     {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
     {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"},
 ]
+jedi = [
+    {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"},
+    {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"},
+]
 jinja2 = [
     {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
     {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
@@ -321,10 +713,58 @@ markupsafe = [
     {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
     {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
 ]
+more-itertools = [
+    {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"},
+    {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"},
+]
+packaging = [
+    {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
+    {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
+]
+parso = [
+    {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"},
+    {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"},
+]
+pexpect = [
+    {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+    {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+pickleshare = [
+    {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+    {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
+pluggy = [
+    {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+    {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+]
+prompt-toolkit = [
+    {file = "prompt_toolkit-3.0.6-py3-none-any.whl", hash = "sha256:683397077a64cd1f750b71c05afcfc6612a7300cb6932666531e5a54f38ea564"},
+    {file = "prompt_toolkit-3.0.6.tar.gz", hash = "sha256:7630ab85a23302839a0f26b31cc24f518e6155dea1ed395ea61b42c45941b6a6"},
+]
+ptyprocess = [
+    {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"},
+    {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"},
+]
+py = [
+    {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
+    {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
+]
 pycparser = [
     {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
     {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
 ]
+pygments = [
+    {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"},
+    {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"},
+]
+pyparsing = [
+    {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
+    {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+]
+pytest = [
+    {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"},
+    {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"},
+]
 requests = [
     {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
     {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
@@ -333,11 +773,27 @@ six = [
     {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
     {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
 ]
+toml = [
+    {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
+    {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
+]
+traitlets = [
+    {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"},
+    {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"},
+]
 urllib3 = [
     {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"},
     {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"},
 ]
+wcwidth = [
+    {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+    {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
 werkzeug = [
     {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
     {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
 ]
+zipp = [
+    {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
+    {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
+]
diff --git a/pyproject.toml b/pyproject.toml
index ab605ef..2945695 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,8 @@ goodconf = "^1.0.0"
 argon2-cffi = "^20.1.0"
 
 [tool.poetry.dev-dependencies]
+pytest = "^6.0.1"
+ipdb = "^0.13.3"
 
 [build-system]
 requires = ["poetry>=0.12"]
diff --git a/src/mangoapi/mangadex.py b/src/mangoapi/mangadex.py
index fc0a246..4894744 100644
--- a/src/mangoapi/mangadex.py
+++ b/src/mangoapi/mangadex.py
@@ -53,7 +53,7 @@ def get_chapter(self, title_id, chapter_id):
 
         chapter = {
             "id": chapter_id,
-            "title_id": md_json["manga_id"],
+            "title_id": str(md_json["manga_id"]),
             "site": "mangadex",
             "name": md_json["title"],
             "pages": [f"{img_path}/{page}" for page in md_json["page_array"]],
@@ -133,7 +133,7 @@ def _parse_chapter_number(string):
 
 def _extract_groups(chap):
     return [
-        group.strip()
+        html.unescape(group.strip())
         for group in [chap["group_name"], chap["group_name_2"], chap["group_name_3"]]
         if group
     ]
diff --git a/tests/mangoapi/test_mangadex.py b/tests/mangoapi/test_mangadex.py
new file mode 100644
index 0000000..6be93fb
--- /dev/null
+++ b/tests/mangoapi/test_mangadex.py
@@ -0,0 +1,133 @@
+import os
+
+from mangoapi.mangadex import Mangadex
+from pytaku.conf import config
+
+
+def test_get_title():
+    title = Mangadex().get_title("2597")
+    assert title == {
+        "id": "2597",
+        "name": "Sayonara Football",
+        "site": "mangadex",
+        "cover_ext": "jpg",
+        "alt_names": ["Adiós al fútbol", "さよならフットボール", "再见足球"],
+        "descriptions": [
+            "Nozomi wants to enter the newcomer's competition. But the coach is against it, because their club is a boy's football club and she's... a she. Will she be able to enter the match she wants to play in?"
+        ],
+        "chapters": [
+            {
+                "id": "84598",
+                "name": "Epilogue",
+                "volume": 2,
+                "groups": ["Shoujo Crusade"],
+                "number": "8",
+                "num_major": 8,
+            },
+            {
+                "id": "84596",
+                "name": "Football Under the Blue Sky",
+                "volume": 2,
+                "groups": ["Shoujo Crusade"],
+                "number": "7",
+                "num_major": 7,
+            },
+            {
+                "id": "84594",
+                "name": "Everyone in a Crisis",
+                "volume": 2,
+                "groups": ["Shoujo Crusade"],
+                "number": "6",
+                "num_major": 6,
+            },
+            {
+                "id": "84592",
+                "name": "Clash and Decide",
+                "volume": 2,
+                "groups": ["Shoujo Crusade"],
+                "number": "5",
+                "num_major": 5,
+            },
+            {
+                "id": "84590",
+                "name": "And There's the Whistle",
+                "volume": 1,
+                "groups": ["Shoujo Crusade"],
+                "number": "4",
+                "num_major": 4,
+            },
+            {
+                "id": "84589",
+                "name": "A Plan to Become a Regular",
+                "volume": 1,
+                "groups": ["Shoujo Crusade"],
+                "number": "3",
+                "num_major": 3,
+            },
+            {
+                "id": "84587",
+                "name": "Her Determination at That Time",
+                "volume": 1,
+                "groups": ["Shoujo Crusade"],
+                "number": "2",
+                "num_major": 2,
+            },
+            {
+                "id": "84585",
+                "name": "The Entry of an Unmanageable Woman",
+                "volume": 1,
+                "groups": ["Shoujo Crusade"],
+                "number": "1",
+                "num_major": 1,
+            },
+        ],
+    }
+
+
+def test_get_chapter():
+    chap = Mangadex().get_chapter("doesn't matter", "696882")
+    assert chap == {
+        "id": "696882",
+        "title_id": "12088",
+        "site": "mangadex",
+        "name": "Extras",
+        "pages": [
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S1.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S2.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S3.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S4.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S5.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S6.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S7.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S8.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S9.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S10.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S11.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S12.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S13.jpg",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S14.jpg",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S15.png",
+            "https://s5.mangadex.org/data/39174bff8c88758a125c32710730223c/S16.png",
+        ],
+        "groups": ["Träumerei Scans", "GlassChair"],
+        "is_webtoon": False,
+        "number": "81.5",
+        "num_major": 81,
+        "num_minor": 5,
+    }
+
+
+def test_search():
+    md = Mangadex()
+    md.username = config.MANGADEX_USERNAME
+    md.password = config.MANGADEX_PASSWORD
+
+    results = md.search_title("sayonara football")
+    assert results == [
+        {
+            "id": "2597",
+            "name": "Sayonara Football",
+            "site": "mangadex",
+            "thumbnail": "https://mangadex.org/images/manga/2597.large.jpg",
+        }
+    ]
diff --git a/tests/mangoapi/test_mangasee.py b/tests/mangoapi/test_mangasee.py
new file mode 100644
index 0000000..b66d306
--- /dev/null
+++ b/tests/mangoapi/test_mangasee.py
@@ -0,0 +1,65 @@
+from mangoapi.mangasee import Mangasee
+
+
+def test_get_title():
+    title = Mangasee().get_title("Yu-Yu-Hakusho")
+    chapters = title.pop("chapters")
+    assert title == {
+        "id": "Yu-Yu-Hakusho",
+        "name": "Yu Yu Hakusho",
+        "site": "mangasee",
+        "cover_ext": "jpg",
+        "alt_names": [],
+        "descriptions": [
+            "Yusuke Urameshi was a tough teen delinquent until one selfless act changed his life... by ending it. When he died saving a little kid from a speeding car, the afterlife didn't know what to do with him, so it gave him a second chance at life. Now, Yusuke is a ghost with a mission, performing good deeds at the behest of Botan, the ferrywoman of the River Styx, and Koenma, the pacifier-sucking judge of the dead."
+        ],
+    }
+    assert len(chapters) == 176
+    assert chapters[112] == {
+        "groups": [],
+        "id": "63.5",
+        "name": "",
+        "num_major": 63,
+        "num_minor": 5,
+        "number": "63.5",
+        "volume": "",
+    }
+
+
+def test_get_chapter():
+    chapter = Mangasee().get_chapter("Yu-Yu-Hakusho", "63.5")
+    pages = chapter.pop("pages")
+    assert chapter == {
+        "groups": [],
+        "id": "63.5",
+        "is_webtoon": False,
+        "name": "",
+        "num_major": 63,
+        "num_minor": 5,
+        "number": "63.5",
+        "site": "mangasee",
+        "title_id": "Yu-Yu-Hakusho",
+    }
+    assert pages[0] == "https://s1.mangabeast01.com/manga/Yu-Yu-Hakusho/0063.5-001.png"
+    assert pages[-1] == "https://s1.mangabeast01.com/manga/Yu-Yu-Hakusho/0063.5-031.png"
+
+
+def test_search_title():
+    class DictKeyvalStore(dict):
+        def get(self, key):
+            return self[key]
+
+        def set(self, key, val):
+            self[key] = val
+
+    ms = Mangasee()
+    ms.keyval_store = DictKeyvalStore()
+    results = ms.search_title("sayonara football")
+    assert results == [
+        {
+            "id": "Sayonara-Football",
+            "name": "Sayonara Football",
+            "site": "mangasee",
+            "thumbnail": "https://cover.mangabeast01.com/cover/Sayonara-Football.jpg",
+        }
+    ]