mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: add search to Transactions tab, spanning all history
Lets the user find a past purchase by keyword instead of paging through months manually — search hits description/merchant/notes and ignores the month filter, with a "jump to month" action on each result to view it in its normal monthly context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ from datetime import date as date_
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -51,6 +51,7 @@ async def list_transactions(
|
||||
account_id: int | None = None,
|
||||
category_id: int | None = None,
|
||||
type: TransactionType | None = None,
|
||||
search: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
@@ -80,6 +81,18 @@ async def list_transactions(
|
||||
if type is not None:
|
||||
stmt = stmt.where(Transaction.type == type)
|
||||
count_stmt = count_stmt.where(Transaction.type == type)
|
||||
if search is not None:
|
||||
# Lets the user find a transaction from any period by keyword (e.g. an
|
||||
# item bought months ago) without knowing which month to look in first —
|
||||
# matches description, merchant, and notes so any of those fields can surface it.
|
||||
pattern = f"%{search.strip()}%"
|
||||
search_clause = or_(
|
||||
Transaction.description.ilike(pattern),
|
||||
Transaction.merchant.ilike(pattern),
|
||||
Transaction.notes.ilike(pattern),
|
||||
)
|
||||
stmt = stmt.where(search_clause)
|
||||
count_stmt = count_stmt.where(search_clause)
|
||||
|
||||
total = (await session.execute(count_stmt)).scalar_one()
|
||||
|
||||
|
||||
@@ -111,6 +111,57 @@ async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_
|
||||
assert body["items"][0]["date"] == "2024-06-15"
|
||||
|
||||
|
||||
async def test_search_matches_description_across_any_period_ignoring_month_filter(
|
||||
client: AsyncClient, account_id, categories
|
||||
):
|
||||
"""The point of search is finding a purchase from an unknown month without
|
||||
already knowing which month to filter by, so it must not be scoped by the
|
||||
year/month params even when the caller happens to also pass them."""
|
||||
category_id = categories["Groceries"]["id"]
|
||||
for txn_date, description in [
|
||||
("2021-03-12", "PlayStation 5 console"),
|
||||
("2024-11-02", "grocery run"),
|
||||
("2026-08-16", "current month coffee"),
|
||||
]:
|
||||
resp = await client.post(
|
||||
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/transactions", params={"search": "playstation"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["description"] == "PlayStation 5 console"
|
||||
|
||||
|
||||
async def test_search_matches_merchant_and_notes(client: AsyncClient, account_id, categories):
|
||||
category_id = categories["Groceries"]["id"]
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, category_id=category_id, description="misc", merchant="Best Buy"),
|
||||
)
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(account_id, category_id=category_id, description="misc", notes="bought for a birthday gift"),
|
||||
)
|
||||
|
||||
merchant_resp = await client.get("/transactions", params={"search": "best buy"})
|
||||
assert merchant_resp.json()["total"] == 1
|
||||
|
||||
notes_resp = await client.get("/transactions", params={"search": "birthday"})
|
||||
assert notes_resp.json()["total"] == 1
|
||||
|
||||
|
||||
async def test_search_with_no_matches_returns_empty(client: AsyncClient, account_id, categories):
|
||||
category_id = categories["Groceries"]["id"]
|
||||
await client.post("/transactions", json=_txn(account_id, category_id=category_id, description="groceries"))
|
||||
|
||||
resp = await client.get("/transactions", params={"search": "nonexistent item xyz"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
|
||||
category_id = categories["Groceries"]["id"]
|
||||
for day in range(1, 26):
|
||||
|
||||
Reference in New Issue
Block a user