diff --git a/app/repository/messages.py b/app/repository/messages.py index c2a052e..3d6ad9f 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -1,5 +1,7 @@ import json +import re import time +from dataclasses import dataclass from typing import Any from app.database import db @@ -12,6 +14,17 @@ from app.models import ( class MessageRepository: + @dataclass + class _SearchQuery: + free_text: str + user_terms: list[str] + channel_terms: list[str] + + _SEARCH_OPERATOR_RE = re.compile( + r'(? tuple[str, list[Any]]: lower_key = public_key.lower() @@ -185,6 +198,92 @@ class MessageRepository: else: return "AND conversation_key LIKE ?", f"{conversation_key}%" + @staticmethod + def _unescape_search_quoted_value(value: str) -> str: + return value.replace('\\"', '"').replace("\\\\", "\\") + + @staticmethod + def _parse_search_query(q: str) -> _SearchQuery: + user_terms: list[str] = [] + channel_terms: list[str] = [] + fragments: list[str] = [] + last_end = 0 + + for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q): + fragments.append(q[last_end : match.start()]) + raw_value = match.group(2) if match.group(2) is not None else match.group(3) or "" + value = MessageRepository._unescape_search_quoted_value(raw_value) + if match.group(1).lower() == "user": + user_terms.append(value) + else: + channel_terms.append(value) + last_end = match.end() + + if not user_terms and not channel_terms: + return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[]) + + fragments.append(q[last_end:]) + free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip()) + return MessageRepository._SearchQuery( + free_text=free_text, + user_terms=user_terms, + channel_terms=channel_terms, + ) + + @staticmethod + def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + @staticmethod + def _looks_like_hex_prefix(value: str) -> bool: + return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value) + + @staticmethod + def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]: + params: list[Any] = [value] + clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE" + + if MessageRepository._looks_like_hex_prefix(value): + if len(value) == 32: + clause += " OR UPPER(messages.conversation_key) = ?" + params.append(value.upper()) + else: + clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'" + params.append(f"{MessageRepository._escape_like(value.upper())}%") + + clause += "))" + return clause, params + + @staticmethod + def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]: + params: list[Any] = [value, value] + clause = ( + "((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)" + " OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)" + ) + + if MessageRepository._looks_like_hex_prefix(value): + lower_value = value.lower() + priv_key_clause: str + chan_key_clause: str + if len(value) == 64: + priv_key_clause = "LOWER(messages.conversation_key) = ?" + chan_key_clause = "LOWER(sender_key) = ?" + params.extend([lower_value, lower_value]) + else: + escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%" + priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'" + chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'" + params.extend([escaped_prefix, escaped_prefix]) + + clause += ( + f" OR (messages.type = 'PRIV' AND {priv_key_clause})" + f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})" + ) + + clause += ")" + return clause, params + @staticmethod def _row_to_message(row: Any) -> Message: """Convert a database row to a Message model.""" @@ -218,15 +317,24 @@ class MessageRepository: blocked_keys: list[str] | None = None, blocked_names: list[str] | None = None, ) -> list[Message]: - query = "SELECT * FROM messages WHERE 1=1" + search_query = MessageRepository._parse_search_query(q) if q else None + query = ( + "SELECT messages.* FROM messages " + "LEFT JOIN contacts ON messages.type = 'PRIV' " + "AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) " + "LEFT JOIN channels ON messages.type = 'CHAN' " + "AND UPPER(messages.conversation_key) = UPPER(channels.key) " + "WHERE 1=1" + ) params: list[Any] = [] if blocked_keys: placeholders = ",".join("?" for _ in blocked_keys) query += ( - f" AND NOT (outgoing=0 AND (" - f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))" - f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))" + f" AND NOT (messages.outgoing=0 AND (" + f"(messages.type='PRIV' AND LOWER(messages.conversation_key) IN ({placeholders}))" + f" OR (messages.type='CHAN' AND messages.sender_key IS NOT NULL" + f" AND LOWER(messages.sender_key) IN ({placeholders}))" f"))" ) params.extend(blocked_keys) @@ -235,36 +343,57 @@ class MessageRepository: if blocked_names: placeholders = ",".join("?" for _ in blocked_names) query += ( - f" AND NOT (outgoing=0 AND sender_name IS NOT NULL" - f" AND sender_name IN ({placeholders}))" + f" AND NOT (messages.outgoing=0 AND messages.sender_name IS NOT NULL" + f" AND messages.sender_name IN ({placeholders}))" ) params.extend(blocked_names) if msg_type: - query += " AND type = ?" + query += " AND messages.type = ?" params.append(msg_type) if conversation_key: clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key) - query += f" {clause}" + query += f" {clause.replace('conversation_key', 'messages.conversation_key')}" params.append(norm_key) - if q: - escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE" + if search_query and search_query.user_terms: + scope_clauses: list[str] = [] + for term in search_query.user_terms: + clause, clause_params = MessageRepository._build_user_scope_clause(term) + scope_clauses.append(clause) + params.extend(clause_params) + query += f" AND ({' OR '.join(scope_clauses)})" + + if search_query and search_query.channel_terms: + scope_clauses = [] + for term in search_query.channel_terms: + clause, clause_params = MessageRepository._build_channel_scope_clause(term) + scope_clauses.append(clause) + params.extend(clause_params) + query += f" AND ({' OR '.join(scope_clauses)})" + + if search_query and search_query.free_text: + escaped_q = MessageRepository._escape_like(search_query.free_text) + query += " AND messages.text LIKE ? ESCAPE '\\' COLLATE NOCASE" params.append(f"%{escaped_q}%") # Forward cursor (after/after_id) — mutually exclusive with before/before_id if after is not None and after_id is not None: - query += " AND (received_at > ? OR (received_at = ? AND id > ?))" + query += ( + " AND (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))" + ) params.extend([after, after, after_id]) - query += " ORDER BY received_at ASC, id ASC LIMIT ?" + query += " ORDER BY messages.received_at ASC, messages.id ASC LIMIT ?" params.append(limit) else: if before is not None and before_id is not None: - query += " AND (received_at < ? OR (received_at = ? AND id < ?))" + query += ( + " AND (messages.received_at < ?" + " OR (messages.received_at = ? AND messages.id < ?))" + ) params.extend([before, before, before_id]) - query += " ORDER BY received_at DESC, id DESC LIMIT ?" + query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?" params.append(limit) if before is None or before_id is None: query += " OFFSET ?" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index daf0901..b93e9f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,10 @@ import { messageContainsMention } from './utils/messageParser'; import type { Conversation, RawPacket } from './types'; export function App() { + const quoteSearchOperatorValue = useCallback((value: string) => { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + }, []); + const messageInputRef = useRef(null); const [rawPackets, setRawPackets] = useState([]); const { @@ -150,6 +154,7 @@ export function App() { infoPaneContactKey, infoPaneFromChannel, infoPaneChannelKey, + searchPrefillRequest, handleOpenContactInfo, handleCloseContactInfo, handleOpenChannelInfo, @@ -157,6 +162,7 @@ export function App() { handleSelectConversationWithTargetReset, handleNavigateToChannel, handleNavigateToMessage, + handleOpenSearchWithQuery, } = useConversationNavigation({ channels, handleSelectConversation, @@ -322,6 +328,7 @@ export function App() { contacts, channels, onNavigateToMessage: handleNavigateToMessage, + prefillRequest: searchPrefillRequest, }; const settingsProps = { config, @@ -361,6 +368,12 @@ export function App() { favorites, onToggleFavorite: handleToggleFavorite, onNavigateToChannel: handleNavigateToChannel, + onSearchMessagesByKey: (publicKey: string) => { + handleOpenSearchWithQuery(`user:${publicKey}`); + }, + onSearchMessagesByName: (name: string) => { + handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`); + }, onToggleBlockedKey: handleBlockKey, onToggleBlockedName: handleBlockName, blockedKeys: appSettings?.blocked_keys ?? [], diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index dd21a2f..dda63db 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useEffect, useState } from 'react'; -import { Ban, Star } from 'lucide-react'; +import { Ban, Search, Star } from 'lucide-react'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { @@ -51,6 +51,8 @@ interface ContactInfoPaneProps { favorites: Favorite[]; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onNavigateToChannel?: (channelKey: string) => void; + onSearchMessagesByKey?: (publicKey: string) => void; + onSearchMessagesByName?: (name: string) => void; blockedKeys?: string[]; blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; @@ -66,6 +68,8 @@ export function ContactInfoPane({ favorites, onToggleFavorite, onNavigateToChannel, + onSearchMessagesByKey, + onSearchMessagesByName, blockedKeys = [], blockedNames = [], onToggleBlockedKey, @@ -183,6 +187,19 @@ export function ContactInfoPane({ )} + {onSearchMessagesByName && ( +
+ +
+ )} + {fromChannel && ( )} + {onSearchMessagesByKey && ( +
+ +
+ )} + {/* Nearest Repeaters */} {analytics && analytics.nearest_repeaters.length > 0 && (
diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx index f28f46a..20e8538 100644 --- a/frontend/src/components/SearchView.tsx +++ b/frontend/src/components/SearchView.tsx @@ -19,6 +19,8 @@ interface SearchResult { sender_name: string | null; } +const SEARCH_OPERATOR_RE = /(? void; + prefillRequest?: { + query: string; + nonce: number; + } | null; } function highlightMatch(text: string, query: string): React.ReactNode[] { @@ -53,7 +59,34 @@ function highlightMatch(text: string, query: string): React.ReactNode[] { return parts; } -export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) { +function getHighlightQuery(query: string): string { + const fragments: string[] = []; + let lastIndex = 0; + let foundOperator = false; + + for (const match of query.matchAll(SEARCH_OPERATOR_RE)) { + foundOperator = true; + fragments.push(query.slice(lastIndex, match.index)); + lastIndex = (match.index ?? 0) + match[0].length; + } + + if (!foundOperator) { + return query; + } + + fragments.push(query.slice(lastIndex)); + return fragments + .map((fragment) => fragment.trim()) + .filter(Boolean) + .join(' '); +} + +export function SearchView({ + contacts, + channels, + onNavigateToMessage, + prefillRequest = null, +}: SearchViewProps) { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [results, setResults] = useState([]); @@ -62,6 +95,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi const [offset, setOffset] = useState(0); const abortRef = useRef(null); const inputRef = useRef(null); + const highlightQuery = getHighlightQuery(debouncedQuery); // Debounce query useEffect(() => { @@ -78,6 +112,17 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi setHasMore(false); }, [debouncedQuery]); + useEffect(() => { + if (!prefillRequest) { + return; + } + + const nextQuery = prefillRequest.query.trim(); + setQuery(nextQuery); + setDebouncedQuery(nextQuery); + inputRef.current?.focus(); + }, [prefillRequest]); + // Fetch search results useEffect(() => { if (!debouncedQuery) { @@ -193,7 +238,11 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
{!debouncedQuery && (
- Type to search across all messages +

Type to search across all messages

+

+ Tip: use user: or channel: for keys or names, and wrap names + with spaces in them in quotes. +

)} @@ -246,7 +295,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi result.sender_name && result.text.startsWith(`${result.sender_name}: `) ? result.text.slice(result.sender_name.length + 2) : result.text, - debouncedQuery + highlightQuery )}
diff --git a/frontend/src/hooks/useConversationNavigation.ts b/frontend/src/hooks/useConversationNavigation.ts index 9a19aa6..e388a89 100644 --- a/frontend/src/hooks/useConversationNavigation.ts +++ b/frontend/src/hooks/useConversationNavigation.ts @@ -14,6 +14,7 @@ interface UseConversationNavigationResult { infoPaneContactKey: string | null; infoPaneFromChannel: boolean; infoPaneChannelKey: string | null; + searchPrefillRequest: { query: string; nonce: number } | null; handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; handleCloseContactInfo: () => void; handleOpenChannelInfo: (channelKey: string) => void; @@ -24,6 +25,7 @@ interface UseConversationNavigationResult { ) => void; handleNavigateToChannel: (channelKey: string) => void; handleNavigateToMessage: (target: SearchNavigateTarget) => void; + handleOpenSearchWithQuery: (query: string) => void; } export function useConversationNavigation({ @@ -34,6 +36,10 @@ export function useConversationNavigation({ const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + const [searchPrefillRequest, setSearchPrefillRequest] = useState<{ + query: string; + nonce: number; + } | null>(null); const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { setInfoPaneContactKey(publicKey); @@ -95,12 +101,30 @@ export function useConversationNavigation({ [handleSelectConversationWithTargetReset] ); + const handleOpenSearchWithQuery = useCallback( + (query: string) => { + setTargetMessageId(null); + setInfoPaneContactKey(null); + handleSelectConversationWithTargetReset({ + type: 'search', + id: 'search', + name: 'Message Search', + }); + setSearchPrefillRequest((prev) => ({ + query, + nonce: (prev?.nonce ?? 0) + 1, + })); + }, + [handleSelectConversationWithTargetReset] + ); + return { targetMessageId, setTargetMessageId, infoPaneContactKey, infoPaneFromChannel, infoPaneChannelKey, + searchPrefillRequest, handleOpenContactInfo, handleCloseContactInfo, handleOpenChannelInfo, @@ -108,5 +132,6 @@ export function useConversationNavigation({ handleSelectConversationWithTargetReset, handleNavigateToChannel, handleNavigateToMessage, + handleOpenSearchWithQuery, }; } diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx index 68182b4..b144dd5 100644 --- a/frontend/src/test/appSearchJump.test.tsx +++ b/frontend/src/test/appSearchJump.test.tsx @@ -132,6 +132,7 @@ vi.mock('../components/NewMessageModal', () => ({ vi.mock('../components/SearchView', () => ({ SearchView: ({ onNavigateToMessage, + prefillRequest, }: { onNavigateToMessage: (target: { id: number; @@ -139,20 +140,24 @@ vi.mock('../components/SearchView', () => ({ conversation_key: string; conversation_name: string; }) => void; + prefillRequest?: { query: string; nonce: number } | null; }) => ( - +
+
{prefillRequest?.query ?? ''}
+ +
), })); @@ -165,7 +170,15 @@ vi.mock('../components/RawPacketList', () => ({ })); vi.mock('../components/ContactInfoPane', () => ({ - ContactInfoPane: () => null, + ContactInfoPane: ({ + onSearchMessagesByKey, + }: { + onSearchMessagesByKey?: (publicKey: string) => void; + }) => ( + + ), })); vi.mock('../components/ChannelInfoPane', () => ({ @@ -258,4 +271,23 @@ describe('App search jump target handling', () => { expect(lastCall?.[1]).toBeNull(); }); }); + + it('opens search with a prefilled query from the contact pane', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Search Contact By Key')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Search Contact By Key')); + + await waitFor(() => { + expect(screen.getByTestId('search-prefill')).toHaveTextContent(`user:${'aa'.repeat(32)}`); + expect( + screen + .getAllByTestId('active-conversation') + .some((node) => node.textContent === 'search:search') + ).toBe(true); + }); + }); }); diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index 1d6789f..8fefb03 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -92,11 +92,15 @@ const baseProps = { config: null, favorites: [], onToggleFavorite: () => {}, + onSearchMessagesByKey: vi.fn(), + onSearchMessagesByName: vi.fn(), }; describe('ContactInfoPane', () => { beforeEach(() => { getContactAnalytics.mockReset(); + baseProps.onSearchMessagesByKey = vi.fn(); + baseProps.onSearchMessagesByName = vi.fn(); }); it('shows hop width when contact has a stored path hash mode', async () => { @@ -190,9 +194,23 @@ describe('ContactInfoPane', () => { screen.getByText(/Name-only analytics include channel messages only/i) ).toBeInTheDocument(); expect(screen.getByText(/same sender name/i)).toBeInTheDocument(); + expect(screen.getByText("Search user's messages by name")).toBeInTheDocument(); }); }); + it('fires the name search callback from the name-only pane', async () => { + getContactAnalytics.mockResolvedValue( + createAnalytics(null, { lookup_type: 'name', name: 'Mystery' }) + ); + + render(); + + const button = await screen.findByRole('button', { name: "Search user's messages by name" }); + button.click(); + + expect(baseProps.onSearchMessagesByName).toHaveBeenCalledWith('Mystery'); + }); + it('shows alias note in the channel attribution warning for keyed contacts', async () => { const contact = createContact(); getContactAnalytics.mockResolvedValue( @@ -214,6 +232,19 @@ describe('ContactInfoPane', () => { /may include messages previously attributed under names shown in Also Known As/i ) ).toBeInTheDocument(); + expect(screen.getByText("Search user's messages by key")).toBeInTheDocument(); }); }); + + it('fires the key search callback from the keyed pane', async () => { + const contact = createContact(); + getContactAnalytics.mockResolvedValue(createAnalytics(contact)); + + render(); + + const button = await screen.findByRole('button', { name: "Search user's messages by key" }); + button.click(); + + expect(baseProps.onSearchMessagesByKey).toHaveBeenCalledWith(contact.public_key); + }); }); diff --git a/frontend/src/test/searchView.test.tsx b/frontend/src/test/searchView.test.tsx index d270a03..e29e8bb 100644 --- a/frontend/src/test/searchView.test.tsx +++ b/frontend/src/test/searchView.test.tsx @@ -70,6 +70,7 @@ describe('SearchView', () => { mockGetMessages.mockResolvedValue([]); render(); expect(screen.getByText('Type to search across all messages')).toBeInTheDocument(); + expect(screen.getByText(/Tip: use/i)).toBeInTheDocument(); }); it('focuses input on mount', () => { @@ -246,4 +247,37 @@ describe('SearchView', () => { expect(screen.getByText('Bob')).toBeInTheDocument(); }); + + it('passes raw operator queries to the API and highlights only free text', async () => { + mockGetMessages.mockResolvedValue([createSearchResult({ text: 'hello world' })]); + + render(); + + await typeAndWaitForResults('user:Alice hello'); + + expect(mockGetMessages).toHaveBeenCalledWith( + expect.objectContaining({ q: 'user:Alice hello' }), + expect.any(AbortSignal) + ); + expect(screen.getByText('hello', { selector: 'mark' })).toBeInTheDocument(); + expect(screen.queryByText('user:Alice', { selector: 'mark' })).not.toBeInTheDocument(); + }); + + it('runs a prefilled search immediately', async () => { + mockGetMessages.mockResolvedValue([createSearchResult({ text: 'prefilled result' })]); + + render( + + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByLabelText('Search messages')).toHaveValue('user:"Alice Smith"'); + expect(mockGetMessages).toHaveBeenCalledWith( + expect.objectContaining({ q: 'user:"Alice Smith"' }), + expect.any(AbortSignal) + ); + }); }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index c94a0fe..2d001b7 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -1,6 +1,6 @@ import type { FullConfig } from '@playwright/test'; -const BASE_URL = 'http://localhost:8000'; +const BASE_URL = 'http://localhost:8001'; const MAX_RETRIES = 10; const RETRY_DELAY_MS = 2000; diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 20a922f..2e42c7c 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -3,7 +3,7 @@ * These bypass the UI to set up preconditions and verify backend state. */ -const BASE_URL = 'http://localhost:8000/api'; +const BASE_URL = 'http://localhost:8001/api'; async function fetchJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, { diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 2a6e0f6..88ed63c 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ reporter: [['list'], ['html', { open: 'never' }]], use: { - baseURL: 'http://localhost:8000', + baseURL: 'http://localhost:8001', trace: 'on-first-retry', screenshot: 'only-on-failure', }, @@ -45,10 +45,10 @@ export default defineConfig({ echo "[e2e] $(date +%T.%3N) frontend/dist exists — skipping build" fi echo "[e2e] $(date +%T.%3N) Launching uvicorn..." - uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 + uv run uvicorn app.main:app --host 127.0.0.1 --port 8001 '`, cwd: projectRoot, - port: 8000, + port: 8001, reuseExistingServer: false, timeout: 180_000, env: { diff --git a/tests/e2e/specs/sidebar-search.spec.ts b/tests/e2e/specs/sidebar-search.spec.ts index f87aabe..9e8f2bb 100644 --- a/tests/e2e/specs/sidebar-search.spec.ts +++ b/tests/e2e/specs/sidebar-search.spec.ts @@ -1,21 +1,29 @@ import { test, expect } from '@playwright/test'; -import { createChannel, deleteChannel, getChannels } from '../helpers/api'; +import { createChannel, createContact, deleteChannel, deleteContact } from '../helpers/api'; test.describe('Sidebar search/filter', () => { const suffix = Date.now().toString().slice(-6); const nameA = `#alpha${suffix}`; const nameB = `#bravo${suffix}`; + const contactName = `Search Contact ${suffix}`; + const contactKey = `feed${suffix.padStart(8, '0')}${'ab'.repeat(26)}`; let keyA = ''; let keyB = ''; test.beforeAll(async () => { const chA = await createChannel(nameA); const chB = await createChannel(nameB); + await createContact(contactKey, contactName); keyA = chA.key; keyB = chB.key; }); test.afterAll(async () => { + try { + await deleteContact(contactKey); + } catch { + // Best-effort cleanup + } for (const key of [keyA, keyB]) { try { await deleteChannel(key); @@ -25,27 +33,40 @@ test.describe('Sidebar search/filter', () => { } }); - test('search filters conversations by name', async ({ page }) => { + test('search filters channel and contact conversations by name and key prefix', async ({ + page, + }) => { await page.goto('/'); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - // Both channels should be visible + // Seeded conversations should be visible. await expect(page.getByText(nameA, { exact: true })).toBeVisible(); await expect(page.getByText(nameB, { exact: true })).toBeVisible(); + await expect(page.getByText(contactName, { exact: true })).toBeVisible(); - // Type partial name to filter const searchInput = page.getByLabel('Search conversations'); - await searchInput.fill(`alpha${suffix}`); - // Only nameA should be visible + // Channel name query should filter to the matching channel only. + await searchInput.fill(`alpha${suffix}`); await expect(page.getByText(nameA, { exact: true })).toBeVisible(); await expect(page.getByText(nameB, { exact: true })).not.toBeVisible(); + await expect(page.getByText(contactName, { exact: true })).not.toBeVisible(); - // Clear search + // Contact name query should filter to the matching contact. + await searchInput.fill(`contact ${suffix}`); + await expect(page.getByText(contactName, { exact: true })).toBeVisible(); + await expect(page.getByText(nameA, { exact: true })).not.toBeVisible(); + await expect(page.getByText(nameB, { exact: true })).not.toBeVisible(); + + // Contact key prefix query should also match that contact. + await searchInput.fill(contactKey.slice(0, 12)); + await expect(page.getByText(contactName, { exact: true })).toBeVisible(); + await expect(page.getByText(nameA, { exact: true })).not.toBeVisible(); + + // Clear search should restore the full conversation list. await page.getByTitle('Clear search').click(); - - // Both should return await expect(page.getByText(nameA, { exact: true })).toBeVisible(); await expect(page.getByText(nameB, { exact: true })).toBeVisible(); + await expect(page.getByText(contactName, { exact: true })).toBeVisible(); }); }); diff --git a/tests/test_messages_search.py b/tests/test_messages_search.py index 08d578d..e3420ee 100644 --- a/tests/test_messages_search.py +++ b/tests/test_messages_search.py @@ -3,7 +3,7 @@ import pytest from app.radio import radio_manager -from app.repository import MessageRepository +from app.repository import ChannelRepository, ContactRepository, MessageRepository CHAN_KEY = "ABC123DEF456ABC123DEF456ABC12345" DM_KEY = "aa" * 32 @@ -136,6 +136,181 @@ class TestMessageSearch: assert len(results) == 1 assert results[0].sender_name == "Alice" + @pytest.mark.asyncio + async def test_search_user_operator_matches_channel_sender_name(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="hello from alice", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + sender_name="Alice", + ) + await MessageRepository.create( + msg_type="CHAN", + text="hello from bob", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + sender_name="Bob", + ) + + results = await MessageRepository.get_all(q='user:"Alice"') + assert [message.text for message in results] == ["hello from alice"] + + @pytest.mark.asyncio + async def test_search_user_operator_matches_dm_contact_name(self, test_db): + await ContactRepository.upsert( + { + "public_key": DM_KEY, + "name": "Alice Smith", + "type": 1, + } + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello from dm", + conversation_key=DM_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello from other dm", + conversation_key=("bb" * 32), + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q='user:"Alice Smith"') + assert [message.text for message in results] == ["hello from dm"] + + @pytest.mark.asyncio + async def test_search_user_operator_matches_key_prefix(self, test_db): + await MessageRepository.create( + msg_type="PRIV", + text="dm by key prefix", + conversation_key=DM_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="chan by key prefix", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + sender_key=DM_KEY, + sender_name="Alice", + ) + await MessageRepository.create( + msg_type="PRIV", + text="other dm", + conversation_key=("bb" * 32), + sender_timestamp=102, + received_at=102, + ) + + results = await MessageRepository.get_all(q=f"user:{DM_KEY[:12]}") + assert [message.text for message in results] == ["chan by key prefix", "dm by key prefix"] + + @pytest.mark.asyncio + async def test_search_channel_operator_matches_channel_name(self, test_db): + await ChannelRepository.upsert(key=CHAN_KEY, name="#flightless", is_hashtag=True) + await ChannelRepository.upsert(key=OTHER_CHAN_KEY, name="#other", is_hashtag=True) + await MessageRepository.create( + msg_type="CHAN", + text="hello flightless", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="hello elsewhere", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q='channel:"#flightless"') + assert [message.text for message in results] == ["hello flightless"] + + @pytest.mark.asyncio + async def test_search_channel_operator_matches_quoted_name_with_spaces(self, test_db): + await ChannelRepository.upsert(key=CHAN_KEY, name="#Ops Room", is_hashtag=True) + await ChannelRepository.upsert(key=OTHER_CHAN_KEY, name="#Other Room", is_hashtag=True) + await MessageRepository.create( + msg_type="CHAN", + text="hello ops room", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="hello other room", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q='channel:"#Ops Room"') + assert [message.text for message in results] == ["hello ops room"] + + @pytest.mark.asyncio + async def test_search_channel_operator_matches_channel_key_prefix(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="chan by key", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="other channel", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q=f"channel:{CHAN_KEY[:8]}") + assert [message.text for message in results] == ["chan by key"] + + @pytest.mark.asyncio + async def test_search_scope_operators_and_free_text_are_combined(self, test_db): + await ChannelRepository.upsert(key=CHAN_KEY, name="#flightless", is_hashtag=True) + await MessageRepository.create( + msg_type="CHAN", + text="hello operator", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + sender_name="Alice", + ) + await MessageRepository.create( + msg_type="CHAN", + text="goodbye operator", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + sender_name="Alice", + ) + await MessageRepository.create( + msg_type="CHAN", + text="hello operator", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=102, + received_at=102, + sender_name="Bob", + ) + + results = await MessageRepository.get_all( + q='user:Alice channel:"#flightless" hello operator' + ) + assert [message.text for message in results] == ["hello operator"] + class TestMessagesAround: """Tests for get_around()."""