mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-15 12:42:21 +02:00
260 lines
10 KiB
Python
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}
|