aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2023-08-07 13:45:31 +0200
committerHugo Hörnquist <hugo@lysator.liu.se>2023-08-07 15:16:41 +0200
commita59af71a77d928536412ea71b20689dfc99dd033 (patch)
treecca2e21ee76fa47364cae883a2ab1d05d310b4cb
parentAdd mail relation tree. (diff)
downloadmu4web-a59af71a77d928536412ea71b20689dfc99dd033.tar.gz
mu4web-a59af71a77d928536412ea71b20689dfc99dd033.tar.xz
Add tests.
-rw-r--r--Makefile15
-rwxr-xr-xmu4web/password.py5
-rw-r--r--setup.cfg12
-rw-r--r--test/conftest.py29
-rw-r--r--test/test_components.py126
-rw-r--r--test/test_html.py50
-rw-r--r--test/test_password.py31
-rw-r--r--test/test_related.py67
-rw-r--r--test/test_util.py68
-rw-r--r--testdata/mail/cur/1691189956.3013485_5.gandalf:2,RS17
-rw-r--r--testdata/mail/cur/1691189958.3013485_7.gandalf:2,S25
-rw-r--r--testdata/mail/cur/1691314589.3192599_3.gandalf:2,S45
12 files changed, 486 insertions, 4 deletions
diff --git a/Makefile b/Makefile
index 753fe28..09a500c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,21 @@
-.PHONY: tags check documentation sphinx-apidoc
+.PHONY: tags check documentation sphinx-apidoc test
DOC_OUTPUT = doc.rendered
+PYTEST_FLAGS = $(if $(shell python -m pytest -VV | grep pytest-cov), \
+ --cov=mu4web --cov-branch --cov-report=html --full-trace)
+
tags:
- ctags `find mu4web -type f -name \*.py `
+ ctags `find mu4web -type f -name '*.py'`
+
+run:
+ python -m mu4web
check:
mypy -p mu4web
flake8 mu4web
+ flake8 test
+ mypy test
sphinx-apidoc:
sphinx-apidoc --separate --force -o doc mu4web
@@ -16,3 +24,6 @@ $(DOC_OUTPUT)/index.html: sphinx-apidoc
sphinx-build -b dirhtml doc $(DOC_OUTPUT)
documentation: $(DOC_OUTPUT)/index.html
+
+test:
+ python -m pytest $(PYTEST_FLAGS) test
diff --git a/mu4web/password.py b/mu4web/password.py
index d7ab5ce..ee7613e 100755
--- a/mu4web/password.py
+++ b/mu4web/password.py
@@ -41,10 +41,11 @@ class Passwords:
Simple password store.
:param fname:
- Path of json file to load and store from.
+ Path of json file to load and store from. It's recommended to
+ end this name with ``.json``.
"""
- def __init__(self, fname: os.PathLike[str]):
+ def __init__(self, fname: str):
self.fname = fname
self.db: dict[str, PasswordEntry]
try:
diff --git a/setup.cfg b/setup.cfg
index b05edd9..4c6a084 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,6 +42,18 @@ warn_unused_ignores = False
# to proper modules
disallow_untyped_defs = False
+[mypy-test.*]
+allow_untyped_defs = True
+strict = False
+
[flake8]
ignore = E731,D105,D107
max-line-length = 100
+per-file-ignores =
+ test/*:D100,D103
+
+[coverage:run]
+omit =
+ # This file should only be an entry-point stub, meaning that there
+ # is nothing meaningfull to test.
+ mu4web/__main__.py
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..782f885
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,29 @@
+import pytest
+from tempfile import TemporaryDirectory
+import os
+import os.path
+
+
+@pytest.fixture
+def testdir() -> str:
+ return os.path.abspath(__file__)
+
+
+@pytest.fixture
+def project_root(testdir: str) -> str:
+ return os.path.dirname(os.path.dirname(testdir))
+
+
+@pytest.fixture
+def testdata_dir(project_root: str) -> str:
+ return os.path.join(project_root, 'testdata')
+
+
+@pytest.fixture
+def maildir(testdata_dir: str) -> str:
+ return os.path.join(testdata_dir, 'mail')
+
+
+@pytest.fixture
+def tmpdir() -> TemporaryDirectory[str]:
+ return TemporaryDirectory()
diff --git a/test/test_components.py b/test/test_components.py
new file mode 100644
index 0000000..f19cfb3
--- /dev/null
+++ b/test/test_components.py
@@ -0,0 +1,126 @@
+from email.headerregistry import Address
+from email.parser import BytesParser
+import email.policy
+from mu4web.components import (
+ dl,
+ flashed_messages,
+ format_email,
+ include_stylesheet,
+ login_page,
+ login_prompt,
+ mailto,
+ search_field,
+ user_info,
+)
+from typing import cast
+
+
+def test_dl() -> None:
+ assert dl([]) == ('dl', [])
+ assert dl([('k', '1'), ('v', '2')]) == ('dl', [('dt', 'k'),
+ ('dd', '1'),
+ ('dt', 'v'),
+ ('dd', '2')])
+
+
+def test_mailto() -> None:
+ assert mailto('hugo@example.com') \
+ == ('a', {'href': 'mailto:hugo@example.com'}, 'hugo@example.com')
+
+
+def test_format_email() -> None:
+ assert format_email(Address('Hugo Hörnquist', 'hugo', 'example.com')) == [
+ 'Hugo Hörnquist',
+ ' <',
+ mailto('hugo@example.com'),
+ '>'
+ ]
+
+
+parser = BytesParser(policy=email.policy.default)
+
+msg = parser.parsebytes(b"""
+Date: Fri, 04 Aug 2023 20:15:30 +0200
+From: Hugo <hugo@example.com>
+To: "Hugo 1" <hugo1@example.com>, "Hugo 2" <hugo2@example.com>
+Message-Id: <9a274279-9915-44e7-b4c0-3d29b7929744>
+In-Reply-To: <1763399c-9483-4c12-a633-5eb0496886b9>
+
+This is a short message
+""".strip())
+
+
+# TODO The above message claims to contain proper data structures,
+# however, it only contains string-like objects.
+# def test_header_format():
+# print("to:", len(msg.get('to')), repr(msg['to']), type(msg['to']))
+# assert header_format('to', msg['to']) \
+# == ('ul', ('li',
+# format_email(msg['to'][0]),
+# format_email(msg['to'][1])))
+# assert 'from'
+# assert 'in-reply-to'
+# assert 'x-fake-header'
+
+def test_attachement_tree() -> None:
+ # TODO
+ pass
+
+
+def test_login_page() -> None:
+ assert login_page() == \
+ ('form', {'action': '/login', 'method': 'POST', 'class': 'loginform'},
+ ('label', {'for': 'username'}, 'Användarnamn'),
+ ('input', {'id': 'username', 'name': 'username', 'placeholder': 'Användarnamn'}),
+ ('label', {'for': 'password'}, 'Lösenord'),
+ ('input', {'id': 'password', 'name': 'password',
+ 'placeholder': 'Lösenord',
+ 'type': 'password'}),
+ ('div',
+ ('input', {'id': 'remember', 'name': 'remember', 'type': 'checkbox'}),
+ ('label', {'for': 'remember'}, 'Kom ihåg mig')),
+ [],
+ ('input', {'type': 'submit', 'value': 'Logga in'}),
+ )
+
+
+def test_user_info() -> None:
+ assert user_info('hugo') == [
+ ('span', 'hugo'),
+ ('form', {'action': '/logout', 'method': 'POST'},
+ ('input', {'type': 'submit', 'value': 'Logga ut'}))
+ ]
+
+
+def test_login_prompt() -> None:
+ assert login_prompt() == ('a', {'href': '/login'}, 'Logga in')
+
+
+def test_flashed_messages() -> None:
+ assert flashed_messages(cast(list[str], [])) == cast(list[str], [])
+ assert flashed_messages(['msg1']) \
+ == ('ul', {'class': 'flashes'}, ('li', 'msg1'))
+ assert flashed_messages([('cat1', 'msg1')]) \
+ == ('ul', {'class': 'flashes'}, ('li', 'msg1'))
+
+
+def test_include_stylesheet() -> None:
+ assert include_stylesheet('style.css') == \
+ ('link', {'type': 'text/css',
+ 'rel': 'stylesheet',
+ 'href': 'style.css'})
+
+
+def test_search_field() -> None:
+ assert search_field('test') == \
+ ('form', {'id': 'searchform',
+ 'action': '/search',
+ 'method': 'GET'},
+ ('label', {'for': 'search'},
+ 'Sökförfrågan till Mu'),
+ ('input', {'id': 'search',
+ 'type': 'text',
+ 'placeholder': 'Sök...',
+ 'name': 'q',
+ 'value': 'test'}),
+ ('input', {'type': 'Submit', 'value': 'Sök'}))
diff --git a/test/test_html.py b/test/test_html.py
new file mode 100644
index 0000000..6dee30e
--- /dev/null
+++ b/test/test_html.py
@@ -0,0 +1,50 @@
+from mu4web.html_render import render_fragment, render_document
+
+
+def test_html_basic() -> None:
+ assert render_fragment(('dl',)) == "<dl></dl>"
+
+
+def test_html_nested() -> None:
+ assert render_fragment(('dl',
+ ('dt', 'Key'),
+ ('dd', 'Val'))) \
+ == "<dl><dt>Key</dt><dd>Val</dd></dl>"
+
+
+def test_html_list() -> None:
+ assert render_fragment(('dl',
+ [('dt', 'Key'),
+ ('dd', 'Val')])) \
+ == "<dl><dt>Key</dt><dd>Val</dd></dl>"
+
+
+def test_html_attributes() -> None:
+ assert render_fragment(('a', {'href': '#'}, 'pretty')) \
+ == '<a href="#">pretty</a>'
+
+
+def test_html_standalone() -> None:
+ assert render_fragment(('hr',)) == '<hr>'
+
+
+def test_html_none() -> None:
+ assert render_fragment(None) == ''
+
+
+def test_html_callable() -> None:
+ # <hr/> can never appear, since ``('hr',)`` is rendered as "<hr>".
+ # This shows that functions return contents verbatim.
+ assert render_fragment(lambda: '<hr/>') == '<hr/>'
+
+
+def test_complete_document() -> None:
+ """
+ Render complete document.
+
+ render_document is currently only render_fragment, but with a a
+ doctype prepended. Test this, through rendeering the samething
+ twicie.
+ """
+ assert render_fragment(('hr',)) == '<hr>'
+ assert render_document(('hr',)) == '<!doctype html>\n<hr>'
diff --git a/test/test_password.py b/test/test_password.py
new file mode 100644
index 0000000..ed62f10
--- /dev/null
+++ b/test/test_password.py
@@ -0,0 +1,31 @@
+from tempfile import TemporaryDirectory
+import os.path
+from mu4web.password import (
+ Passwords,
+)
+
+
+dir = TemporaryDirectory()
+path = os.path.join(dir.name, 'passwords.json')
+
+
+def test_password() -> None:
+ # Start with a blank store
+ store1 = Passwords(path)
+ # Try to validate a non-existant user (this should fail)
+ assert not store1.validate('hugo', 'password')
+
+ # Add a user
+ store1.add('hugo', 'password')
+ # Try to validate it (this should succeed)
+ assert store1.validate('hugo', 'password')
+ # Try to validate it with the wrong password (this should not succeed)
+ assert not store1.validate('hugo', 'Hunter2')
+
+ # Save store to disk
+ store1.save()
+
+ # Load same store into different instance
+ store2 = Passwords(path)
+ # Check that password was correctly saved.
+ assert store2.validate('hugo', 'password')
diff --git a/test/test_related.py b/test/test_related.py
new file mode 100644
index 0000000..349edc0
--- /dev/null
+++ b/test/test_related.py
@@ -0,0 +1,67 @@
+from email.message import EmailMessage
+from email.parser import BytesParser
+from glob import glob
+import email.policy
+import os
+import os.path
+from datetime import datetime, timezone, timedelta
+from typing import cast
+import sqlite3
+from tempfile import TemporaryDirectory
+from mu4web.tree import (
+ parse_msg_ids,
+ setup_relation_data,
+ Tree,
+ TreeEntry,
+ fetch_relation_tree
+)
+
+
+def test_parse_msg_ids() -> None:
+ assert parse_msg_ids("<id1>, <id2>") == ["id1", "id2"]
+
+
+parser = BytesParser(policy=email.policy.default)
+
+
+def test_relation_data(tmpdir: TemporaryDirectory[str], maildir: str) -> None:
+ db = os.path.join(tmpdir.name, 'mail.db')
+ setup_relation_data(db, maildir)
+
+ con = sqlite3.connect(db)
+ cur = con.cursor()
+
+ CEST = timezone(timedelta(seconds=7200))
+
+ tree1 = Tree(data=TreeEntry(entry='ZM2CNjF0jRjd1H4x@lysator.liu.se',
+ ref='',
+ subject='Initial email',
+ from_='Hugo <hugo@example.com>',
+ date=datetime(2023, 8, 5, 0, 56, 54, tzinfo=CEST)),
+ children=[Tree(data=TreeEntry(entry='ZM2CTjoCe8dwaoAg@lysator.liu.se',
+ ref='ZM2CNjF0jRjd1H4x@lysator.liu.se',
+ subject='Re: Initial email',
+ from_='Hugo <hugo@example.com>',
+ date=datetime(2023, 8, 5, 0, 57, 18, tzinfo=CEST)))
+ ])
+ tree2 = Tree(data=TreeEntry(entry='ZM9pUTtYOHiPevlg@lysator.liu.se',
+ ref='',
+ subject='FWD: Initial email',
+ from_='Hugo <hugo@example.com>',
+ date=datetime(2023, 8, 6, 11, 35, 13, tzinfo=CEST)))
+
+ data = {
+ '1691189956.3013485_5.gandalf:2,RS': tree1,
+ '1691189958.3013485_7.gandalf:2,S': tree1,
+ '1691314589.3192599_3.gandalf:2,S': tree2,
+ }
+
+ msg: EmailMessage
+
+ # os.system(f"cp {os.path.join(tmpdir.name, 'mail.db')} /tmp/mail.db")
+
+ for mailfile in glob(os.path.join(maildir, 'cur', '*')):
+ with open(mailfile, 'rb') as f:
+ msg = cast(EmailMessage, parser.parse(f))
+
+ assert fetch_relation_tree(cur, msg) == data[os.path.basename(mailfile)]
diff --git a/test/test_util.py b/test/test_util.py
new file mode 100644
index 0000000..c83ac98
--- /dev/null
+++ b/test/test_util.py
@@ -0,0 +1,68 @@
+from mu4web.util import (
+ MutableString,
+ chain,
+ cwd,
+ find,
+ force,
+ split_path,
+)
+import os
+from tempfile import TemporaryDirectory
+from math import sqrt
+import pytest
+
+
+def test_split_path() -> None:
+ assert split_path("/home/hugo/test/something") == ['home', 'hugo', 'test', 'something']
+
+
+def test_find(maildir: str) -> None:
+ assert [split_path(p)[-3:] for p in find(maildir, type='f')] == [
+ [b'mail', b'cur', b'1691189956.3013485_5.gandalf:2,RS'],
+ [b'mail', b'cur', b'1691189958.3013485_7.gandalf:2,S'],
+ [b'mail', b'cur', b'1691314589.3192599_3.gandalf:2,S']]
+ assert [split_path(p)[-1] for p in find(maildir, type='d')] \
+ == [b'mail', b'cur', b'new', b'tmp']
+
+
+def test_cwd() -> None:
+ outer = os.getcwd()
+ with TemporaryDirectory() as dir:
+ with cwd(dir):
+ assert os.getcwd() == dir
+ assert outer == os.getcwd()
+
+
+def test_chain() -> None:
+ assert (chain(range(9))
+ @ sum # type: ignore
+ @ sqrt).value == 6.0 # type: ignore
+
+
+def test_force() -> None:
+ assert force(1) == 1
+
+ with pytest.raises(AssertionError):
+ force(None)
+
+
+def test_mutable_string() -> None:
+
+ def inner(s: str | MutableString, msg: str) -> None:
+ s += msg
+
+ s1: str = ''
+ s2: MutableString = MutableString()
+
+ inner(s1, "Hello")
+ inner(s1, "World")
+
+ inner(s2, "Hello")
+ inner(s2, "World")
+
+ # Control with regular string, to ensure that we actually mutate
+ # the variable value, and not just the variable.
+ assert s1 == ''
+
+ assert str(s2) == "HelloWorld"
+ assert repr(s2) == 'MutableString("HelloWorld")'
diff --git a/testdata/mail/cur/1691189956.3013485_5.gandalf:2,RS b/testdata/mail/cur/1691189956.3013485_5.gandalf:2,RS
new file mode 100644
index 0000000..2554040
--- /dev/null
+++ b/testdata/mail/cur/1691189956.3013485_5.gandalf:2,RS
@@ -0,0 +1,17 @@
+Return-Path: <hugo@example.com>
+X-Original-To: hugo@example.com
+Delivered-To: hugo@example.com
+Date: Sat, 5 Aug 2023 00:56:54 +0200
+From: Hugo <hugo@example.com>
+To: hugo@example.com
+Subject: Initial email
+Message-ID: <ZM2CNjF0jRjd1H4x@lysator.liu.se>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: inline
+Content-Length: 16
+
+Hello
+
+--
+hugo
diff --git a/testdata/mail/cur/1691189958.3013485_7.gandalf:2,S b/testdata/mail/cur/1691189958.3013485_7.gandalf:2,S
new file mode 100644
index 0000000..60a6884
--- /dev/null
+++ b/testdata/mail/cur/1691189958.3013485_7.gandalf:2,S
@@ -0,0 +1,25 @@
+Return-Path: <hugo@example.com>
+X-Original-To: hugo@example.com
+Delivered-To: hugo@example.com
+Date: Sat, 5 Aug 2023 00:57:18 +0200
+From: Hugo <hugo@example.com>
+To: hugo@example.com
+Subject: Re: Initial email
+Message-ID: <ZM2CTjoCe8dwaoAg@lysator.liu.se>
+References: <ZM2CNjF0jRjd1H4x@lysator.liu.se>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: inline
+Content-Transfer-Encoding: 8bit
+In-Reply-To: <ZM2CNjF0jRjd1H4x@lysator.liu.se>
+Content-Length: 111
+
+And a reply
+
+--
+hugo
+On Sat, Aug 05, 2023 at 12:56:54AM +0200, Hugo wrote:
+> Hello
+>
+> --
+> hugo
diff --git a/testdata/mail/cur/1691314589.3192599_3.gandalf:2,S b/testdata/mail/cur/1691314589.3192599_3.gandalf:2,S
new file mode 100644
index 0000000..cb0022c
--- /dev/null
+++ b/testdata/mail/cur/1691314589.3192599_3.gandalf:2,S
@@ -0,0 +1,45 @@
+Return-Path: <hugo@example.com>
+X-Original-To: hugo@example.com
+Delivered-To: hugo@example.com
+Date: Sun, 6 Aug 2023 11:35:13 +0200
+From: Hugo <hugo@example.com>
+To: hugo@example.com
+Subject: FWD: Initial email
+Message-ID: <ZM9pUTtYOHiPevlg@lysator.liu.se>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="WSH/KnH5jgwcMqOI"
+Content-Disposition: inline
+Content-Length: 569
+
+
+--WSH/KnH5jgwcMqOI
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: inline
+
+See forwarded.
+
+--
+hugo
+
+--WSH/KnH5jgwcMqOI
+Content-Type: message/rfc822
+Content-Disposition: inline
+
+Return-Path: <hugo@example.com>
+X-Original-To: hugo@example.com
+Delivered-To: hugo@example.com
+Date: Sat, 5 Aug 2023 00:56:54 +0200
+From: Hugo <hugo@example.com>
+To: hugo@example.com
+Subject: Initial email
+Message-ID: <ZM2CNjF0jRjd1H4x@lysator.liu.se>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Disposition: inline
+
+Hello
+
+--
+hugo
+
+--WSH/KnH5jgwcMqOI--