Files
odysseus/tests/test_email_open_dedup_js.py
T

260 lines
10 KiB
Python

"""Focused browser-side regression coverage for authoritative email opens."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
_HAS_NODE = shutil.which("node") is not None
def _extract_between(source: str, signature: str, next_marker: str) -> str:
start = source.index(signature)
end = source.index(next_marker, start)
return source[start:end].rstrip()
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
assert function.count("/api/email/read/") == 1
assert "/api/email/mark-read/" not in function
assert "&mark_seen=true" in function
assert "_syncEmailReadState(uidAtStart, true, readContext)" in function
assert "_syncEmailReadState(uidAtStart, false, readContext)" in function
assert "openGeneration === _emailCardOpenSeq" in function
assert "_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation" in function
assert "authoritativeReadSucceeded = true;" in function
assert "if (!authoritativeReadSucceeded) restoreUnreadState();" in function
assert "if (!isCurrentOpen()) return" in function
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
settlements = _extract_between(
function,
" const restoreUnreadState = () => {",
"\n\n // Collapse any other expanded card",
)
harness = f"""
const _emailReadMutations = new Map();
const readContextKey = 'same-mailbox-message';
const uidAtStart = '1';
const readContext = {{ accountId: 'acct-a', folder: 'INBOX', uid: '1' }};
const readUpdates = [];
function _syncEmailReadState(uid, isRead, context) {{
readUpdates.push({{ uid, isRead, context }});
}}
function createSettlers(readMutation) {{
{settlements}
return {{ restoreUnreadState, commitReadState }};
}}
function runRace(successFirst) {{
_emailReadMutations.clear();
readUpdates.length = 0;
const mutationA = {{ generation: 1, rollbackUnread: true }};
_emailReadMutations.set(readContextKey, mutationA);
const settlersA = createSettlers(mutationA);
const mutationB = {{ generation: 2, rollbackUnread: true }};
_emailReadMutations.set(readContextKey, mutationB);
const settlersB = createSettlers(mutationB);
if (successFirst) {{
settlersA.commitReadState();
settlersB.restoreUnreadState();
}} else {{
settlersB.restoreUnreadState();
settlersA.commitReadState();
}}
return {{
hasMutation: _emailReadMutations.has(readContextKey),
readUpdates: readUpdates.map(update => update.isRead),
}};
}}
console.log(JSON.stringify({{
successFirst: runRace(true),
failureFirst: runRace(false),
}}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
assert json.loads(proc.stdout.strip()) == {
"successFirst": {"hasMutation": False, "readUpdates": [True]},
"failureFirst": {"hasMutation": False, "readUpdates": [False, True]},
}
def test_library_reply_open_carries_immutable_mailbox_context():
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
assert "messageFolder = String(options.email?.folder || libraryFolder)" in library_source
assert "return onEmailClick({ ...options, mailboxContext });" in library_source
assert "mailboxContext?.messageFolder || _currentFolder" in inbox_source
assert "mailboxContextIsCurrent()" in inbox_source
reply_branch = inbox_source[
inbox_source.index("const reuseExisting = mode !== 'forward'"):
inbox_source.index("const createReplyDoc =", inbox_source.index("const reuseExisting = mode !== 'forward'"))
]
assert reply_branch.index("if (!isCurrentOpen()) return;") < reply_branch.index("_createEmailChat(data")
assert "let activeSid = sessionModule?.getCurrentSessionId?.() || '';" in reply_branch
assert "_createEmailChat(data);" in reply_branch
assert "forceNew: !aiSuggestedBody" not in reply_branch
def test_attachment_warning_only_checks_authored_reply_text():
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
assert "_emailReplyOwnText(text)" in helper
assert "text.split(/^>|^On .* wrote:/m)" not in helper
def test_email_send_saves_recovery_draft_before_send_and_closes_only_on_success():
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
assert "async function _saveEmailDraftForRecovery" in source
assert send.index("_saveEmailDraftForRecovery({") < send.index("fetch(`${API_BASE}/api/email/send`")
success_branch = send[send.index("if (data.success) {"):]
assert "closeLibrary()" in success_branch
assert send.index("closeLibrary()") > send.index("const res = await sendRequest")
assert "Draft kept in Drafts." in send
assert "recoveryDraft.draft_uid" in send
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_inbox_late_read_response_cannot_apply_after_newer_open():
source = _INBOX_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
assert "let _openEmailRequestSeq = 0;" in source
harness = f"""
const realLog = console.log;
console.error = () => {{}};
const API_BASE = 'https://odysseus.invalid';
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
let _currentFolder = 'INBOX';
const _acct = () => '&account_id=acct-a';
let _openEmailRequestSeq = 0;
let _docModule = null;
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
const sessionModule = null;
let firstResolve;
const calls = [];
async function fetch(url) {{
calls.push(String(url));
if (calls.length === 1) {{
return await new Promise((resolve) => {{
firstResolve = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
}});
}}
return {{ json: async () => ({{ error: 'newer open completed test' }}) }};
}}
{function}
const oldEmail = {{ uid: '1', is_read: false }};
const newerEmail = {{ uid: '2', is_read: false }};
const first = _openEmail(oldEmail, null);
await Promise.resolve();
const second = _openEmail(newerEmail, null);
await second;
firstResolve();
await first;
realLog(JSON.stringify({{ calls, oldRead: oldEmail.is_read, newerRead: newerEmail.is_read }}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
result = json.loads(proc.stdout.strip())
assert len(result["calls"]) == 2
assert all("mark_seen=true" in url for url in result["calls"])
assert result["oldRead"] is False
assert result["newerRead"] is False
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@pytest.mark.parametrize("context_change", ["account", "folder", "library"])
def test_inbox_late_read_response_cannot_apply_after_mailbox_switch(context_change):
source = _INBOX_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
changes = {
"account": "window.__odysseusActiveEmailAccount = 'acct-b';",
"folder": "_currentFolder = 'Archive';",
"library": "libraryCurrent = false;",
}
change = changes[context_change]
open_call = (
"_openEmail(email, null, null, 'reply', '', '', mailboxContext)"
if context_change == "library"
else "_openEmail(email, null)"
)
harness = f"""
const realLog = console.log;
console.error = () => {{}};
const API_BASE = 'https://odysseus.invalid';
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
let _currentFolder = 'INBOX';
const _acct = () => '&account_id=acct-a';
let _openEmailRequestSeq = 0;
let libraryCurrent = true;
const mailboxContext = {{
accountId: 'acct-a',
messageFolder: 'Archive',
isCurrent: () => libraryCurrent,
}};
let createCalls = 0;
let _docModule = {{}};
async function _createEmailChat() {{ createCalls += 1; return 'stale-session'; }}
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
const sessionModule = null;
let resolveRead;
async function fetch() {{
return await new Promise((resolve) => {{
resolveRead = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
}});
}}
{function}
const email = {{ uid: '1', is_read: false }};
const pending = {open_call};
await Promise.resolve();
{change}
resolveRead();
await pending;
realLog(JSON.stringify({{ createCalls, isRead: email.is_read }}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
result = json.loads(proc.stdout.strip())
assert result == {"createCalls": 0, "isRead": False}