Compare commits

...

2 Commits

Author SHA1 Message Date
Bendt
848e2a43a6 Fix Himalaya search by quoting query and placing it at end of command
The search query was being inserted unquoted in the middle of the command,
but Himalaya CLI expects the query to be quoted and positioned at the end.
2025-12-19 14:43:01 -05:00
Bendt
bbc53b4ce7 Add Himalaya search integration tests and fix 0 results display
- Add test mailbox with 5 sample emails for integration testing
- Add himalaya_test_config.toml for local Maildir backend testing
- Create 12 integration tests covering search by from/to/subject/body
- Fix search results display to clear list and show message when 0 results
- Add clear_content() method to ContentContainer widget
2025-12-19 14:42:10 -05:00
10 changed files with 416 additions and 2 deletions

View File

@@ -1003,9 +1003,21 @@ class EmailViewerApp(App):
config = get_config()
# Add search results header
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
if results:
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
else:
header_label = f"Search: '{query}' - No results found"
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
if not results:
# Clear the message viewer when no results
content_container = self.query_one(ContentContainer)
content_container.clear_content()
self.message_store.envelopes = []
self.total_messages = 0
self.current_message_id = 0
return
# Create a temporary message store for search results
search_store = MessageStore()
search_store.load(results, self.sort_order_ascending)

View File

@@ -176,6 +176,14 @@ class ContentContainer(ScrollableContainer):
format_type = "text" if self.current_mode == "markdown" else "html"
self.content_worker = self.fetch_message_content(message_id, format_type)
def clear_content(self) -> None:
"""Clear the message content display."""
self.content.update("")
self.html_content.update("")
self.current_content = None
self.current_message_id = None
self.border_title = "No message selected"
def _update_content(self, content: str | None) -> None:
"""Update the content widgets with the fetched content."""
if content is None:

View File

@@ -339,11 +339,15 @@ async def search_envelopes(
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
search_query = f"from {query} or to {query} or subject {query} or body {query}"
cmd = f"himalaya envelope list -o json -s {limit} {search_query}"
# Build command with options before the query (query must be at the end, quoted)
cmd = "himalaya envelope list -o json"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
cmd += f" -s {limit}"
# Query must be quoted and at the end of the command
cmd += f' "{search_query}"'
process = await asyncio.create_subprocess_shell(
cmd,

View File

@@ -0,0 +1,24 @@
# Himalaya Test Configuration
#
# This configuration file sets up a local Maildir test account for integration testing.
# Copy this file to ~/.config/himalaya/config.toml or merge with existing config.
#
# Usage:
# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account
# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account from edson
#
# Or set the config path and use the test account:
# export HIMALAYA_CONFIG=tests/fixtures/himalaya_test_config.toml
# himalaya envelope list -a test-account
[accounts.test-account]
default = true
email = "test@example.com"
display-name = "Test User"
# Maildir backend configuration
backend.type = "maildir"
backend.root-dir = "tests/fixtures/test_mailbox"
# Message configuration
message.send.backend.type = "none"

View File

@@ -0,0 +1,22 @@
From: Edson Martinez <edson.martinez@example.com>
To: Test User <test@example.com>
Subject: DevOps weekly report
Date: Fri, 14 Dec 2025 16:00:00 -0600
Message-ID: <msg005@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hi Team,
Here's the weekly DevOps report:
1. Server uptime: 99.9%
2. Deployments this week: 12
3. Incidents resolved: 3
4. Pending tasks: 5
The CI/CD pipeline improvements are on track for next week.
Best,
Edson Martinez
DevOps Lead

View File

@@ -0,0 +1,17 @@
From: Carol Davis <carol.davis@example.com>
To: Test User <test@example.com>
Subject: Re: Budget spreadsheet
Date: Thu, 15 Dec 2025 11:20:00 -0600
Message-ID: <msg004@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hi,
Thanks for sending over the budget spreadsheet. I've reviewed it and everything looks good.
One small note: the Q3 numbers need to be updated with the final figures from accounting.
Let me know once that's done.
Carol

View File

@@ -0,0 +1,19 @@
From: Bob Williams <bob.williams@example.com>
To: Test User <test@example.com>
Subject: Urgent: Server maintenance tonight
Date: Wed, 16 Dec 2025 18:45:00 -0600
Message-ID: <msg003@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
URGENT
The production server will be undergoing maintenance tonight from 11pm to 2am.
Please save all your work before 10:30pm.
Edson from the DevOps team will be handling the maintenance.
Contact the IT helpdesk if you have any concerns.
Bob Williams
IT Department

View File

@@ -0,0 +1,17 @@
From: Alice Johnson <alice.johnson@example.com>
To: Test User <test@example.com>
Subject: Project proposal review
Date: Tue, 17 Dec 2025 14:30:00 -0600
Message-ID: <msg002@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hello,
I've attached the project proposal for your review.
Please take a look and let me know if you have any questions.
The deadline for feedback is Friday.
Thanks,
Alice Johnson

View File

@@ -0,0 +1,17 @@
From: John Smith <john.smith@example.com>
To: Test User <test@example.com>
Subject: Meeting tomorrow at 10am
Date: Mon, 18 Dec 2025 09:00:00 -0600
Message-ID: <msg001@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hi Test User,
Just a reminder about our meeting tomorrow at 10am in the conference room.
We'll be discussing the Q4 budget review.
Please bring your laptop.
Best regards,
John Smith

View File

@@ -0,0 +1,274 @@
"""Integration tests for Himalaya client with test mailbox.
These tests use a local Maildir test mailbox to verify himalaya operations
without touching real email accounts.
Run with:
pytest tests/test_himalaya_integration.py -v
Requires:
- himalaya CLI installed
- tests/fixtures/test_mailbox with sample emails
- tests/fixtures/himalaya_test_config.toml
"""
import asyncio
import json
import os
import subprocess
from pathlib import Path
import pytest
# Path to the test config
TEST_CONFIG = Path(__file__).parent / "fixtures" / "himalaya_test_config.toml"
TEST_MAILBOX = Path(__file__).parent / "fixtures" / "test_mailbox"
def himalaya_available() -> bool:
"""Check if himalaya CLI is installed."""
try:
result = subprocess.run(
["himalaya", "--version"],
capture_output=True,
text=True,
)
return result.returncode == 0
except FileNotFoundError:
return False
# Skip all tests if himalaya is not installed
pytestmark = pytest.mark.skipif(
not himalaya_available(),
reason="himalaya CLI not installed",
)
@pytest.fixture
def himalaya_cmd():
"""Return the base himalaya command with test config."""
# Note: -a must come after the subcommand in himalaya
return f"himalaya -c {TEST_CONFIG}"
@pytest.fixture
def account_arg():
"""Return the account argument for himalaya."""
return "-a test-account"
class TestHimalayaListEnvelopes:
"""Tests for listing envelopes."""
def test_list_all_envelopes(self, himalaya_cmd, account_arg):
"""Test listing all envelopes from test mailbox."""
result = subprocess.run(
f"{himalaya_cmd} envelope list {account_arg} -o json",
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
assert len(envelopes) == 5, f"Expected 5 emails, got {len(envelopes)}"
def test_envelope_has_required_fields(self, himalaya_cmd, account_arg):
"""Test that envelopes have all required fields."""
result = subprocess.run(
f"{himalaya_cmd} envelope list {account_arg} -o json",
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0
envelopes = json.loads(result.stdout)
required_fields = ["id", "subject", "from", "to", "date"]
for envelope in envelopes:
for field in required_fields:
assert field in envelope, f"Missing field: {field}"
class TestHimalayaSearch:
"""Tests for search functionality."""
def test_search_by_from_name(self, himalaya_cmd, account_arg):
"""Test searching by sender name."""
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
assert len(envelopes) == 1
assert "Edson" in envelopes[0]["from"]["name"]
def test_search_by_body_content(self, himalaya_cmd, account_arg):
"""Test searching by body content."""
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "body edson"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
# Should find 2: one from Edson, one mentioning Edson in body
assert len(envelopes) == 2
def test_search_by_subject(self, himalaya_cmd, account_arg):
"""Test searching by subject."""
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "subject meeting"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
assert len(envelopes) == 1
assert "Meeting" in envelopes[0]["subject"]
def test_search_compound_or_query(self, himalaya_cmd, account_arg):
"""Test compound OR search query."""
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson or body edson"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
# Should find both emails mentioning Edson
assert len(envelopes) >= 2
def test_search_no_results(self, himalaya_cmd, account_arg):
"""Test search that returns no results."""
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "from nonexistent_person_xyz"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
assert len(envelopes) == 0
def test_search_full_compound_query(self, himalaya_cmd, account_arg):
"""Test the full compound query format used by our search function."""
query = "edson"
search_query = f"from {query} or to {query} or subject {query} or body {query}"
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "{search_query}"',
shell=True,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Error: {result.stderr}"
envelopes = json.loads(result.stdout)
# Should find emails where Edson is sender or mentioned in body
assert len(envelopes) >= 2
class TestHimalayaReadMessage:
"""Tests for reading message content."""
def test_read_message_by_id(self, himalaya_cmd, account_arg):
"""Test reading a message by ID."""
# First get the list to find an ID
list_result = subprocess.run(
f"{himalaya_cmd} envelope list {account_arg} -o json",
shell=True,
capture_output=True,
text=True,
)
assert list_result.returncode == 0
envelopes = json.loads(list_result.stdout)
message_id = envelopes[0]["id"]
# Read the message
read_result = subprocess.run(
f"{himalaya_cmd} message read {account_arg} {message_id}",
shell=True,
capture_output=True,
text=True,
)
assert read_result.returncode == 0, f"Error: {read_result.stderr}"
assert len(read_result.stdout) > 0
class TestHimalayaAsyncClient:
"""Tests for the async himalaya client module."""
@pytest.mark.asyncio
async def test_search_envelopes_async(self):
"""Test the async search_envelopes function."""
# Import here to avoid issues if himalaya module has import errors
import sys
# Add project root to path for proper imports
project_root = str(Path(__file__).parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from src.services.himalaya import client as himalaya_client
# Note: This test would need the CLI to use our test config
# For now, just verify the function exists and has correct signature
assert hasattr(himalaya_client, "search_envelopes")
assert asyncio.iscoroutinefunction(himalaya_client.search_envelopes)
# Additional test for edge cases
class TestSearchEdgeCases:
"""Edge case tests for search."""
def test_search_with_special_characters(self, himalaya_cmd, account_arg):
"""Test searching with special characters in query."""
# This should not crash, even if no results
result = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "subject Q4"',
shell=True,
capture_output=True,
text=True,
)
# May fail or succeed depending on himalaya version
# Just verify it doesn't crash catastrophically
assert result.returncode in [0, 1]
def test_search_case_insensitive(self, himalaya_cmd, account_arg):
"""Test that search is case insensitive."""
result_upper = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "from EDSON"',
shell=True,
capture_output=True,
text=True,
)
result_lower = subprocess.run(
f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"',
shell=True,
capture_output=True,
text=True,
)
# Both should succeed
assert result_upper.returncode == 0
assert result_lower.returncode == 0
# Results should be the same (case insensitive)
upper_envelopes = json.loads(result_upper.stdout)
lower_envelopes = json.loads(result_lower.stdout)
assert len(upper_envelopes) == len(lower_envelopes)