diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebe7885..5804abe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v0.6.0 (2026-03-29)
+
+### Chores
+
+- Initial plan for frontend test coverage
+ ([`7dd0b5f`](https://github.com/christianlouis/InboxConverge/commit/7dd0b5f6005b4d4c2db4cc0939fa6b4921ceff94))
+
+### Features
+
+- Add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider,
+ DashboardLayout
+ ([`63839c3`](https://github.com/christianlouis/InboxConverge/commit/63839c3886445527258d988ad65f869cf46387a5))
+
+- Add NotificationWizard + ProviderWizard tests, fix lint, update docs
+ ([`769907a`](https://github.com/christianlouis/InboxConverge/commit/769907a65b956048a77e5714b998a45426d08bf4))
+
+
## v0.5.1 (2026-03-28)
### Bug Fixes
@@ -22,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Celery tasks test coverage**: Added 40 unit tests for `backend/app/workers/tasks.py`, raising coverage from 9.22% to 96%. Tests cover `_as_utc` helper, `process_mail_account` (Gmail API and SMTP forwarding, credential revocation, empty-email detection, error handling), `process_all_enabled_accounts` (stale-run cleanup, interval checking), and `cleanup_old_logs` (data retention, stale-run recovery).
- **Domain-based logo fallback for mail accounts**: `ProviderLogoBanner` now shows provider logos even for accounts that have no `provider_name` set, by extracting the domain from the email address and matching it against a new `DOMAIN_ICON_MAP`. Covers Gmail, GMX, WEB.DE, Yahoo Mail, AOL, T-Online, Outlook/Hotmail, IONOS, Freenet, iCloud, Posteo, and Proton Mail.
+- **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components, utility functions, and API interceptors. Installed `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event`. New suites: `date-utils.test.ts` (30 tests), `api.test.ts` (9 tests), `AuthGuard.test.tsx` (6 tests), `QueryProvider.test.tsx` (2 tests), `DashboardLayout.test.tsx` (14 tests), `NotificationWizard.test.tsx` (32 tests), `ProviderWizard.test.tsx` (20 tests). Total frontend: 119 tests across 8 suites.
+- **Improved `mail_processor.py` test coverage**: Added 46 unit tests for POP3 connection testing, POP3 email fetching, IMAP edge cases (stale UID store failure, delete failure, MailFetchError re-raise, star-prefix line filtering), email forwarding (STARTTLS/SSL/multipart), and routing methods. Statement coverage increased from ~42% to 98%.
### Fixed
diff --git a/backend/tests/unit/test_mail_processor_imap.py b/backend/tests/unit/test_mail_processor_imap.py
index 34c1033..9c148b3 100644
--- a/backend/tests/unit/test_mail_processor_imap.py
+++ b/backend/tests/unit/test_mail_processor_imap.py
@@ -17,6 +17,8 @@ These tests verify that _fetch_imap_emails:
import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch
+from app.services.mail_processor import MailProcessor, MailFetchError
+
# ---------------------------------------------------------------------------
# Helpers to build lightweight fakes
# ---------------------------------------------------------------------------
@@ -546,3 +548,164 @@ class TestFetchImapEmailsUidCommands:
assert emails == []
assert new_uids == []
+
+
+# ---------------------------------------------------------------------------
+# Additional edge-case tests for remaining branch coverage
+# ---------------------------------------------------------------------------
+
+
+class TestFetchImapEdgeCases:
+ """Tests for edge cases in _fetch_imap_emails not covered above."""
+
+ async def test_search_ok_but_empty_split_returns_empty(self):
+ """If SEARCH UNSEEN returns OK but lines[0].split() is empty, return []."""
+ account = _make_account()
+ processor = MailProcessor(account, "secret")
+
+ mock_imap = AsyncMock()
+ mock_imap.wait_hello_from_server = AsyncMock()
+ mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
+ # lines[0] is a non-empty string with only spaces => split() returns []
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response("OK", lines=[b" "])
+ )
+ mock_imap.logout = AsyncMock()
+
+ with patch(
+ "app.services.mail_processor.aioimaplib.IMAP4_SSL",
+ return_value=mock_imap,
+ ):
+ emails, new_uids = await processor._fetch_imap_emails(10, set())
+
+ assert emails == []
+ assert new_uids == []
+
+ async def test_stale_uid_store_failure_is_non_fatal(self):
+ """If the UID STORE to re-mark stale UIDs fails, processing continues."""
+ account = _make_account()
+ processor = MailProcessor(account, "secret")
+
+ mock_imap = AsyncMock()
+ mock_imap.wait_hello_from_server = AsyncMock()
+ mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response("OK", lines=[b"1"])
+ )
+ mock_imap.fetch = AsyncMock(
+ return_value=_make_uid_list_response([("1", "100")])
+ )
+
+ async def uid_side_effect(cmd, *args):
+ if cmd == "store":
+ raise Exception("store failed")
+ return _make_imap_response("OK")
+
+ mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
+ mock_imap.logout = AsyncMock()
+
+ with patch(
+ "app.services.mail_processor.aioimaplib.IMAP4_SSL",
+ return_value=mock_imap,
+ ):
+ emails, new_uids = await processor._fetch_imap_emails(10, {"100"})
+
+ assert emails == []
+ assert new_uids == []
+
+ async def test_delete_failure_is_non_fatal(self):
+ """If batch UID STORE \\Deleted fails, emails are still returned."""
+ email_bytes = b"From: a@b.com\r\nSubject: hi\r\n\r\nbody"
+ account = _make_account(delete_after_forward=True)
+ processor = MailProcessor(account, "secret")
+
+ mock_imap = AsyncMock()
+ mock_imap.wait_hello_from_server = AsyncMock()
+ mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response("OK", lines=[b"1"])
+ )
+ mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "42")]))
+
+ async def uid_side_effect(cmd, *args):
+ if cmd == "fetch":
+ return _make_fetch_response("42", email_bytes)
+ if cmd == "store":
+ raise Exception("delete failed")
+ return _make_imap_response("OK")
+
+ mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
+ mock_imap.logout = AsyncMock()
+
+ with patch(
+ "app.services.mail_processor.aioimaplib.IMAP4_SSL",
+ return_value=mock_imap,
+ ):
+ emails, new_uids = await processor._fetch_imap_emails(10, set())
+
+ assert len(emails) == 1
+ assert new_uids == ["42"]
+
+ async def test_mail_fetch_error_is_re_raised_directly(self):
+ """A MailFetchError raised inside the try block is re-raised, not wrapped."""
+ account = _make_account()
+ processor = MailProcessor(account, "secret")
+
+ mock_imap = AsyncMock()
+ mock_imap.wait_hello_from_server = AsyncMock()
+ mock_imap.login = AsyncMock(side_effect=MailFetchError("inner error"))
+ mock_imap.logout = AsyncMock()
+
+ with patch(
+ "app.services.mail_processor.aioimaplib.IMAP4_SSL",
+ return_value=mock_imap,
+ ):
+ with pytest.raises(MailFetchError, match="inner error"):
+ await processor._fetch_imap_emails(10, set())
+
+ async def test_response_lines_with_star_prefix_are_skipped(self):
+ """Response lines starting with b'*' are filtered out."""
+ email_bytes = b"From: a@b.com\r\nSubject: test\r\n\r\nbody"
+ account = _make_account()
+ processor = MailProcessor(account, "secret")
+
+ mock_imap = AsyncMock()
+ mock_imap.wait_hello_from_server = AsyncMock()
+ mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response("OK", lines=[b"1"])
+ )
+ mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "99")]))
+
+ fetch_resp = _make_imap_response(
+ "OK",
+ lines=[
+ b"1 (UID 99 RFC822 {100}", # header containing RFC822
+ "some string line", # non-bytes/bytearray => skipped
+ b"* extra info", # starts with * => skipped
+ email_bytes, # actual data
+ b")", # closing paren => skipped
+ ],
+ )
+
+ async def uid_side_effect(cmd, *args):
+ if cmd == "fetch":
+ return fetch_resp
+ return _make_imap_response("OK")
+
+ mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
+ mock_imap.logout = AsyncMock()
+
+ with patch(
+ "app.services.mail_processor.aioimaplib.IMAP4_SSL",
+ return_value=mock_imap,
+ ):
+ emails, new_uids = await processor._fetch_imap_emails(10, set())
+
+ assert len(emails) == 1
+ assert emails[0] == email_bytes
+ assert new_uids == ["99"]
diff --git a/backend/tests/unit/test_mail_processor_pop3.py b/backend/tests/unit/test_mail_processor_pop3.py
new file mode 100644
index 0000000..f01ab2e
--- /dev/null
+++ b/backend/tests/unit/test_mail_processor_pop3.py
@@ -0,0 +1,782 @@
+"""
+Unit tests for POP3 fetch, connection testing, and email forwarding in MailProcessor.
+
+These tests cover the methods not exercised by test_mail_processor_imap.py:
+ - test_connection() routing to POP3 / IMAP helpers
+ - _test_pop3_connection() for POP3_SSL and plain POP3
+ - _test_imap_connection() for IMAP_SSL and plain IMAP
+ - fetch_emails() delegation to POP3 / IMAP
+ - _fetch_pop3_emails() end-to-end: UIDL, skip-seen, max_count, delete, errors
+ - forward_email() via STARTTLS and SSL, multipart / plain, error paths
+"""
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from app.models.database_models import MailProtocol
+from app.services.mail_processor import (
+ MailProcessor,
+ MailFetchError,
+ MailForwardError,
+)
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_account(
+ protocol="pop3_ssl",
+ host="pop.example.com",
+ port=995,
+ username="user@example.com",
+ delete_after_forward=False,
+ account_id=1,
+ max_emails_per_check=50,
+):
+ """Return a minimal MailAccount-like mock."""
+ proto_map = {
+ "pop3_ssl": MailProtocol.POP3_SSL,
+ "pop3": MailProtocol.POP3,
+ "imap_ssl": MailProtocol.IMAP_SSL,
+ "imap": MailProtocol.IMAP,
+ }
+ account = MagicMock()
+ account.id = account_id
+ account.host = host
+ account.port = port
+ account.username = username
+ account.delete_after_forward = delete_after_forward
+ account.protocol = proto_map[protocol]
+ account.max_emails_per_check = max_emails_per_check
+ return account
+
+
+# ---------------------------------------------------------------------------
+# test_connection – routing
+# ---------------------------------------------------------------------------
+
+
+class TestTestConnection:
+ """test_connection() should delegate to POP3 or IMAP helpers."""
+
+ async def test_routes_to_pop3_for_pop3_ssl(self):
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ with patch.object(
+ proc, "_test_pop3_connection", new_callable=AsyncMock
+ ) as mock:
+ mock.return_value = (True, "ok")
+ result = await proc.test_connection()
+ mock.assert_awaited_once()
+ assert result == (True, "ok")
+
+ async def test_routes_to_pop3_for_pop3(self):
+ account = _make_account(protocol="pop3")
+ proc = MailProcessor(account, "secret")
+ with patch.object(
+ proc, "_test_pop3_connection", new_callable=AsyncMock
+ ) as mock:
+ mock.return_value = (True, "ok")
+ result = await proc.test_connection()
+ mock.assert_awaited_once()
+ assert result == (True, "ok")
+
+ async def test_routes_to_imap_for_imap_ssl(self):
+ account = _make_account(protocol="imap_ssl")
+ proc = MailProcessor(account, "secret")
+ with patch.object(
+ proc, "_test_imap_connection", new_callable=AsyncMock
+ ) as mock:
+ mock.return_value = (True, "connected")
+ result = await proc.test_connection()
+ mock.assert_awaited_once()
+ assert result == (True, "connected")
+
+ async def test_routes_to_imap_for_imap(self):
+ account = _make_account(protocol="imap")
+ proc = MailProcessor(account, "secret")
+ with patch.object(
+ proc, "_test_imap_connection", new_callable=AsyncMock
+ ) as mock:
+ mock.return_value = (True, "connected")
+ result = await proc.test_connection()
+ mock.assert_awaited_once()
+ assert result == (True, "connected")
+
+ async def test_returns_false_on_unexpected_exception(self):
+ account = _make_account(protocol="imap_ssl")
+ proc = MailProcessor(account, "secret")
+ with patch.object(
+ proc, "_test_imap_connection", new_callable=AsyncMock
+ ) as mock:
+ mock.side_effect = RuntimeError("boom")
+ success, msg = await proc.test_connection()
+ assert success is False
+ assert "boom" in msg
+
+
+# ---------------------------------------------------------------------------
+# _test_pop3_connection
+# ---------------------------------------------------------------------------
+
+
+class TestTestPop3Connection:
+ """Unit tests for _test_pop3_connection()."""
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_pop3_ssl_success(self, mock_poplib):
+ """POP3_SSL: successful connection reports message count."""
+ mock_conn = MagicMock()
+ mock_conn.stat.return_value = (42, 123456)
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_pop3_connection()
+
+ assert success is True
+ assert "42 messages" in msg
+ mock_conn.user.assert_called_once_with("user@example.com")
+ mock_conn.pass_.assert_called_once_with("secret")
+ mock_conn.quit.assert_called_once()
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_pop3_plain_success(self, mock_poplib):
+ """Plain POP3: uses POP3 (not POP3_SSL)."""
+ mock_conn = MagicMock()
+ mock_conn.stat.return_value = (10, 5000)
+ mock_poplib.POP3.return_value = mock_conn
+
+ account = _make_account(protocol="pop3", port=110)
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_pop3_connection()
+
+ assert success is True
+ assert "10 messages" in msg
+ mock_poplib.POP3.assert_called_once()
+ mock_poplib.POP3_SSL.assert_not_called()
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_pop3_auth_error(self, mock_poplib):
+ """Authentication failure returns False with auth message."""
+ import poplib as real_poplib
+
+ mock_conn = MagicMock()
+ mock_conn.user.side_effect = real_poplib.error_proto("authentication failed")
+ mock_poplib.POP3_SSL.return_value = mock_conn
+ mock_poplib.error_proto = real_poplib.error_proto
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_pop3_connection()
+
+ assert success is False
+ assert "Authentication failed" in msg
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_pop3_protocol_error(self, mock_poplib):
+ """Non-auth protocol error returns False with protocol error message."""
+ import poplib as real_poplib
+
+ mock_conn = MagicMock()
+ mock_conn.user.side_effect = real_poplib.error_proto("some protocol error")
+ mock_poplib.POP3_SSL.return_value = mock_conn
+ mock_poplib.error_proto = real_poplib.error_proto
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_pop3_connection()
+
+ assert success is False
+ assert "POP3 protocol error" in msg
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_pop3_generic_exception(self, mock_poplib):
+ """Generic exception returns False with connection-failed message."""
+ import poplib as real_poplib
+
+ mock_poplib.error_proto = real_poplib.error_proto
+ mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_pop3_connection()
+
+ assert success is False
+ assert "Connection failed" in msg
+
+
+# ---------------------------------------------------------------------------
+# _test_imap_connection
+# ---------------------------------------------------------------------------
+
+
+class TestTestImapConnection:
+ """Unit tests for _test_imap_connection()."""
+
+ @patch("app.services.mail_processor.aioimaplib")
+ async def test_imap_ssl_success(self, mock_aioimaplib):
+ """IMAP_SSL: successful connection reports message count."""
+ mock_client = AsyncMock()
+ mock_aioimaplib.IMAP4_SSL.return_value = mock_client
+
+ # login response
+ login_resp = MagicMock()
+ login_resp.result = "OK"
+ mock_client.login.return_value = login_resp
+
+ # search response with 3 messages
+ search_resp = MagicMock()
+ search_resp.lines = [b"1 2 3"]
+ mock_client.search.return_value = search_resp
+
+ account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_imap_connection()
+
+ assert success is True
+ assert "3 messages" in msg
+ mock_client.wait_hello_from_server.assert_awaited_once()
+ mock_client.login.assert_awaited_once()
+ mock_client.select.assert_awaited_once_with("INBOX")
+ mock_client.logout.assert_awaited_once()
+
+ @patch("app.services.mail_processor.aioimaplib")
+ async def test_imap_plain_success(self, mock_aioimaplib):
+ """Plain IMAP: uses IMAP4, not IMAP4_SSL."""
+ mock_client = AsyncMock()
+ mock_aioimaplib.IMAP4.return_value = mock_client
+
+ login_resp = MagicMock()
+ login_resp.result = "OK"
+ mock_client.login.return_value = login_resp
+
+ search_resp = MagicMock()
+ search_resp.lines = [b"1"]
+ mock_client.search.return_value = search_resp
+
+ account = _make_account(protocol="imap", host="imap.example.com", port=143)
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_imap_connection()
+
+ assert success is True
+ mock_aioimaplib.IMAP4.assert_called_once()
+ mock_aioimaplib.IMAP4_SSL.assert_not_called()
+
+ @patch("app.services.mail_processor.aioimaplib")
+ async def test_imap_auth_failure(self, mock_aioimaplib):
+ """Authentication failure returns False with auth failure message."""
+ mock_client = AsyncMock()
+ mock_aioimaplib.IMAP4_SSL.return_value = mock_client
+
+ login_resp = MagicMock()
+ login_resp.result = "NO"
+ login_resp.lines = ["Invalid credentials"]
+ mock_client.login.return_value = login_resp
+
+ account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_imap_connection()
+
+ assert success is False
+ assert "Authentication failed" in msg
+
+ @patch("app.services.mail_processor.aioimaplib")
+ async def test_imap_generic_exception(self, mock_aioimaplib):
+ """Generic exception returns False with IMAP-connection-failed message."""
+ mock_aioimaplib.IMAP4_SSL.side_effect = OSError("network unreachable")
+
+ account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
+ proc = MailProcessor(account, "secret")
+ success, msg = await proc._test_imap_connection()
+
+ assert success is False
+ assert "IMAP connection failed" in msg
+
+
+# ---------------------------------------------------------------------------
+# fetch_emails – routing and defaults
+# ---------------------------------------------------------------------------
+
+
+class TestFetchEmails:
+ """fetch_emails() should route and fill defaults correctly."""
+
+ async def test_delegates_to_pop3_for_pop3_ssl(self):
+ account = _make_account(protocol="pop3_ssl", max_emails_per_check=25)
+ proc = MailProcessor(account, "secret")
+ with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
+ mock.return_value = ([b"email"], ["uid1"])
+ result = await proc.fetch_emails()
+ # Should use max_emails_per_check as default
+ mock.assert_awaited_once_with(25, set())
+ assert result == ([b"email"], ["uid1"])
+
+ async def test_delegates_to_imap_for_imap_ssl(self):
+ account = _make_account(protocol="imap_ssl")
+ proc = MailProcessor(account, "secret")
+ with patch.object(proc, "_fetch_imap_emails", new_callable=AsyncMock) as mock:
+ mock.return_value = ([], [])
+ await proc.fetch_emails(max_count=10, already_seen_uids={"u1"})
+ mock.assert_awaited_once_with(10, {"u1"})
+
+ async def test_uses_max_count_when_provided(self):
+ account = _make_account(protocol="pop3", max_emails_per_check=100)
+ proc = MailProcessor(account, "secret")
+ with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
+ mock.return_value = ([], [])
+ await proc.fetch_emails(max_count=5)
+ mock.assert_awaited_once_with(5, set())
+
+ async def test_uses_max_emails_per_check_when_no_max_count(self):
+ account = _make_account(protocol="pop3_ssl", max_emails_per_check=77)
+ proc = MailProcessor(account, "secret")
+ with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
+ mock.return_value = ([], [])
+ await proc.fetch_emails()
+ mock.assert_awaited_once_with(77, set())
+
+
+# ---------------------------------------------------------------------------
+# _fetch_pop3_emails
+# ---------------------------------------------------------------------------
+
+
+class TestFetchPop3Emails:
+ """Unit tests for _fetch_pop3_emails()."""
+
+ def _make_pop3_mock(self, uid_entries, retr_data=None, retr_errors=None):
+ """Build a mock POP3 connection.
+
+ Args:
+ uid_entries: list of (msg_num, uid_string) pairs
+ retr_data: dict mapping msg_num -> bytes to return from retr()
+ retr_errors: dict mapping msg_num -> exception for retr()
+ """
+ mock_conn = MagicMock()
+ uidl_lines = [f"{n} {uid}".encode() for n, uid in uid_entries]
+ mock_conn.uidl.return_value = (b"+OK", uidl_lines, 0)
+
+ retr_data = retr_data or {}
+ retr_errors = retr_errors or {}
+
+ def retr_side_effect(msg_num):
+ if msg_num in retr_errors:
+ raise retr_errors[msg_num]
+ data = retr_data.get(msg_num, b"From: test\r\nSubject: hi\r\n\r\nbody")
+ return (b"+OK", data.split(b"\r\n"), len(data))
+
+ mock_conn.retr.side_effect = retr_side_effect
+ return mock_conn
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_fetch_pop3_ssl_basic(self, mock_poplib):
+ """POP3_SSL: fetches messages and returns email data + UIDs."""
+ mock_conn = self._make_pop3_mock(
+ uid_entries=[(1, "abc"), (2, "def")],
+ retr_data={
+ 1: b"From: a@b.com\r\nSubject: A\r\n\r\nBody A",
+ 2: b"From: c@d.com\r\nSubject: B\r\n\r\nBody B",
+ },
+ )
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ assert len(emails) == 2
+ assert uids == ["abc", "def"]
+ mock_conn.quit.assert_called_once()
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_fetch_pop3_plain(self, mock_poplib):
+ """Plain POP3: uses POP3 (not POP3_SSL)."""
+ mock_conn = self._make_pop3_mock(uid_entries=[(1, "uid1")])
+ mock_poplib.POP3.return_value = mock_conn
+
+ account = _make_account(protocol="pop3", port=110)
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ assert len(emails) == 1
+ assert uids == ["uid1"]
+ mock_poplib.POP3.assert_called_once()
+ mock_poplib.POP3_SSL.assert_not_called()
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_skips_already_seen_uids(self, mock_poplib):
+ """Already-seen UIDs are skipped."""
+ mock_conn = self._make_pop3_mock(
+ uid_entries=[(1, "seen1"), (2, "new1"), (3, "seen2")]
+ )
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, {"seen1", "seen2"})
+
+ assert uids == ["new1"]
+ assert len(emails) == 1
+ # retr should only be called for msg 2
+ mock_conn.retr.assert_called_once_with(2)
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_respects_max_count(self, mock_poplib):
+ """Only max_count messages are fetched."""
+ mock_conn = self._make_pop3_mock(
+ uid_entries=[(1, "a"), (2, "b"), (3, "c"), (4, "d")]
+ )
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(2, set())
+
+ assert len(emails) == 2
+ assert len(uids) == 2
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_delete_after_forward(self, mock_poplib):
+ """delete_after_forward=True issues dele() for fetched messages."""
+ mock_conn = self._make_pop3_mock(uid_entries=[(1, "a"), (2, "b")])
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
+ proc = MailProcessor(account, "secret")
+ await proc._fetch_pop3_emails(10, set())
+
+ assert mock_conn.dele.call_count == 2
+ mock_conn.dele.assert_any_call(1)
+ mock_conn.dele.assert_any_call(2)
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_no_delete_when_disabled(self, mock_poplib):
+ """delete_after_forward=False: no dele() calls."""
+ mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl", delete_after_forward=False)
+ proc = MailProcessor(account, "secret")
+ await proc._fetch_pop3_emails(10, set())
+
+ mock_conn.dele.assert_not_called()
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_individual_retr_error_does_not_abort(self, mock_poplib):
+ """A single message retr() failure doesn't stop the entire fetch."""
+ mock_conn = self._make_pop3_mock(
+ uid_entries=[(1, "a"), (2, "b"), (3, "c")],
+ retr_errors={2: Exception("corrupt message")},
+ )
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ # Messages 1 and 3 should still be fetched
+ assert len(emails) == 2
+ assert "a" in uids
+ assert "c" in uids
+ assert "b" not in uids
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_delete_error_does_not_abort(self, mock_poplib):
+ """A dele() error is logged but doesn't raise."""
+ mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
+ mock_conn.dele.side_effect = Exception("delete failed")
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
+ proc = MailProcessor(account, "secret")
+ # Should not raise
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+ assert len(emails) == 1
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib):
+ """Connection failure raises MailFetchError."""
+ mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ with pytest.raises(MailFetchError, match="POP3 fetch error"):
+ await proc._fetch_pop3_emails(10, set())
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_empty_mailbox(self, mock_poplib):
+ """Empty mailbox returns empty lists."""
+ mock_conn = self._make_pop3_mock(uid_entries=[])
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ assert emails == []
+ assert uids == []
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_uidl_parsing_handles_extra_whitespace(self, mock_poplib):
+ """UIDL entries with extra whitespace in UID are stripped."""
+ mock_conn = MagicMock()
+ mock_conn.uidl.return_value = (b"+OK", [b"1 uid_with_space "], 0)
+ mock_conn.retr.return_value = (
+ b"+OK",
+ [b"From: x", b"", b"body"],
+ 10,
+ )
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ assert uids == ["uid_with_space"]
+
+ @patch("app.services.mail_processor.poplib")
+ async def test_malformed_uidl_entry_is_skipped(self, mock_poplib):
+ """UIDL entry without a space (malformed) is silently skipped."""
+ mock_conn = MagicMock()
+ # One malformed entry (no space), one valid entry
+ mock_conn.uidl.return_value = (
+ b"+OK",
+ [b"malformed_no_space", b"2 valid_uid"],
+ 0,
+ )
+ mock_conn.retr.return_value = (b"+OK", [b"From: x", b"", b"body"], 10)
+ mock_poplib.POP3_SSL.return_value = mock_conn
+
+ account = _make_account(protocol="pop3_ssl")
+ proc = MailProcessor(account, "secret")
+ emails, uids = await proc._fetch_pop3_emails(10, set())
+
+ # Only the valid entry should be processed
+ assert uids == ["valid_uid"]
+ assert len(emails) == 1
+
+
+# ---------------------------------------------------------------------------
+# forward_email
+# ---------------------------------------------------------------------------
+
+
+class TestForwardEmail:
+ """Unit tests for forward_email()."""
+
+ SMTP_CONFIG = {
+ "host": "smtp.example.com",
+ "port": 587,
+ "username": "sender@example.com",
+ "password": "smtp_pass",
+ "use_tls": True,
+ }
+
+ SMTP_CONFIG_SSL = {
+ "host": "smtp.example.com",
+ "port": 465,
+ "username": "sender@example.com",
+ "password": "smtp_pass",
+ "use_tls": False,
+ }
+
+ SIMPLE_EMAIL = (
+ b"From: original@sender.com\r\n"
+ b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
+ b"Subject: Test Subject\r\n"
+ b"\r\n"
+ b"Hello, this is the body."
+ )
+
+ MULTIPART_EMAIL = (
+ b"From: original@sender.com\r\n"
+ b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
+ b"Subject: Multipart Test\r\n"
+ b"MIME-Version: 1.0\r\n"
+ b'Content-Type: multipart/mixed; boundary="boundary123"\r\n'
+ b"\r\n"
+ b"--boundary123\r\n"
+ b"Content-Type: text/plain; charset=utf-8\r\n"
+ b"\r\n"
+ b"Plain text body.\r\n"
+ b"--boundary123--\r\n"
+ )
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_starttls(self, mock_smtplib):
+ """STARTTLS path: SMTP + starttls() is used."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ result = await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ assert result is True
+ mock_smtplib.SMTP.assert_called_once_with("smtp.example.com", 587, timeout=30)
+ mock_server.starttls.assert_called_once()
+ mock_server.login.assert_called_once_with("sender@example.com", "smtp_pass")
+ mock_server.send_message.assert_called_once()
+ mock_server.quit.assert_called_once()
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_ssl(self, mock_smtplib):
+ """SSL path: SMTP_SSL is used when use_tls=False."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP_SSL.return_value = mock_server
+
+ result = await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG_SSL
+ )
+
+ assert result is True
+ mock_smtplib.SMTP_SSL.assert_called_once_with(
+ "smtp.example.com", 465, timeout=30
+ )
+ mock_server.starttls.assert_not_called()
+ mock_server.login.assert_called_once()
+ mock_server.send_message.assert_called_once()
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_preserves_subject(self, mock_smtplib):
+ """Forwarded email subject includes source account name and original subject."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "Work Mail", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ sent_msg = mock_server.send_message.call_args[0][0]
+ assert "[Fwd from Work Mail]" in sent_msg["Subject"]
+ assert "Test Subject" in sent_msg["Subject"]
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_sets_from_and_to(self, mock_smtplib):
+ """Forwarded email has correct From/To headers."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ sent_msg = mock_server.send_message.call_args[0][0]
+ assert sent_msg["From"] == "sender@example.com"
+ assert sent_msg["To"] == "dest@example.com"
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_multipart_email(self, mock_smtplib):
+ """Multipart email: extracts text/plain body."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ result = await MailProcessor.forward_email(
+ self.MULTIPART_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ assert result is True
+ sent_msg = mock_server.send_message.call_args[0][0]
+ # Body should contain original header info and plain text
+ payload = sent_msg.get_payload()
+ assert len(payload) > 0
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_email_body_contains_header_info(self, mock_smtplib):
+ """Forwarded body includes original From, Date, Subject, Source Account."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "WorkAccount", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ sent_msg = mock_server.send_message.call_args[0][0]
+ # Get body from the MIME parts
+ body_part = sent_msg.get_payload()[0]
+ body_text = body_part.get_payload(decode=True).decode("utf-8")
+
+ assert "Originally from: original@sender.com" in body_text
+ assert "Source Account: WorkAccount" in body_text
+ assert "Hello, this is the body." in body_text
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_smtp_error_raises_forward_error(self, mock_smtplib):
+ """SMTP send failure raises MailForwardError."""
+ mock_server = MagicMock()
+ mock_server.login.side_effect = Exception("auth failed")
+ mock_smtplib.SMTP.return_value = mock_server
+
+ with pytest.raises(MailForwardError, match="Forward error"):
+ await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_quit_error_does_not_mask_success(self, mock_smtplib):
+ """If quit() fails after successful send, True is still returned."""
+ mock_server = MagicMock()
+ mock_server.quit.side_effect = Exception("quit error")
+ mock_smtplib.SMTP.return_value = mock_server
+
+ result = await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ assert result is True
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_connection_error_raises_forward_error(self, mock_smtplib):
+ """SMTP connection failure raises MailForwardError."""
+ mock_smtplib.SMTP.side_effect = OSError("connection refused")
+
+ with pytest.raises(MailForwardError, match="Forward error"):
+ await MailProcessor.forward_email(
+ self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_email_without_payload(self, mock_smtplib):
+ """Email with no payload body is forwarded with just header info."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ empty_body_email = b"From: x@y.com\r\n" b"Subject: Empty\r\n" b"\r\n"
+
+ result = await MailProcessor.forward_email(
+ empty_body_email, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ assert result is True
+
+ @patch("app.services.mail_processor.smtplib")
+ async def test_forward_multipart_no_text_plain(self, mock_smtplib):
+ """Multipart email with no text/plain part forwards with empty body."""
+ mock_server = MagicMock()
+ mock_smtplib.SMTP.return_value = mock_server
+
+ html_only_email = (
+ b"From: x@y.com\r\n"
+ b"Subject: HTML Only\r\n"
+ b"MIME-Version: 1.0\r\n"
+ b'Content-Type: multipart/mixed; boundary="bnd"\r\n'
+ b"\r\n"
+ b"--bnd\r\n"
+ b"Content-Type: text/html; charset=utf-8\r\n"
+ b"\r\n"
+ b"
HTML body
\r\n"
+ b"--bnd--\r\n"
+ )
+
+ result = await MailProcessor.forward_email(
+ html_only_email, "Acct", "dest@example.com", self.SMTP_CONFIG
+ )
+
+ assert result is True
+ sent_msg = mock_server.send_message.call_args[0][0]
+ body_part = sent_msg.get_payload()[0]
+ body_text = body_part.get_payload(decode=True).decode("utf-8")
+ # Body should have header info but no HTML content extracted
+ assert "Originally from: x@y.com" in body_text
diff --git a/docs/TODO.md b/docs/TODO.md
index 8c81680..84e970a 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines
## ✅ Recently Completed
+- [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%.
+
- [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`.
- [x] **ESLint fix**: Converted `frontend/jest.config.js` to `jest.config.mjs` (ES module syntax) to resolve `@typescript-eslint/no-require-imports` lint error.
- [x] **Codecov integration**: Added Codecov coverage reporting with `CODECOV_TOKEN` authentication. Set up Jest for frontend tests with lcov coverage, updated CI to collect and upload both backend (XML via pytest-cov) and frontend (lcov via Jest) coverage reports to Codecov with separate `backend` and `frontend` flags.
@@ -116,6 +118,7 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Write unit tests for application factory and core endpoints
- [x] Reach 50%+ test coverage (currently 59%)
- [x] Write tests for Celery tasks (96% coverage for `tasks.py`)
+- [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites.
### In Progress 🔨
- [ ] Write unit tests for authentication (target 80%+ coverage)
diff --git a/frontend/jest.config.mjs b/frontend/jest.config.mjs
index 547dfae..ed96df8 100644
--- a/frontend/jest.config.mjs
+++ b/frontend/jest.config.mjs
@@ -6,6 +6,7 @@ const createJestConfig = nextJest({
const customJestConfig = {
testEnvironment: 'jest-environment-jsdom',
+ setupFilesAfterEnv: ['/src/test-setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '/src/$1',
},
@@ -16,6 +17,7 @@ const customJestConfig = {
'!src/**/layout.tsx',
'!src/**/page.tsx',
'!src/instrumentation.ts',
+ '!src/test-setup.ts',
],
};
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3aad4d0..4a7a9cc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -18,6 +18,9 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -30,6 +33,13 @@
"typescript": "^5"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -518,6 +528,16 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2678,6 +2698,156 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2689,6 +2859,14 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2819,7 +2997,7 @@
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -4238,6 +4416,13 @@
"node": ">= 8"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -4256,7 +4441,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -4436,6 +4621,17 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4469,6 +4665,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6082,6 +6286,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -7891,6 +8105,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8011,6 +8236,16 @@
"node": ">=6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -8855,6 +9090,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9690,6 +9939,19 @@
"node": ">=6"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index dce4123..94c5b76 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,6 +21,9 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
diff --git a/frontend/src/components/AuthGuard.test.tsx b/frontend/src/components/AuthGuard.test.tsx
new file mode 100644
index 0000000..b79f9e0
--- /dev/null
+++ b/frontend/src/components/AuthGuard.test.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { AuthGuard } from './AuthGuard';
+
+// Mock next/navigation
+const mockPush = jest.fn();
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+// Mock userApi
+const mockGetCurrentUser = jest.fn();
+jest.mock('@/lib/api', () => ({
+ userApi: {
+ getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
+ },
+}));
+
+// Mock authStore
+const mockSetUser = jest.fn();
+const mockSetLoading = jest.fn();
+let mockUser: Record | null = null;
+let mockIsLoading = true;
+
+jest.mock('@/store/authStore', () => ({
+ useAuthStore: () => ({
+ user: mockUser,
+ isLoading: mockIsLoading,
+ setUser: mockSetUser,
+ setLoading: mockSetLoading,
+ }),
+}));
+
+const mockUserData = {
+ id: 1,
+ email: 'test@example.com',
+ full_name: 'Test User',
+ is_active: true,
+ is_superuser: false,
+ subscription_tier: 'free',
+ subscription_status: 'active',
+ created_at: '2024-01-01T00:00:00Z',
+};
+
+describe('AuthGuard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorage.clear();
+ mockUser = null;
+ mockIsLoading = true;
+ });
+
+ it('should show loading spinner while isLoading is true', () => {
+ mockIsLoading = true;
+ localStorage.setItem('access_token', 'test-token');
+ mockGetCurrentUser.mockResolvedValue(mockUserData);
+
+ render(
+
+ Protected Content
+
+ );
+
+ // Should show spinner (via animate-spin class)
+ const spinner = document.querySelector('.animate-spin');
+ expect(spinner).toBeInTheDocument();
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
+ });
+
+ it('should render children when user is authenticated', () => {
+ mockUser = mockUserData;
+ mockIsLoading = false;
+ localStorage.setItem('access_token', 'test-token');
+
+ render(
+
+ Protected Content
+
+ );
+
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
+ });
+
+ it('should render nothing when not loading and no user', () => {
+ mockUser = null;
+ mockIsLoading = false;
+
+ const { container } = render(
+
+ Protected Content
+
+ );
+
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should redirect to /login when no token exists', async () => {
+ // No token in localStorage
+ mockGetCurrentUser.mockResolvedValue(mockUserData);
+
+ render(
+
+ Protected Content
+
+ );
+
+ await waitFor(() => {
+ expect(mockSetLoading).toHaveBeenCalledWith(false);
+ expect(mockPush).toHaveBeenCalledWith('/login');
+ });
+ });
+
+ it('should fetch user data when token exists', async () => {
+ localStorage.setItem('access_token', 'valid-token');
+ mockGetCurrentUser.mockResolvedValue(mockUserData);
+
+ render(
+
+ Protected Content
+
+ );
+
+ await waitFor(() => {
+ expect(mockGetCurrentUser).toHaveBeenCalled();
+ expect(mockSetUser).toHaveBeenCalledWith(mockUserData);
+ });
+ });
+
+ it('should redirect to /login when API call fails', async () => {
+ localStorage.setItem('access_token', 'expired-token');
+ mockGetCurrentUser.mockRejectedValue(new Error('Unauthorized'));
+
+ render(
+
+ Protected Content
+
+ );
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(null);
+ expect(mockPush).toHaveBeenCalledWith('/login');
+ });
+ });
+});
diff --git a/frontend/src/components/DashboardLayout.test.tsx b/frontend/src/components/DashboardLayout.test.tsx
new file mode 100644
index 0000000..0946925
--- /dev/null
+++ b/frontend/src/components/DashboardLayout.test.tsx
@@ -0,0 +1,248 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DashboardLayout } from './DashboardLayout';
+
+// Mock next/navigation
+const mockPush = jest.fn();
+let mockPathname = '/dashboard';
+jest.mock('next/navigation', () => ({
+ usePathname: () => mockPathname,
+ useRouter: () => ({ push: mockPush }),
+}));
+
+// Mock next/link to render a simple anchor
+jest.mock('next/link', () => {
+ const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
+ {children}
+ );
+ MockLink.displayName = 'MockLink';
+ return MockLink;
+});
+
+// Mock @tanstack/react-query
+let mockVersionInfo: Record | null = null;
+jest.mock('@tanstack/react-query', () => ({
+ useQuery: () => ({ data: mockVersionInfo }),
+}));
+
+// Mock lucide-react icons as simple spans
+jest.mock('lucide-react', () => {
+ const iconNames = [
+ 'LayoutDashboard', 'Mail', 'Settings', 'LogOut', 'Menu', 'X',
+ 'User', 'Shield', 'Users', 'CreditCard', 'Bell', 'Inbox', 'Activity',
+ ];
+ const icons: Record> = {};
+ iconNames.forEach((name) => {
+ icons[name] = ({ className }: { className?: string }) => (
+
+ );
+ });
+ return icons;
+});
+
+// Mock authStore
+const mockLogout = jest.fn();
+let mockUser: Record | null = null;
+
+jest.mock('@/store/authStore', () => ({
+ useAuthStore: () => ({
+ user: mockUser,
+ logout: mockLogout,
+ }),
+}));
+
+// Mock versionApi
+jest.mock('@/lib/api', () => ({
+ versionApi: {
+ get: jest.fn(),
+ },
+}));
+
+const regularUser = {
+ id: 1,
+ email: 'user@example.com',
+ full_name: 'Regular User',
+ is_active: true,
+ is_superuser: false,
+ subscription_tier: 'free',
+ subscription_status: 'active',
+ created_at: '2024-01-01T00:00:00Z',
+};
+
+const adminUser = {
+ ...regularUser,
+ id: 2,
+ email: 'admin@example.com',
+ full_name: 'Admin User',
+ is_superuser: true,
+};
+
+describe('DashboardLayout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockPathname = '/dashboard';
+ mockUser = regularUser;
+ mockVersionInfo = null;
+ });
+
+ it('should render the InboxConverge branding', () => {
+ render(
+
+ Content
+
+ );
+ // Desktop sidebar has the branding
+ expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0);
+ });
+
+ it('should render main navigation items', () => {
+ render(
+
+ Content
+
+ );
+ expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Mail Accounts').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Mailbox Activity').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
+ });
+
+ it('should render children in main content area', () => {
+ render(
+
+ Page Content
+
+ );
+ expect(screen.getByTestId('page-content')).toBeInTheDocument();
+ expect(screen.getByText('Page Content')).toBeInTheDocument();
+ });
+
+ it('should display user info in the top bar', () => {
+ render(
+
+ Content
+
+ );
+ expect(screen.getByText('Regular User')).toBeInTheDocument();
+ expect(screen.getByText('user@example.com')).toBeInTheDocument();
+ });
+
+ it('should not show admin navigation for regular users', () => {
+ render(
+
+ Content
+
+ );
+ expect(screen.queryByText('Admin Overview')).not.toBeInTheDocument();
+ expect(screen.queryByText('Manage Users')).not.toBeInTheDocument();
+ expect(screen.queryByText('Manage Plans')).not.toBeInTheDocument();
+ expect(screen.queryByText('Activity Logs')).not.toBeInTheDocument();
+ });
+
+ it('should show admin navigation for superusers', () => {
+ mockUser = adminUser;
+ render(
+
+ Content
+
+ );
+ expect(screen.getAllByText('Admin Overview').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Manage Users').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Manage Plans').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Activity Logs').length).toBeGreaterThan(0);
+ });
+
+ it('should show Admin badge for superusers', () => {
+ mockUser = adminUser;
+ render(
+
+ Content
+
+ );
+ // The Admin badge has a specific class for styling
+ const adminBadges = screen.getAllByText('Admin');
+ const badge = adminBadges.find((el) => el.classList.contains('bg-purple-100'));
+ expect(badge).toBeInTheDocument();
+ });
+
+ it('should not show Admin badge for regular users', () => {
+ render(
+
+ Content
+
+ );
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
+
+ it('should call logout and redirect on Logout button click', () => {
+ render(
+
+ Content
+
+ );
+ // Click the first Logout button (desktop sidebar)
+ const logoutButtons = screen.getAllByText('Logout');
+ fireEvent.click(logoutButtons[0]);
+ expect(mockLogout).toHaveBeenCalled();
+ expect(mockPush).toHaveBeenCalledWith('/login');
+ });
+
+ it('should display the current page title based on pathname', () => {
+ mockPathname = '/accounts';
+ render(
+
+ Content
+
+ );
+ // The top bar should show "Mail Accounts" as the h2 heading
+ const headings = screen.getAllByText('Mail Accounts');
+ // At least one should be a heading in the top bar
+ expect(headings.length).toBeGreaterThan(0);
+ });
+
+ it('should show version info in the footer when available', () => {
+ mockVersionInfo = { version: '1.2.3', build_date: '2024-06-15T12:00:00Z' };
+ render(
+
+ Content
+
+ );
+ expect(screen.getByText('v1.2.3')).toBeInTheDocument();
+ });
+
+ it('should not show version info when not available', () => {
+ mockVersionInfo = null;
+ render(
+
+ Content
+
+ );
+ expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
+ });
+
+ it('should render footer links', () => {
+ render(
+
+ Content
+
+ );
+ expect(screen.getByText('Impressum')).toBeInTheDocument();
+ expect(screen.getByText('Datenschutz')).toBeInTheDocument();
+ });
+
+ it('should open mobile sidebar on menu button click', () => {
+ render(
+
+ Content
+
+ );
+ // The mobile menu button has a Menu icon
+ const menuButton = screen.getByTestId('icon-Menu').closest('button');
+ expect(menuButton).toBeInTheDocument();
+ fireEvent.click(menuButton!);
+
+ // After clicking, the close (X) button should appear
+ expect(screen.getByTestId('icon-X')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/NotificationWizard.test.tsx b/frontend/src/components/NotificationWizard.test.tsx
new file mode 100644
index 0000000..7c00d78
--- /dev/null
+++ b/frontend/src/components/NotificationWizard.test.tsx
@@ -0,0 +1,346 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { NotificationWizard } from './NotificationWizard';
+
+// Mock lucide-react icons as simple spans
+jest.mock('lucide-react', () => ({
+ ArrowLeft: ({ className }: { className?: string }) => ,
+ Bell: ({ className }: { className?: string }) => ,
+ Check: ({ className }: { className?: string }) => ,
+ Eye: ({ className }: { className?: string }) => ,
+ EyeOff: ({ className }: { className?: string }) => ,
+ Send: ({ className }: { className?: string }) => ,
+}));
+
+const mockOnComplete = jest.fn();
+const mockOnCancel = jest.fn();
+
+describe('NotificationWizard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // ── Step 1: Channel Selection ──────────────────────────────────────────
+
+ describe('Step 1 - Channel Selection', () => {
+ it('should render all channel options', () => {
+ render();
+ expect(screen.getByText('Telegram')).toBeInTheDocument();
+ expect(screen.getByText('Discord')).toBeInTheDocument();
+ expect(screen.getByText('Slack')).toBeInTheDocument();
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ expect(screen.getByText('Webhook')).toBeInTheDocument();
+ expect(screen.getByText('Custom Apprise URL')).toBeInTheDocument();
+ });
+
+ it('should show the channel selection heading', () => {
+ render();
+ expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument();
+ });
+
+ it('should call onCancel when Cancel button is clicked', () => {
+ render();
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(mockOnCancel).toHaveBeenCalled();
+ });
+
+ it('should navigate to step 2 when a channel is selected', () => {
+ render();
+ fireEvent.click(screen.getByText('Telegram'));
+ // Step 2 shows field labels
+ expect(screen.getByText('Bot Token')).toBeInTheDocument();
+ expect(screen.getByText('Chat ID')).toBeInTheDocument();
+ });
+ });
+
+ // ── Step 2: Field Entry ────────────────────────────────────────────────
+
+ describe('Step 2 - Field Entry', () => {
+ function goToStep2(channel: string) {
+ render();
+ fireEvent.click(screen.getByText(channel));
+ }
+
+ it('should show Telegram fields', () => {
+ goToStep2('Telegram');
+ expect(screen.getByText('Bot Token')).toBeInTheDocument();
+ expect(screen.getByText('Chat ID')).toBeInTheDocument();
+ });
+
+ it('should show Discord fields', () => {
+ goToStep2('Discord');
+ expect(screen.getByText('Discord Webhook URL')).toBeInTheDocument();
+ });
+
+ it('should show Slack fields', () => {
+ goToStep2('Slack');
+ expect(screen.getByText('Slack Webhook URL')).toBeInTheDocument();
+ });
+
+ it('should show Email fields', () => {
+ goToStep2('Email');
+ expect(screen.getByText('Username / Email')).toBeInTheDocument();
+ expect(screen.getByText('SMTP Password')).toBeInTheDocument();
+ expect(screen.getByText('SMTP Host')).toBeInTheDocument();
+ expect(screen.getByText('SMTP Port')).toBeInTheDocument();
+ });
+
+ it('should show Webhook fields', () => {
+ goToStep2('Webhook');
+ expect(screen.getByText('Webhook URL')).toBeInTheDocument();
+ });
+
+ it('should show Custom Apprise URL fields', () => {
+ goToStep2('Custom Apprise URL');
+ expect(screen.getByText('Apprise URL')).toBeInTheDocument();
+ });
+
+ it('should go back to step 1 when Back button is clicked', () => {
+ goToStep2('Telegram');
+ fireEvent.click(screen.getByText('Back to channel selection'));
+ expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument();
+ });
+
+ it('should disable Next button when required fields are empty', () => {
+ goToStep2('Telegram');
+ const nextButton = screen.getByText('Next').closest('button');
+ expect(nextButton).toBeDisabled();
+ });
+
+ it('should enable Next button when required fields are filled', () => {
+ goToStep2('Telegram');
+ const inputs = screen.getAllByRole('textbox');
+ fireEvent.change(inputs[0], { target: { value: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw' } });
+ fireEvent.change(inputs[1], { target: { value: '12345678' } });
+ const nextButton = screen.getByText('Next').closest('button');
+ expect(nextButton).not.toBeDisabled();
+ });
+
+ it('should call onCancel when Cancel is clicked on step 2', () => {
+ goToStep2('Telegram');
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(mockOnCancel).toHaveBeenCalled();
+ });
+ });
+
+ // ── Step 3: Preview + Preferences ──────────────────────────────────────
+
+ describe('Step 3 - Preview and Preferences', () => {
+ function goToStep3WithTelegram() {
+ render();
+ // Step 1: select Telegram
+ fireEvent.click(screen.getByText('Telegram'));
+ // Step 2: fill fields
+ const inputs = screen.getAllByRole('textbox');
+ fireEvent.change(inputs[0], { target: { value: 'mybot:AAHdqTcvCH1vGWJx' } });
+ fireEvent.change(inputs[1], { target: { value: '12345678' } });
+ // Go to step 3
+ fireEvent.click(screen.getByText('Next').closest('button')!);
+ }
+
+ it('should show Final Setup heading', () => {
+ goToStep3WithTelegram();
+ expect(screen.getByText('Final Setup')).toBeInTheDocument();
+ });
+
+ it('should show Channel Name input', () => {
+ goToStep3WithTelegram();
+ expect(screen.getByText('Channel Name')).toBeInTheDocument();
+ });
+
+ it('should show notification preference checkboxes', () => {
+ goToStep3WithTelegram();
+ expect(screen.getByText('Email processing errors occur')).toBeInTheDocument();
+ expect(screen.getByText('Emails are successfully forwarded')).toBeInTheDocument();
+ });
+
+ it('should have errors checkbox checked by default', () => {
+ goToStep3WithTelegram();
+ const checkboxes = screen.getAllByRole('checkbox');
+ // First checkbox is "notify on errors" (default: true)
+ expect(checkboxes[0]).toBeChecked();
+ // Second is "notify on success" (default: false)
+ expect(checkboxes[1]).not.toBeChecked();
+ });
+
+ it('should disable Save button when name is empty', () => {
+ goToStep3WithTelegram();
+ const saveButton = screen.getByText('Save Channel').closest('button');
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('should enable Save button when name is filled', () => {
+ goToStep3WithTelegram();
+ const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
+ fireEvent.change(nameInput, { target: { value: 'My Alert' } });
+ const saveButton = screen.getByText('Save Channel').closest('button');
+ expect(saveButton).not.toBeDisabled();
+ });
+
+ it('should call onComplete with correct data on Save', () => {
+ goToStep3WithTelegram();
+ const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
+ fireEvent.change(nameInput, { target: { value: 'My Telegram Alert' } });
+ fireEvent.click(screen.getByText('Save Channel').closest('button')!);
+
+ expect(mockOnComplete).toHaveBeenCalledWith({
+ name: 'My Telegram Alert',
+ channel: 'telegram',
+ apprise_url: 'tgram://mybot:AAHdqTcvCH1vGWJx/12345678/',
+ notify_on_errors: true,
+ notify_on_success: false,
+ });
+ });
+
+ it('should toggle notification preferences', () => {
+ goToStep3WithTelegram();
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Uncheck errors
+ fireEvent.click(checkboxes[0]);
+ // Check success
+ fireEvent.click(checkboxes[1]);
+
+ const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
+ fireEvent.change(nameInput, { target: { value: 'Test' } });
+ fireEvent.click(screen.getByText('Save Channel').closest('button')!);
+
+ expect(mockOnComplete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ notify_on_errors: false,
+ notify_on_success: true,
+ })
+ );
+ });
+
+ it('should toggle URL visibility', () => {
+ goToStep3WithTelegram();
+ // URL should be hidden by default (masked with dots)
+ const urlText = screen.getByText(/^•+$/);
+ expect(urlText).toBeInTheDocument();
+
+ // Click the show/hide button
+ const toggleButton = screen.getByLabelText('Show URL');
+ fireEvent.click(toggleButton);
+
+ // Now the URL should be visible
+ expect(screen.getByText(/^tgram:\/\//)).toBeInTheDocument();
+ });
+
+ it('should go back to step 2 when Back is clicked', () => {
+ goToStep3WithTelegram();
+ fireEvent.click(screen.getByText('Back to configuration'));
+ // Should be back on step 2 with Telegram fields
+ expect(screen.getByText('Bot Token')).toBeInTheDocument();
+ });
+ });
+
+ // ── buildAppriseUrl via component behavior ─────────────────────────────
+
+ describe('Apprise URL generation', () => {
+ function fillAndSubmit(channel: string, fields: Record) {
+ render();
+ fireEvent.click(screen.getByText(channel));
+
+ const inputs = screen.getAllByRole('textbox');
+ const passwordInputs = document.querySelectorAll('input[type="password"]');
+ const allInputs = [...Array.from(inputs), ...Array.from(passwordInputs)];
+
+ Object.values(fields).forEach((value, i) => {
+ fireEvent.change(allInputs[i], { target: { value } });
+ });
+
+ fireEvent.click(screen.getByText('Next').closest('button')!);
+
+ const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
+ fireEvent.change(nameInput, { target: { value: 'Test' } });
+ fireEvent.click(screen.getByText('Save Channel').closest('button')!);
+
+ return mockOnComplete.mock.calls[0][0].apprise_url;
+ }
+
+ it('should build correct Telegram URL', () => {
+ const url = fillAndSubmit('Telegram', {
+ bot_token: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw',
+ chat_id: '12345678',
+ });
+ expect(url).toBe('tgram://110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/12345678/');
+ });
+
+ it('should build correct Discord URL from webhook', () => {
+ const url = fillAndSubmit('Discord', {
+ webhook_url: 'https://discord.com/api/webhooks/123456789/abcdefghij',
+ });
+ expect(url).toBe('discord://123456789/abcdefghij/');
+ });
+
+ it('should build correct Slack URL from webhook', () => {
+ const url = fillAndSubmit('Slack', {
+ webhook_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
+ });
+ expect(url).toBe('slack://T00000000/B00000000/XXXXXXXXXXXXXXXX/');
+ });
+
+ it('should build correct Webhook URL', () => {
+ const url = fillAndSubmit('Webhook', {
+ url: 'https://hooks.example.com/notify',
+ });
+ expect(url).toBe('https://hooks.example.com/notify');
+ });
+
+ it('should pass through Custom Apprise URL', () => {
+ const url = fillAndSubmit('Custom Apprise URL', {
+ apprise_url: 'tgram://mybot/mychat/',
+ });
+ expect(url).toBe('tgram://mybot/mychat/');
+ });
+ });
+
+ // ── Edit mode (initialData) ────────────────────────────────────────────
+
+ describe('Edit mode with initialData', () => {
+ const initialData = {
+ name: 'My Existing Alert',
+ channel: 'telegram',
+ apprise_url: 'tgram://existingbot/existingchat/',
+ notify_on_errors: true,
+ notify_on_success: true,
+ };
+
+ it('should start on step 3 when initialData is provided', () => {
+ render(
+
+ );
+ expect(screen.getByText('Final Setup')).toBeInTheDocument();
+ });
+
+ it('should pre-fill the name from initialData', () => {
+ render(
+
+ );
+ const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert') as HTMLInputElement;
+ expect(nameInput.value).toBe('My Existing Alert');
+ });
+
+ it('should pre-set notification preferences from initialData', () => {
+ render(
+
+ );
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes[0]).toBeChecked(); // notify_on_errors
+ expect(checkboxes[1]).toBeChecked(); // notify_on_success
+ });
+ });
+});
diff --git a/frontend/src/components/ProviderWizard.test.tsx b/frontend/src/components/ProviderWizard.test.tsx
new file mode 100644
index 0000000..1b129a6
--- /dev/null
+++ b/frontend/src/components/ProviderWizard.test.tsx
@@ -0,0 +1,231 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ProviderWizard } from './ProviderWizard';
+
+// Mock next/image
+jest.mock('next/image', () => {
+ return function MockImage({ alt, ...props }: { alt: string; [key: string]: unknown }) {
+ // eslint-disable-next-line @next/next/no-img-element
+ return
;
+ };
+});
+
+// Mock lucide-react icons
+jest.mock('lucide-react', () => ({
+ Mail: ({ className }: { className?: string }) => ,
+ ArrowLeft: ({ className }: { className?: string }) => ,
+}));
+
+const mockOnSelect = jest.fn();
+const mockOnManual = jest.fn();
+
+describe('ProviderWizard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // ── Provider List ──────────────────────────────────────────────────────
+
+ describe('Provider List', () => {
+ it('should render all email providers', () => {
+ render();
+ expect(screen.getByText('Gmail')).toBeInTheDocument();
+ expect(screen.getByText('GMX')).toBeInTheDocument();
+ expect(screen.getByText('WEB.DE')).toBeInTheDocument();
+ expect(screen.getByText('Outlook / Hotmail')).toBeInTheDocument();
+ expect(screen.getByText('Yahoo Mail')).toBeInTheDocument();
+ expect(screen.getByText('AOL Mail')).toBeInTheDocument();
+ expect(screen.getByText('T-Online')).toBeInTheDocument();
+ expect(screen.getByText('1&1 / IONOS')).toBeInTheDocument();
+ expect(screen.getByText('Freenet')).toBeInTheDocument();
+ expect(screen.getByText('iCloud Mail')).toBeInTheDocument();
+ expect(screen.getByText('Posteo')).toBeInTheDocument();
+ expect(screen.getByText('Proton Mail')).toBeInTheDocument();
+ });
+
+ it('should show the quick setup heading', () => {
+ render();
+ expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument();
+ });
+
+ it('should show Configure Manually button', () => {
+ render();
+ expect(screen.getByText('Configure Manually')).toBeInTheDocument();
+ });
+
+ it('should call onManual when Configure Manually is clicked', () => {
+ render();
+ fireEvent.click(screen.getByText('Configure Manually'));
+ expect(mockOnManual).toHaveBeenCalled();
+ });
+ });
+
+ // ── Provider Detail ────────────────────────────────────────────────────
+
+ describe('Provider Detail (after selecting a provider)', () => {
+ it('should show provider details when Gmail is selected', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ expect(screen.getByText(/gmail\.com, googlemail\.com/)).toBeInTheDocument();
+ expect(screen.getByText(/Enable IMAP\/POP3 in Gmail settings/)).toBeInTheDocument();
+ });
+
+ it('should show protocol selection buttons', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
+ expect(screen.getByText('POP3')).toBeInTheDocument();
+ });
+
+ it('should show IMAP server details for Gmail', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ expect(screen.getByText('imap.gmail.com:993')).toBeInTheDocument();
+ expect(screen.getByText('pop.gmail.com:995')).toBeInTheDocument();
+ });
+
+ it('should show Back to providers button', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ expect(screen.getByText('Back to providers')).toBeInTheDocument();
+ });
+
+ it('should go back to provider list when Back is clicked', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ fireEvent.click(screen.getByText('Back to providers'));
+ expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument();
+ });
+
+ it('should show Use button with provider name', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ expect(screen.getByText('Use Gmail Settings')).toBeInTheDocument();
+ });
+ });
+
+ // ── Provider Selection Callback ────────────────────────────────────────
+
+ describe('onSelect callback', () => {
+ it('should call onSelect with IMAP config when IMAP is chosen for Gmail', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ // IMAP is selected by default
+ fireEvent.click(screen.getByText('Use Gmail Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'Gmail',
+ provider_name: 'Gmail',
+ protocol: 'imap_ssl',
+ host: 'imap.gmail.com',
+ port: 993,
+ use_ssl: true,
+ });
+ });
+
+ it('should call onSelect with POP3 config when POP3 is chosen for Gmail', () => {
+ render();
+ fireEvent.click(screen.getByText('Gmail'));
+ fireEvent.click(screen.getByText('POP3'));
+ fireEvent.click(screen.getByText('Use Gmail Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'Gmail',
+ provider_name: 'Gmail',
+ protocol: 'pop3_ssl',
+ host: 'pop.gmail.com',
+ port: 995,
+ use_ssl: true,
+ });
+ });
+
+ it('should call onSelect with correct config for Outlook', () => {
+ render();
+ fireEvent.click(screen.getByText('Outlook / Hotmail'));
+ fireEvent.click(screen.getByText('Use Outlook / Hotmail Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'Outlook / Hotmail',
+ provider_name: 'Outlook / Hotmail',
+ protocol: 'imap_ssl',
+ host: 'outlook.office365.com',
+ port: 993,
+ use_ssl: true,
+ });
+ });
+
+ it('should call onSelect with correct config for GMX', () => {
+ render();
+ fireEvent.click(screen.getByText('GMX'));
+ fireEvent.click(screen.getByText('Use GMX Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'GMX',
+ provider_name: 'GMX',
+ protocol: 'imap_ssl',
+ host: 'imap.gmx.net',
+ port: 993,
+ use_ssl: true,
+ });
+ });
+ });
+
+ // ── IMAP-only providers ────────────────────────────────────────────────
+
+ describe('IMAP-only providers', () => {
+ it('should only show IMAP option for iCloud', () => {
+ render();
+ fireEvent.click(screen.getByText('iCloud Mail'));
+ expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
+ expect(screen.queryByText('POP3')).not.toBeInTheDocument();
+ });
+
+ it('should only show IMAP option for Posteo', () => {
+ render();
+ fireEvent.click(screen.getByText('Posteo'));
+ expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
+ expect(screen.queryByText('POP3')).not.toBeInTheDocument();
+ });
+
+ it('should auto-select IMAP for iCloud and call onSelect correctly', () => {
+ render();
+ fireEvent.click(screen.getByText('iCloud Mail'));
+ fireEvent.click(screen.getByText('Use iCloud Mail Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'iCloud Mail',
+ provider_name: 'iCloud Mail',
+ protocol: 'imap_ssl',
+ host: 'imap.mail.me.com',
+ port: 993,
+ use_ssl: true,
+ });
+ });
+ });
+
+ // ── Proton Mail (Bridge) ───────────────────────────────────────────────
+
+ describe('Proton Mail (Bridge)', () => {
+ it('should show Proton Mail Bridge notes', () => {
+ render();
+ fireEvent.click(screen.getByText('Proton Mail'));
+ expect(screen.getByText(/Requires Proton Mail Bridge/)).toBeInTheDocument();
+ });
+
+ it('should show localhost connection details', () => {
+ render();
+ fireEvent.click(screen.getByText('Proton Mail'));
+ expect(screen.getByText('127.0.0.1:1143')).toBeInTheDocument();
+ expect(screen.getByText('127.0.0.1:1144')).toBeInTheDocument();
+ });
+
+ it('should call onSelect with localhost config', () => {
+ render();
+ fireEvent.click(screen.getByText('Proton Mail'));
+ fireEvent.click(screen.getByText('Use Proton Mail Settings'));
+ expect(mockOnSelect).toHaveBeenCalledWith({
+ name: 'Proton Mail',
+ provider_name: 'Proton Mail',
+ protocol: 'imap_ssl',
+ host: '127.0.0.1',
+ port: 1143,
+ use_ssl: true,
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/QueryProvider.test.tsx b/frontend/src/components/QueryProvider.test.tsx
new file mode 100644
index 0000000..a492cec
--- /dev/null
+++ b/frontend/src/components/QueryProvider.test.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { QueryProvider } from './QueryProvider';
+
+describe('QueryProvider', () => {
+ it('should render children', () => {
+ render(
+
+ Hello
+
+ );
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ });
+
+ it('should render multiple children', () => {
+ render(
+
+ First
+ Second
+
+ );
+ expect(screen.getByTestId('first')).toBeInTheDocument();
+ expect(screen.getByTestId('second')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..5c51f3e
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,128 @@
+/**
+ * Tests for the Axios API instance configuration — interceptors and
+ * default headers. We use jest.mock to stub axios.create so we can
+ * inspect the interceptor callbacks that api.ts registers.
+ */
+
+// Capture interceptor callbacks registered by api.ts
+type InterceptorFn = (config: Record) => unknown;
+type ErrorFn = (error: unknown) => unknown;
+
+let requestInterceptor: InterceptorFn | null = null;
+let responseSuccessInterceptor: InterceptorFn | null = null;
+let responseErrorInterceptor: ErrorFn | null = null;
+
+const mockCreate = jest.fn();
+
+const mockAxiosInstance = {
+ interceptors: {
+ request: {
+ use: jest.fn((fn: InterceptorFn) => {
+ requestInterceptor = fn;
+ }),
+ },
+ response: {
+ use: jest.fn((successFn: InterceptorFn, errorFn: ErrorFn) => {
+ responseSuccessInterceptor = successFn;
+ responseErrorInterceptor = errorFn;
+ }),
+ },
+ },
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+};
+
+mockCreate.mockReturnValue(mockAxiosInstance);
+
+jest.mock('axios', () => ({
+ __esModule: true,
+ default: {
+ create: mockCreate,
+ },
+}));
+
+// Force module initialization to capture interceptors
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+require('./api');
+
+describe('API module setup', () => {
+ it('should create an axios instance with correct baseURL', () => {
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baseURL: '/api/v1',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ }),
+ })
+ );
+ });
+
+ it('should register request and response interceptors', () => {
+ expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
+ expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
+ });
+});
+
+describe('Request interceptor', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('should attach Authorization header when access_token exists', () => {
+ localStorage.setItem('access_token', 'test-jwt-token');
+ const config = { headers: {} as Record };
+ const result = requestInterceptor!(config) as typeof config;
+ expect(result.headers.Authorization).toBe('Bearer test-jwt-token');
+ });
+
+ it('should not attach Authorization header when no token exists', () => {
+ const config = { headers: {} as Record };
+ const result = requestInterceptor!(config) as typeof config;
+ expect(result.headers.Authorization).toBeUndefined();
+ });
+});
+
+describe('Response interceptor', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('should pass through successful responses', () => {
+ const response = { data: { ok: true }, status: 200 };
+ const result = responseSuccessInterceptor!(response);
+ expect(result).toBe(response);
+ });
+
+ it('should clear auth state on 401', async () => {
+ localStorage.setItem('access_token', 'expired-token');
+ localStorage.setItem('user', '{"id":1}');
+
+ const error = { response: { status: 401 } };
+ await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
+
+ expect(localStorage.getItem('access_token')).toBeNull();
+ expect(localStorage.getItem('user')).toBeNull();
+ });
+
+ it('should not clear auth state for non-401 errors', async () => {
+ localStorage.setItem('access_token', 'valid-token');
+
+ const error = { response: { status: 500 } };
+ await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
+
+ expect(localStorage.getItem('access_token')).toBe('valid-token');
+ });
+
+ it('should reject with the error for non-401 errors', async () => {
+ const error = { response: { status: 403 } };
+ await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
+ });
+
+ it('should handle errors without a response object', async () => {
+ const error = new Error('Network error');
+ await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
+ });
+});
diff --git a/frontend/src/lib/date-utils.test.ts b/frontend/src/lib/date-utils.test.ts
new file mode 100644
index 0000000..4300268
--- /dev/null
+++ b/frontend/src/lib/date-utils.test.ts
@@ -0,0 +1,154 @@
+import { parseUTC, formatRelative, formatDate, formatDuration } from './date-utils';
+
+describe('parseUTC', () => {
+ it('should parse ISO string with Z suffix as UTC', () => {
+ const date = parseUTC('2024-01-15T10:30:00Z');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should parse ISO string with positive timezone offset', () => {
+ const date = parseUTC('2024-01-15T12:30:00+02:00');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should parse ISO string with negative timezone offset', () => {
+ const date = parseUTC('2024-01-15T05:30:00-05:00');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should append Z to timezone-naive ISO string', () => {
+ const date = parseUTC('2024-01-15T10:30:00');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
+ });
+
+ it('should handle ISO string with milliseconds and Z', () => {
+ const date = parseUTC('2024-01-15T10:30:00.123Z');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.123Z');
+ });
+
+ it('should handle ISO string with compact offset (no colon)', () => {
+ const date = parseUTC('2024-01-15T12:30:00+0200');
+ expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
+ });
+});
+
+describe('formatRelative', () => {
+ it('should return "Never" for undefined input', () => {
+ expect(formatRelative(undefined)).toBe('Never');
+ });
+
+ it('should return "Never" for null input', () => {
+ expect(formatRelative(null)).toBe('Never');
+ });
+
+ it('should return "Never" for empty string', () => {
+ expect(formatRelative('')).toBe('Never');
+ });
+
+ it('should return "Just now" for timestamps less than 1 minute ago', () => {
+ const now = new Date().toISOString();
+ expect(formatRelative(now)).toBe('Just now');
+ });
+
+ it('should return minutes ago for timestamps less than 1 hour ago', () => {
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
+ expect(formatRelative(fiveMinutesAgo)).toBe('5m ago');
+ });
+
+ it('should return hours ago for timestamps less than 1 day ago', () => {
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
+ expect(formatRelative(threeHoursAgo)).toBe('3h ago');
+ });
+
+ it('should return days ago for timestamps 1+ days ago', () => {
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
+ expect(formatRelative(twoDaysAgo)).toBe('2d ago');
+ });
+
+ it('should return "1m ago" for exactly 1 minute ago', () => {
+ const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
+ expect(formatRelative(oneMinuteAgo)).toBe('1m ago');
+ });
+
+ it('should return "59m ago" for 59 minutes ago', () => {
+ const fiftyNineMinutesAgo = new Date(Date.now() - 59 * 60 * 1000).toISOString();
+ expect(formatRelative(fiftyNineMinutesAgo)).toBe('59m ago');
+ });
+
+ it('should return "1h ago" for exactly 60 minutes ago', () => {
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+ expect(formatRelative(oneHourAgo)).toBe('1h ago');
+ });
+
+ it('should return "23h ago" for 23 hours ago', () => {
+ const twentyThreeHoursAgo = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
+ expect(formatRelative(twentyThreeHoursAgo)).toBe('23h ago');
+ });
+
+ it('should return "1d ago" for exactly 24 hours ago', () => {
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
+ expect(formatRelative(oneDayAgo)).toBe('1d ago');
+ });
+
+ it('should handle timezone-naive timestamps correctly', () => {
+ // Create a timestamp without Z suffix
+ const now = new Date();
+ const naive = now.toISOString().replace('Z', '');
+ // parseUTC will append Z, so it should be interpreted as UTC
+ const result = formatRelative(naive);
+ expect(result).toBe('Just now');
+ });
+});
+
+describe('formatDate', () => {
+ it('should return a locale-formatted date string', () => {
+ const result = formatDate('2024-01-15T10:30:00Z');
+ // The exact format depends on locale, but it should contain key parts
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should handle timezone-naive ISO strings', () => {
+ const result = formatDate('2024-01-15T10:30:00');
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+});
+
+describe('formatDuration', () => {
+ it('should return em dash for null', () => {
+ expect(formatDuration(null)).toBe('—');
+ });
+
+ it('should return em dash for undefined', () => {
+ expect(formatDuration(undefined)).toBe('—');
+ });
+
+ it('should format 0 seconds', () => {
+ expect(formatDuration(0)).toBe('0.0s');
+ });
+
+ it('should format sub-minute durations with one decimal', () => {
+ expect(formatDuration(3.14)).toBe('3.1s');
+ });
+
+ it('should format exactly 59.9 seconds', () => {
+ expect(formatDuration(59.9)).toBe('59.9s');
+ });
+
+ it('should format exactly 60 seconds as minutes', () => {
+ expect(formatDuration(60)).toBe('1m 0s');
+ });
+
+ it('should format 125 seconds as 2m 5s', () => {
+ expect(formatDuration(125)).toBe('2m 5s');
+ });
+
+ it('should format large durations', () => {
+ expect(formatDuration(3661)).toBe('61m 1s');
+ });
+
+ it('should format fractional seconds above 60', () => {
+ expect(formatDuration(65.7)).toBe('1m 5s');
+ });
+});
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/frontend/src/test-setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/pyproject.toml b/pyproject.toml
index f1bc858..3c51c90 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "inboxconverge"
-version = "0.5.1"
+version = "0.6.0"
description = "Multi-account email forwarding and processing service"
readme = "README.md"
requires-python = ">=3.12"