Merge pull request #422 from christianlouis/copilot/fix-search-field-bug
fix(ui): fix search input clearing on keystroke; add dedicated /search page
This commit is contained in:
@@ -12,6 +12,7 @@ from app.views.general import router as general_router
|
|||||||
from app.views.google_drive import router as google_drive_router
|
from app.views.google_drive import router as google_drive_router
|
||||||
from app.views.license_routes import router as license_router # Add the license router
|
from app.views.license_routes import router as license_router # Add the license router
|
||||||
from app.views.onedrive import router as onedrive_router
|
from app.views.onedrive import router as onedrive_router
|
||||||
|
from app.views.search import router as search_router
|
||||||
from app.views.settings import router as settings_router
|
from app.views.settings import router as settings_router
|
||||||
from app.views.status import router as status_router
|
from app.views.status import router as status_router
|
||||||
from app.views.wizard import router as wizard_router
|
from app.views.wizard import router as wizard_router
|
||||||
@@ -27,3 +28,4 @@ router.include_router(google_drive_router)
|
|||||||
router.include_router(license_router) # Include the license router
|
router.include_router(license_router) # Include the license router
|
||||||
router.include_router(settings_router)
|
router.include_router(settings_router)
|
||||||
router.include_router(filemanager_router)
|
router.include_router(filemanager_router)
|
||||||
|
router.include_router(search_router)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Search view for dedicated full-text document search page.
|
||||||
|
|
||||||
|
Provides a Google-style search experience with content previews,
|
||||||
|
powered by Meilisearch via the existing ``/api/search`` backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Query, Request
|
||||||
|
|
||||||
|
from app.views.base import APIRouter, require_login, templates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
@require_login
|
||||||
|
def search_page(
|
||||||
|
request: Request,
|
||||||
|
q: Optional[str] = Query(None, max_length=512, description="Full-text search query"),
|
||||||
|
):
|
||||||
|
"""Render the dedicated search page.
|
||||||
|
|
||||||
|
The page loads with an empty search box (or pre-filled when *q* is given).
|
||||||
|
Actual searching is performed client-side via ``fetch('/api/search')`` so
|
||||||
|
that results stream in without a full page reload.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"search.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"query": q or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
+25
-1
@@ -39,6 +39,7 @@ DocuElevate features a simple navigation system with the following main sections
|
|||||||
- **Home**: Dashboard and overview
|
- **Home**: Dashboard and overview
|
||||||
- **Upload**: For adding new documents to the system
|
- **Upload**: For adding new documents to the system
|
||||||
- **Files**: For viewing and managing processed documents
|
- **Files**: For viewing and managing processed documents
|
||||||
|
- **Search**: Dedicated full-text search across all document content
|
||||||
- **About**: Information about DocuElevate
|
- **About**: Information about DocuElevate
|
||||||
|
|
||||||
## Uploading Documents
|
## Uploading Documents
|
||||||
@@ -87,10 +88,33 @@ If configured, DocuElevate can automatically fetch documents from email attachme
|
|||||||
The **Files** page provides access to all processed documents:
|
The **Files** page provides access to all processed documents:
|
||||||
|
|
||||||
1. Navigate to the **Files** page
|
1. Navigate to the **Files** page
|
||||||
2. Use the search box to find specific documents
|
2. Use the search box to find specific documents by filename, or the full-text search bar to search document content, metadata, and tags
|
||||||
3. Click on any file to view its details
|
3. Click on any file to view its details
|
||||||
4. Sort the list by any column by clicking on the column header
|
4. Sort the list by any column by clicking on the column header
|
||||||
|
|
||||||
|
## Searching Documents
|
||||||
|
|
||||||
|
DocuElevate provides two ways to search your documents:
|
||||||
|
|
||||||
|
### Full-Text Search on the Files Page
|
||||||
|
|
||||||
|
The **Files** page includes a full-text search bar (labelled "Full-Text Search") that searches across OCR-extracted text, AI metadata, tags, sender, recipient, and document type. Type at least 2 characters and results appear automatically below the search bar.
|
||||||
|
|
||||||
|
### Dedicated Search Page
|
||||||
|
|
||||||
|
For a more focused search experience, use the **Search** page accessible from the main navigation:
|
||||||
|
|
||||||
|
1. Navigate to the **Search** page
|
||||||
|
2. Type your query into the search box — results appear automatically as you type (or press Enter)
|
||||||
|
3. Results are displayed in a Google-style format showing:
|
||||||
|
- **Document title** (linked to the file detail page)
|
||||||
|
- **Filename**
|
||||||
|
- **Document type**, **sender**, and **tag** badges
|
||||||
|
- **Content preview** with highlighted matching terms
|
||||||
|
4. Use pagination to browse through large result sets
|
||||||
|
|
||||||
|
The search page is also accessible via URL with a pre-filled query: `/search?q=invoice`
|
||||||
|
|
||||||
### File Detail View
|
### File Detail View
|
||||||
|
|
||||||
When you click on a file, you'll see a comprehensive detail view with the following sections:
|
When you click on a file, you'll see a comprehensive detail view with the following sections:
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
|
||||||
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
|
||||||
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
|
||||||
|
<a href="/search" class="text-gray-700 hover:text-gray-900">Search</a>
|
||||||
|
|
||||||
<!-- Admin dropdown (shown only for admin users via JS) -->
|
<!-- Admin dropdown (shown only for admin users via JS) -->
|
||||||
<div id="adminMenuContainer" class="relative hidden">
|
<div id="adminMenuContainer" class="relative hidden">
|
||||||
@@ -150,6 +151,7 @@
|
|||||||
<a href="/" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
|
<a href="/" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
|
||||||
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
|
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
|
||||||
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
|
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
|
||||||
|
<a href="/search" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Search</a>
|
||||||
|
|
||||||
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
|
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
|
||||||
<div id="mobileAdminSection" class="hidden">
|
<div id="mobileAdminSection" class="hidden">
|
||||||
|
|||||||
@@ -936,7 +936,10 @@
|
|||||||
function debounceSearch(value) {
|
function debounceSearch(value) {
|
||||||
clearTimeout(_searchDebounceTimer);
|
clearTimeout(_searchDebounceTimer);
|
||||||
if (!value || value.trim().length < 2) {
|
if (!value || value.trim().length < 2) {
|
||||||
clearFullTextSearch();
|
// Only hide the results panel; do NOT clear the input value so the
|
||||||
|
// user can continue typing characters until the query is long enough.
|
||||||
|
var _p = document.getElementById('search-results-panel');
|
||||||
|
if (_p) _p.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_searchDebounceTimer = setTimeout(() => {
|
_searchDebounceTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Search Documents - DocuElevate{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
<style>
|
||||||
|
.search-container { max-width: 800px; margin: 0 auto; }
|
||||||
|
.search-box {
|
||||||
|
display: flex; gap: 0.5rem; align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.search-box input {
|
||||||
|
flex: 1; padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db; border-radius: 0.5rem;
|
||||||
|
font-size: 1rem; outline: none;
|
||||||
|
}
|
||||||
|
.search-box input:focus {
|
||||||
|
border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
|
||||||
|
}
|
||||||
|
.search-box button {
|
||||||
|
padding: 0.75rem 1.5rem; background-color: #3b82f6;
|
||||||
|
color: white; border: none; border-radius: 0.5rem;
|
||||||
|
font-size: 1rem; cursor: pointer; font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-box button:hover { background-color: #2563eb; }
|
||||||
|
.search-summary {
|
||||||
|
font-size: 0.875rem; color: #6b7280;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
/* Google-style result cards */
|
||||||
|
.search-result {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.search-result-title a {
|
||||||
|
font-size: 1.125rem; font-weight: 600;
|
||||||
|
color: #1a0dab; text-decoration: none;
|
||||||
|
}
|
||||||
|
.search-result-title a:hover { text-decoration: underline; }
|
||||||
|
.search-result-url {
|
||||||
|
font-size: 0.8rem; color: #006621; margin-top: 0.1rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.search-result-meta {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.4rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.search-result-badge {
|
||||||
|
display: inline-block; padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px; font-size: 0.7rem; font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-type { background: #eff6ff; color: #1d4ed8; }
|
||||||
|
.badge-tag { background: #f0fdf4; color: #15803d; }
|
||||||
|
.badge-sender { background: #fef3c7; color: #92400e; }
|
||||||
|
.search-result-snippet {
|
||||||
|
font-size: 0.875rem; color: #4d5156;
|
||||||
|
line-height: 1.5; margin-top: 0.35rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.search-result-snippet mark {
|
||||||
|
background: #fef08a; font-weight: 600;
|
||||||
|
border-radius: 2px; padding: 0 1px;
|
||||||
|
}
|
||||||
|
.search-pagination {
|
||||||
|
display: flex; justify-content: center;
|
||||||
|
gap: 0.5rem; margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.search-pagination button {
|
||||||
|
padding: 0.4rem 1rem; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem; font-size: 0.875rem;
|
||||||
|
cursor: pointer; background: white; color: #374151;
|
||||||
|
}
|
||||||
|
.search-pagination button:hover { background: #f3f4f6; }
|
||||||
|
.search-pagination span {
|
||||||
|
padding: 0.4rem 0.75rem; font-size: 0.875rem; color: #6b7280;
|
||||||
|
}
|
||||||
|
.search-empty {
|
||||||
|
text-align: center; padding: 3rem 1rem; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.search-empty i { font-size: 3rem; margin-bottom: 1rem; display: block; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="search-container">
|
||||||
|
<h1 class="text-3xl font-bold mb-6"><i class="fas fa-search text-blue-500"></i> Document Search</h1>
|
||||||
|
|
||||||
|
<!-- Search box -->
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-input"
|
||||||
|
placeholder="Search documents by content, sender, tags, type..."
|
||||||
|
value="{{ query }}"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button type="button" id="search-btn">
|
||||||
|
<i class="fas fa-search"></i> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div id="search-summary" class="search-summary" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="search-results"></div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="search-pagination" class="search-pagination" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
const resultsDiv = document.getElementById('search-results');
|
||||||
|
const summaryDiv = document.getElementById('search-summary');
|
||||||
|
const paginationDiv = document.getElementById('search-pagination');
|
||||||
|
|
||||||
|
let _debounce = null;
|
||||||
|
let _currentPage = 1;
|
||||||
|
const PER_PAGE = 20;
|
||||||
|
|
||||||
|
function doSearch(page) {
|
||||||
|
const q = searchInput.value.trim();
|
||||||
|
if (!q) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
summaryDiv.style.display = 'none';
|
||||||
|
paginationDiv.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_currentPage = page || 1;
|
||||||
|
|
||||||
|
// Update URL without reload
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('q', q);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x"></i><p style="margin-top:0.75rem;">Searching…</p></div>';
|
||||||
|
summaryDiv.style.display = 'none';
|
||||||
|
paginationDiv.style.display = 'none';
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ q, page: _currentPage, per_page: PER_PAGE });
|
||||||
|
fetch('/api/search?' + params.toString())
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); })
|
||||||
|
.then(data => renderResults(data, q))
|
||||||
|
.catch(err => {
|
||||||
|
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search is temporarily unavailable. Please try again in a moment.</p><p style="font-size:0.75rem;color:#9ca3af;margin-top:0.25rem;">' + escapeHtml(err.message) + '</p></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = str;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize Meilisearch highlighted HTML: allow only <mark> tags,
|
||||||
|
* escape everything else to prevent XSS from indexed content.
|
||||||
|
*/
|
||||||
|
function sanitizeHighlight(html) {
|
||||||
|
// Temporarily replace <mark> and </mark> with placeholders
|
||||||
|
var safe = html
|
||||||
|
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
|
||||||
|
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
|
||||||
|
// Escape all remaining HTML
|
||||||
|
safe = escapeHtml(safe);
|
||||||
|
// Restore the <mark> tags
|
||||||
|
safe = safe
|
||||||
|
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
|
||||||
|
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data, q) {
|
||||||
|
const { results, total, page, pages } = data;
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
summaryDiv.textContent = total + ' result' + (total !== 1 ? 's' : '') + ' for "' + q + '"';
|
||||||
|
summaryDiv.style.display = 'block';
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<div class="search-empty"><i class="fas fa-search"></i><p>No documents found matching your query.</p></div>';
|
||||||
|
paginationDiv.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = results.map(function(hit) {
|
||||||
|
var fmt = hit._formatted || {};
|
||||||
|
var title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
|
||||||
|
var filename = hit.original_filename || '';
|
||||||
|
var snippet = fmt.ocr_text || '';
|
||||||
|
var tags = Array.isArray(hit.tags) ? hit.tags : (hit.tags ? [hit.tags] : []);
|
||||||
|
var docType = hit.document_type || '';
|
||||||
|
var sender = hit.sender || hit.absender || '';
|
||||||
|
var fileUrl = '/files/' + hit.file_id;
|
||||||
|
|
||||||
|
// Build badges
|
||||||
|
var badges = '';
|
||||||
|
if (docType) badges += '<span class="search-result-badge badge-type">' + escapeHtml(docType) + '</span>';
|
||||||
|
if (sender) badges += '<span class="search-result-badge badge-sender"><i class="fas fa-user"></i> ' + escapeHtml(sender) + '</span>';
|
||||||
|
tags.forEach(function(t) { badges += '<span class="search-result-badge badge-tag">' + escapeHtml(t) + '</span>'; });
|
||||||
|
|
||||||
|
// Snippet: use highlighted text, truncate if very long
|
||||||
|
var snippetHtml = '';
|
||||||
|
if (snippet) {
|
||||||
|
var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet;
|
||||||
|
snippetHtml = '<div class="search-result-snippet">…' + sanitizeHighlight(trimmed) + '…</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize title (may contain <mark> highlights from _formatted)
|
||||||
|
var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title);
|
||||||
|
|
||||||
|
return '<div class="search-result">' +
|
||||||
|
'<div class="search-result-title"><a href="' + escapeHtml(fileUrl) + '">' + safeTitle + '</a></div>' +
|
||||||
|
'<div class="search-result-url">' + escapeHtml(filename) + '</div>' +
|
||||||
|
(badges ? '<div class="search-result-meta">' + badges + '</div>' : '') +
|
||||||
|
snippetHtml +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
if (pages > 1) {
|
||||||
|
var btns = [];
|
||||||
|
if (page > 1) btns.push('<button data-page="' + (page - 1) + '">« Previous</button>');
|
||||||
|
btns.push('<span>Page ' + page + ' of ' + pages + '</span>');
|
||||||
|
if (page < pages) btns.push('<button data-page="' + (page + 1) + '">Next »</button>');
|
||||||
|
paginationDiv.innerHTML = btns.join('');
|
||||||
|
paginationDiv.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
paginationDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation for pagination buttons
|
||||||
|
paginationDiv.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('button[data-page]');
|
||||||
|
if (btn) doSearch(parseInt(btn.getAttribute('data-page'), 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
searchBtn.addEventListener('click', function() { doSearch(1); });
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') { doSearch(1); }
|
||||||
|
});
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(_debounce);
|
||||||
|
var val = searchInput.value.trim();
|
||||||
|
if (val.length < 2) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
summaryDiv.style.display = 'none';
|
||||||
|
paginationDiv.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debounce = setTimeout(function() { doSearch(1); }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If q was provided via URL, search immediately
|
||||||
|
if (searchInput.value.trim().length >= 2) {
|
||||||
|
doSearch(1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for the dedicated search page view (app/views/search.py)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSearchPage:
|
||||||
|
"""Tests for GET /search view endpoint."""
|
||||||
|
|
||||||
|
def test_search_page_renders(self, client):
|
||||||
|
"""GET /search returns 200 with search template."""
|
||||||
|
response = client.get("/search")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Document Search" in response.text
|
||||||
|
|
||||||
|
def test_search_page_with_query(self, client):
|
||||||
|
"""GET /search?q=invoice pre-fills the search input."""
|
||||||
|
response = client.get("/search?q=invoice")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'value="invoice"' in response.text
|
||||||
|
|
||||||
|
def test_search_page_empty_query(self, client):
|
||||||
|
"""GET /search?q= renders page without error."""
|
||||||
|
response = client.get("/search?q=")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Document Search" in response.text
|
||||||
|
|
||||||
|
def test_search_page_contains_search_elements(self, client):
|
||||||
|
"""GET /search contains required search UI elements."""
|
||||||
|
response = client.get("/search")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="search-input"' in response.text
|
||||||
|
assert 'id="search-btn"' in response.text
|
||||||
|
assert 'id="search-results"' in response.text
|
||||||
|
assert "/api/search" in response.text
|
||||||
|
|
||||||
|
def test_search_page_query_too_long(self, client):
|
||||||
|
"""GET /search?q=<very long> returns 422 for exceeding max_length."""
|
||||||
|
long_query = "a" * 600
|
||||||
|
response = client.get(f"/search?q={long_query}")
|
||||||
|
assert response.status_code == 422
|
||||||
Reference in New Issue
Block a user