From a59af71a77d928536412ea71b20689dfc99dd033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= Date: Mon, 7 Aug 2023 13:45:31 +0200 Subject: Add tests. --- Makefile | 15 ++- mu4web/password.py | 5 +- setup.cfg | 12 ++ test/conftest.py | 29 +++++ test/test_components.py | 126 +++++++++++++++++++++ test/test_html.py | 50 ++++++++ test/test_password.py | 31 +++++ test/test_related.py | 67 +++++++++++ test/test_util.py | 68 +++++++++++ .../mail/cur/1691189956.3013485_5.gandalf:2,RS | 17 +++ testdata/mail/cur/1691189958.3013485_7.gandalf:2,S | 25 ++++ testdata/mail/cur/1691314589.3192599_3.gandalf:2,S | 45 ++++++++ 12 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 test/conftest.py create mode 100644 test/test_components.py create mode 100644 test/test_html.py create mode 100644 test/test_password.py create mode 100644 test/test_related.py create mode 100644 test/test_util.py create mode 100644 testdata/mail/cur/1691189956.3013485_5.gandalf:2,RS create mode 100644 testdata/mail/cur/1691189958.3013485_7.gandalf:2,S create mode 100644 testdata/mail/cur/1691314589.3192599_3.gandalf:2,S 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 +To: "Hugo 1" , "Hugo 2" +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',)) == "
" + + +def test_html_nested() -> None: + assert render_fragment(('dl', + ('dt', 'Key'), + ('dd', 'Val'))) \ + == "
Key
Val
" + + +def test_html_list() -> None: + assert render_fragment(('dl', + [('dt', 'Key'), + ('dd', 'Val')])) \ + == "
Key
Val
" + + +def test_html_attributes() -> None: + assert render_fragment(('a', {'href': '#'}, 'pretty')) \ + == 'pretty' + + +def test_html_standalone() -> None: + assert render_fragment(('hr',)) == '
' + + +def test_html_none() -> None: + assert render_fragment(None) == '' + + +def test_html_callable() -> None: + #
can never appear, since ``('hr',)`` is rendered as "
". + # This shows that functions return contents verbatim. + assert render_fragment(lambda: '
') == '
' + + +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',)) == '
' + assert render_document(('hr',)) == '\n
' 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"] + + +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 ', + 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 ', + 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 ', + 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: +X-Original-To: hugo@example.com +Delivered-To: hugo@example.com +Date: Sat, 5 Aug 2023 00:56:54 +0200 +From: Hugo +To: hugo@example.com +Subject: Initial email +Message-ID: +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: +X-Original-To: hugo@example.com +Delivered-To: hugo@example.com +Date: Sat, 5 Aug 2023 00:57:18 +0200 +From: Hugo +To: hugo@example.com +Subject: Re: Initial email +Message-ID: +References: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit +In-Reply-To: +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: +X-Original-To: hugo@example.com +Delivered-To: hugo@example.com +Date: Sun, 6 Aug 2023 11:35:13 +0200 +From: Hugo +To: hugo@example.com +Subject: FWD: Initial email +Message-ID: +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: +X-Original-To: hugo@example.com +Delivered-To: hugo@example.com +Date: Sat, 5 Aug 2023 00:56:54 +0200 +From: Hugo +To: hugo@example.com +Subject: Initial email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Disposition: inline + +Hello + +-- +hugo + +--WSH/KnH5jgwcMqOI-- -- cgit v1.2.3