01c04c20ce
- Resolve add/add conflict in tests/test_api_saved_searches.py by keeping the improved HEAD version
- Resolve content conflict in tests/test_api_advanced_filters.py by keeping HEAD (no CRUD tests)
- Remove no-op test_get_user_id_branches (was just 'pass')
- Remove unused 'from fastapi import Request' import (fixes Ruff F401)
- Fix duplicate 'session = {}' assignment in MockRequest (fixes Ruff F811)
369 lines
15 KiB
Python
369 lines
15 KiB
Python
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.models import SavedSearch
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Saved searches CRUD tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSavedSearchesCRUD:
|
|
"""Tests for saved searches CRUD API endpoints."""
|
|
|
|
def test_list_saved_searches_empty(self, client: TestClient):
|
|
"""GET /api/saved-searches returns empty list when no searches exist."""
|
|
response = client.get("/api/saved-searches")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
def test_create_saved_search(self, client: TestClient):
|
|
"""POST /api/saved-searches creates a new saved search."""
|
|
payload = {
|
|
"name": "My Invoices",
|
|
"filters": {"tags": "invoice", "status": "completed"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "My Invoices"
|
|
assert data["filters"]["tags"] == "invoice"
|
|
assert data["filters"]["status"] == "completed"
|
|
assert "id" in data
|
|
|
|
def test_create_and_list_saved_search(self, client: TestClient):
|
|
"""Creating a saved search makes it appear in the list."""
|
|
payload = {
|
|
"name": "PDF Files",
|
|
"filters": {"mime_type": "application/pdf"},
|
|
}
|
|
client.post("/api/saved-searches", json=payload)
|
|
|
|
response = client.get("/api/saved-searches")
|
|
assert response.status_code == 200
|
|
searches = response.json()
|
|
assert len(searches) == 1
|
|
assert searches[0]["name"] == "PDF Files"
|
|
|
|
def test_create_saved_search_missing_name(self, client: TestClient):
|
|
"""POST /api/saved-searches without name returns 422."""
|
|
payload = {"filters": {"status": "completed"}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_saved_search_empty_filters(self, client: TestClient):
|
|
"""POST /api/saved-searches with empty filters returns 422."""
|
|
payload = {"name": "Empty", "filters": {}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_saved_search_invalid_filter_keys(self, client: TestClient):
|
|
"""POST /api/saved-searches ignores unknown filter keys."""
|
|
payload = {
|
|
"name": "With unknown keys",
|
|
"filters": {"invalid_key": "value", "status": "completed"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
# Only valid filter key should remain
|
|
assert "invalid_key" not in data["filters"]
|
|
assert data["filters"]["status"] == "completed"
|
|
|
|
def test_create_saved_search_only_invalid_keys(self, client: TestClient):
|
|
"""POST with only invalid filter keys returns 422."""
|
|
payload = {
|
|
"name": "All invalid",
|
|
"filters": {"bad_key": "value"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_duplicate_name(self, client: TestClient):
|
|
"""POST /api/saved-searches with duplicate name returns 409."""
|
|
payload = {"name": "My Search", "filters": {"status": "completed"}}
|
|
response1 = client.post("/api/saved-searches", json=payload)
|
|
assert response1.status_code == 201
|
|
|
|
response2 = client.post("/api/saved-searches", json=payload)
|
|
assert response2.status_code == 409
|
|
|
|
def test_update_saved_search(self, client: TestClient):
|
|
"""PUT /api/saved-searches/{id} updates the saved search."""
|
|
# Create
|
|
create_resp = client.post(
|
|
"/api/saved-searches",
|
|
json={"name": "Original", "filters": {"status": "pending"}},
|
|
)
|
|
search_id = create_resp.json()["id"]
|
|
|
|
# Update
|
|
update_resp = client.put(
|
|
f"/api/saved-searches/{search_id}",
|
|
json={"name": "Updated", "filters": {"status": "completed"}},
|
|
)
|
|
assert update_resp.status_code == 200
|
|
data = update_resp.json()
|
|
assert data["name"] == "Updated"
|
|
assert data["filters"]["status"] == "completed"
|
|
|
|
def test_update_saved_search_not_found(self, client: TestClient):
|
|
"""PUT /api/saved-searches/999 returns 404."""
|
|
response = client.put(
|
|
"/api/saved-searches/999",
|
|
json={"name": "Nope", "filters": {"status": "completed"}},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
def test_delete_saved_search(self, client: TestClient):
|
|
"""DELETE /api/saved-searches/{id} removes the saved search."""
|
|
# Create
|
|
create_resp = client.post(
|
|
"/api/saved-searches",
|
|
json={"name": "To Delete", "filters": {"status": "failed"}},
|
|
)
|
|
search_id = create_resp.json()["id"]
|
|
|
|
# Delete
|
|
del_resp = client.delete(f"/api/saved-searches/{search_id}")
|
|
assert del_resp.status_code == 204
|
|
|
|
# Verify it's gone
|
|
list_resp = client.get("/api/saved-searches")
|
|
assert len(list_resp.json()) == 0
|
|
|
|
def test_delete_saved_search_not_found(self, client: TestClient):
|
|
"""DELETE /api/saved-searches/999 returns 404."""
|
|
response = client.delete("/api/saved-searches/999")
|
|
assert response.status_code == 404
|
|
|
|
def test_create_name_too_long(self, client: TestClient):
|
|
"""POST /api/saved-searches with name > 100 chars returns 422."""
|
|
payload = {
|
|
"name": "x" * 101,
|
|
"filters": {"status": "completed"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_saved_search_filters_sanitized(self, client: TestClient):
|
|
"""Saved search filters are sanitized to allowed keys only."""
|
|
payload = {
|
|
"name": "Sanitized",
|
|
"filters": {
|
|
"search": "invoice",
|
|
"mime_type": "application/pdf",
|
|
"date_from": "2026-01-01",
|
|
"date_to": "2026-12-31",
|
|
"storage_provider": "dropbox",
|
|
"tags": "invoice,amazon",
|
|
"sort_by": "created_at",
|
|
"sort_order": "desc",
|
|
},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data["filters"]) == 8
|
|
assert data["filters"]["search"] == "invoice"
|
|
assert data["filters"]["tags"] == "invoice,amazon"
|
|
|
|
def test_saved_search_with_fulltext_query(self, client: TestClient):
|
|
"""Saved search can include full-text query (q) for the search view."""
|
|
payload = {
|
|
"name": "Invoice Search",
|
|
"filters": {"q": "invoice total amount", "document_type": "Invoice"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["filters"]["q"] == "invoice total amount"
|
|
assert data["filters"]["document_type"] == "Invoice"
|
|
|
|
def test_saved_search_content_finding_filters(self, client: TestClient):
|
|
"""Saved search accepts content-finding filter keys (language, sender, text_quality)."""
|
|
payload = {
|
|
"name": "German Invoices",
|
|
"filters": {
|
|
"q": "rechnung",
|
|
"language": "de",
|
|
"sender": "ACME GmbH",
|
|
"text_quality": "high",
|
|
"tags": "invoice",
|
|
},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["filters"]["q"] == "rechnung"
|
|
assert data["filters"]["language"] == "de"
|
|
assert data["filters"]["sender"] == "ACME GmbH"
|
|
assert data["filters"]["text_quality"] == "high"
|
|
assert data["filters"]["tags"] == "invoice"
|
|
|
|
|
|
def test_create_saved_search_max_limit(self, client: TestClient, db_session, mocker):
|
|
"""POST /api/saved-searches returns 409 when max limit is reached."""
|
|
from app.api.saved_searches import MAX_SAVED_SEARCHES_PER_USER
|
|
user_id = "test_user"
|
|
mocker.patch("app.api.saved_searches._get_user_id", return_value=user_id)
|
|
|
|
for i in range(MAX_SAVED_SEARCHES_PER_USER):
|
|
search = SavedSearch(user_id=user_id, name=f"Search {i}", filters="""{"tags": "invoice"}""")
|
|
db_session.add(search)
|
|
db_session.commit()
|
|
|
|
payload = {
|
|
"name": "One More",
|
|
"filters": {"tags": "invoice"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 409
|
|
|
|
def test_create_saved_search_db_error(self, client: TestClient, mocker):
|
|
"""POST /api/saved-searches handles db.commit errors gracefully."""
|
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
|
payload = {
|
|
"name": "Fail Me",
|
|
"filters": {"tags": "invoice"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 500
|
|
|
|
def test_update_saved_search_db_error(self, client: TestClient, mocker):
|
|
"""PUT /api/saved-searches/{id} handles db.commit errors gracefully."""
|
|
# Create a search first
|
|
payload = {
|
|
"name": "Update Target",
|
|
"filters": {"tags": "invoice"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
|
update_payload = {"name": "New Name"}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 500
|
|
|
|
def test_delete_saved_search_db_error(self, client: TestClient, mocker):
|
|
"""DELETE /api/saved-searches/{id} handles db.commit errors gracefully."""
|
|
# Create a search first
|
|
payload = {
|
|
"name": "Delete Target",
|
|
"filters": {"tags": "invoice"},
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
|
response = client.delete(f"/api/saved-searches/{search_id}")
|
|
assert response.status_code == 500
|
|
|
|
def test_update_saved_search_name_conflict(self, client: TestClient):
|
|
"""PUT /api/saved-searches/{id} returns 409 when the new name conflicts with an existing search."""
|
|
# Create search 1
|
|
payload1 = {"name": "Search One", "filters": {"tags": "invoice"}}
|
|
client.post("/api/saved-searches", json=payload1)
|
|
|
|
# Create search 2
|
|
payload2 = {"name": "Search Two", "filters": {"status": "completed"}}
|
|
response2 = client.post("/api/saved-searches", json=payload2)
|
|
search2_id = response2.json()["id"]
|
|
|
|
# Try to update search 2 to have name "Search One"
|
|
update_payload = {"name": "Search One"}
|
|
response = client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
|
|
assert response.status_code == 409
|
|
|
|
def test_update_saved_search_empty_filters(self, client: TestClient):
|
|
"""PUT /api/saved-searches/{id} returns 422 if filters are empty or invalid."""
|
|
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
# Empty filters
|
|
update_payload = {"filters": {}}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 422
|
|
|
|
# Invalid keys
|
|
update_payload = {"filters": {"invalid_key": "value"}}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_update_saved_search_invalid_name(self, client: TestClient):
|
|
"""PUT /api/saved-searches/{id} returns 422 if name is invalid or too long."""
|
|
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
# Empty name
|
|
update_payload = {"name": ""}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 422
|
|
|
|
# Too long name
|
|
update_payload = {"name": "A" * 101}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_update_saved_search_same_name(self, client: TestClient):
|
|
"""PUT /api/saved-searches/{id} with the same name does not trigger duplicate check error."""
|
|
# Create a search
|
|
payload = {"name": "Same Name", "filters": {"tags": "invoice"}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
# Update with the exact same name
|
|
update_payload = {"name": "Same Name"}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "Same Name"
|
|
|
|
def test_get_user_id_branches_real(self, client: TestClient):
|
|
from app.api.saved_searches import _get_user_id
|
|
|
|
# We need a mock request
|
|
class MockRequest:
|
|
session = {}
|
|
state = type('obj', (object,), {'user': None})
|
|
|
|
req = MockRequest()
|
|
assert _get_user_id(req) == "anonymous"
|
|
|
|
req.session["user"] = {"preferred_username": "pref"}
|
|
assert _get_user_id(req) == "pref"
|
|
|
|
req.session["user"] = {"email": "em@il.com"}
|
|
assert _get_user_id(req) == "em@il.com"
|
|
|
|
req.session["user"] = {"name": "named"}
|
|
assert _get_user_id(req) == "named"
|
|
|
|
req.session["user"] = {}
|
|
assert _get_user_id(req) == "anonymous"
|
|
|
|
def test_validate_filters_not_dict(self, client: TestClient):
|
|
"""POST /api/saved-searches with non-dict filters returns 422."""
|
|
payload = {
|
|
"name": "Invalid Filters",
|
|
"filters": "not a dict",
|
|
}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_create_saved_search_non_dict_filters(self, client: TestClient):
|
|
payload = {"name": "Test", "filters": []}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
def test_update_saved_search_non_dict_filters(self, client: TestClient):
|
|
payload = {"name": "Test", "filters": {"tags": "invoice"}}
|
|
response = client.post("/api/saved-searches", json=payload)
|
|
search_id = response.json()["id"]
|
|
|
|
update_payload = {"filters": []}
|
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
|
assert response.status_code == 422
|