Files
odysseus/tests/test_email_library_bulk_actions.py
T
Léo ab5a08a6f9 refactor(email): move emailLibrary.js into static/js/emailLibrary/
The email library was 11,375 lines in one file, the second-largest JS
module in the repo. `static/js/emailLibrary/` already held four extracted
helpers, so the package existed; the bulk of the code just was not in it.

The implementation moves to `emailLibrary/index.js` and the old path
becomes a re-export wrapper. Five call sites import that path, four of
them dynamically with a `?v=` string, and `sw.js` caches URLs verbatim,
so a wrapper is what makes the move need no coordinated edit to any of
them.

The test side is the part worth reviewing. 58 tests read
`static/js/emailLibrary.js` as text. Pointing them at
`emailLibrary/index.js` would buy one move and break again on the next
one, which is exactly what happened to the stylesheet tests (they now go
through `tests/helpers/stylesheets.py`). So the same shape:
`tests/helpers/js_modules.py` reads the whole package, and assertions
stop caring which module a function sits in.

`tests/test_email_library_module_graph_js.py` is new. This frontend has
no module-graph validation, and a package fails in ways a single file
cannot: a wrapper that drops an export is `undefined` at call time rather
than an error at load time, and a module that reads a `const` across an
import cycle throws only when that module is entered first. It pins the
wrapper's surface against the entry module's, evaluates every module on
its own in a browser, and requires both import paths to hand out one
instance.

It also caught a live gap while being written:
`emailLibrary/replyRecipients.js` is imported by `emailInbox.js`, an app
shell module, and was never in the `sw.js` precache.
2026-09-30 09:08:21 +02:00

183 lines
7.9 KiB
Python

from pathlib import Path
from tests.helpers.js_modules import email_library_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_ROUTES = _REPO / "routes" / "email_routes.py"
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
def _bulk_action_source() -> str:
text = email_library_source()
start = text.index("async function _bulkAction(action)")
end = text.index("\n}\n\n// _extractName", start) + 3
return text[start:end]
def _function_source(name: str) -> str:
text = email_library_source()
start = text.index(f"function {name}")
next_function = text.find("\nfunction ", start + 1)
next_async = text.find("\nasync function ", start + 1)
candidates = [idx for idx in (next_function, next_async) if idx != -1]
end = min(candidates) if candidates else len(text)
return text[start:end]
def test_email_bulk_read_unread_calls_provider_write_routes():
"""Bulk read/unread must persist to IMAP/provider, not only mutate UI state.
Regression for issue #800's email follow-up: list select -> Actions ->
Mark Read used to update `em.is_read` locally and cache that fake state,
then refresh from the provider made the message unread again.
"""
src = _bulk_action_source()
assert "Local toggle for now" not in src
assert "mark-read" in src
assert "mark-unread" in src
assert "method: 'POST'" in src
assert "_syncEmailReadState(uid, action === 'read')" in src
def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
src = _bulk_action_source()
assert "data?.success === false" in src
assert "throw new Error(data?.error" in src
assert "_libCacheWriteBack()" in src
def test_email_bulk_export_attachments_is_ui_only_selected_context():
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
]
payload_src = _function_source("_selectedEmailExportPayload")
assert "Export Attachments" in frontend
assert "All in view" in frontend
assert "/api/email/attachments-download-bulk" in export_src
assert "method: 'POST'" in export_src
assert "res.blob()" in export_src
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
assert "folder: String(em.folder || state._libFolder || 'INBOX')" in payload_src
assert '@router.post("/attachments-download-bulk")' in backend
assert "Download visible attachments from selected emails as one zip archive." in backend
assert "_fixture_email_read(uid, folder, owner)" in backend
assert "_is_likely_signature_image_attachment(att)" in backend
def test_fixture_tag_filter_supports_receipt_tag_flow():
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert 'row.get("tags") or row.get("category_tags") or []' in backend
assert 'elif str(filter_).startswith("tag:"):' in backend
assert 'rows = [e for e in rows if tag_name in (e.get("tags") or [])]' in backend
def test_email_context_changes_clear_bulk_selection_state():
"""IMAP UIDs are folder/account scoped, so stale bulk selections must die.
Folder, account, filter, quick-filter, attachment, and search basis changes
must exit select mode before the next list/search view can run bulk actions.
"""
text = email_library_source()
reset_src = _function_source("_resetBulkSelectionForContextChange")
fresh_src = _function_source("_resetEmailListForFreshLoad")
add_pill_src = _function_source("_addSearchPill")
remove_pill_src = _function_source("_removeSearchPillAt")
search_src = text[text.index("async function _doSearch()"):text.index("// Custom dropdown", text.index("async function _doSearch()"))]
assert "state._selectedUids.clear()" in reset_src
assert "state._selectMode = false" in reset_src
assert "_updateBulkBar()" in reset_src
assert "_resetBulkSelectionForContextChange()" in fresh_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in add_pill_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in remove_pill_src
assert "_resetBulkSelectionForContextChange({ rerender: true })" in search_src
assert "state._libFolder = _resolveEmailFolderAlias(e.target.value);" in text
assert "state._libFilter = e.target.value;" in text
assert "state._libHasAttachments = !state._libHasAttachments;" in text
assert "const nextAccountId = btn.dataset.accId || null;" in text
assert "state._libAccountId = nextAccountId;" in text
assert text.count("_loadEmailsFresh();") >= 5
assert "state._libSearchDraft = input.value;" in text
def test_email_refresh_uses_explicit_server_refresh_contract():
"""Manual refresh must bypass more than the browser/list response cache.
The server has a durable index and pooled IMAP handles for speed. The
refresh button should keep the old rows visible while asking the server to
evict those fast paths and refetch the visible mailbox slice.
"""
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "refresh=1&_=${Date.now()}" in frontend
assert "refresh: int = Query(0)" in backend
assert "manual_refresh = bool(refresh)" in backend
assert "_invalidate_list_cache(account_id, folder)" in backend
assert "_invalidate_imap_pool(account_id, owner)" in backend
assert "cached_rows = {} if refresh else _email_index_rows" in backend
def test_fixture_email_requires_explicit_eval_flag():
"""A leftover fixture file must not replace a user's real mailbox.
The deterministic fixture mailbox is useful for evals, but production
routes and MCP tools must not activate it merely because
data/fixture_email_messages.json exists.
"""
routes = _EMAIL_ROUTES.read_text(encoding="utf-8")
mcp = _EMAIL_MCP_SERVER.read_text(encoding="utf-8")
helper = _EMAIL_FIXTURE_HELPER.read_text(encoding="utf-8")
guard = 'os.environ.get("ODYSSEUS_EMAIL_FIXTURE") == "1" and _fixture_email_file().exists()'
assert guard in routes
assert guard in mcp
assert 'old_fixture_env = os.environ.get("ODYSSEUS_EMAIL_FIXTURE")' in helper
assert 'os.environ["ODYSSEUS_EMAIL_FIXTURE"] = "1"' in helper
assert 'os.environ.pop("ODYSSEUS_EMAIL_FIXTURE", None)' in helper
def test_email_client_cache_drops_fixture_rows():
"""Old fixture rows in browser storage must not keep rendering."""
frontend = email_library_source()
assert "function _looksLikeFixtureEmailRow(row)" in frontend
assert "function _libCacheHasFixtureRows(value)" in frontend
assert "function _libDropCacheKey(key)" in frontend
assert "older inbox message" in frontend
assert "fixture-email-" in frontend
assert "if (_libCacheHasFixtureRows(memory))" in frontend
assert "if (_libCacheHasFixtureRows(stored))" in frontend
assert "if (_libCacheHasFixtureRows(value))" in frontend
def test_email_compose_can_attach_gallery_images():
"""Compose attachments should support local files, documents, and Gallery images."""
frontend = _REPO / "static" / "js" / "document.js"
frontend_text = frontend.read_text(encoding="utf-8")
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "Upload from computer" in frontend_text
assert 'data-ody-attach-kind="document"' in frontend_text
assert "Gallery images" in frontend_text
assert 'data-ody-attach-kind="gallery"' in frontend_text
assert "api/gallery/library" in frontend_text
assert "compose-from-odysseus" in frontend_text
assert 'kind not in {"document", "gallery"}' in backend
assert 'if kind == "gallery":' in backend
assert "GalleryImage" in backend
assert "_gallery_image_path(img.filename)" in backend
assert "_stage_compose_file" in backend