feat(5.22): Saved Searches / Smart Lists — reusable filters for list views

- SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
- Routes: GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
- Alembic migration 0029_saved_filters creates saved_filters table
- Frontend: SavedFilters.tsx component with save/load/delete UI
- Frontend: api/savedFilters.ts with React Query hooks
- Integrated into ContactsListPage as example
- i18n keys for savedFilters.* in de.json and en.json
- Tests: test_saved_filters.py (9 tests) + SavedFilters.test.tsx (3 tests)
- Registered SavedFilter in conftest.py
This commit is contained in:
Agent Zero
2026-07-23 23:44:50 +02:00
parent 2c9e74776e
commit 182af355d1
12 changed files with 663 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
"""saved_filters table
Revision ID: 0029_saved_filters
Revises: 0028_user_preferences
Create Date: 2025-07-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers
revision = "0029_saved_filters"
down_revision = "0028_user_preferences"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"saved_filters",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("filter_criteria", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
)
op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"])
op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"])
def downgrade() -> None:
op.drop_index("ix_saved_filters_tenant_entity", table_name="saved_filters")
op.drop_index("ix_saved_filters_tenant_user", table_name="saved_filters")
op.drop_table("saved_filters")
+2
View File
@@ -48,6 +48,7 @@ from app.routes import (
system_settings,
attachments,
custom_fields,
saved_filters,
)
@@ -306,6 +307,7 @@ def create_app() -> FastAPI:
app.include_router(addresses.router)
app.include_router(audit.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
# ── Register plugin routes (before SPA catch-all) ──────────────────
registry = get_registry()
+36
View File
@@ -0,0 +1,36 @@
"""SavedFilter model — tenant-scoped, user-scoped saved filter criteria."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class SavedFilter(Base, TenantMixin):
"""Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS)."""
__tablename__ = "saved_filters"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
Index("ix_saved_filters_tenant_user", "tenant_id", "user_id"),
Index("ix_saved_filters_tenant_entity", "tenant_id", "entity_type"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(
String(50), nullable=False
) # contacts, mail, calendar, dms
filter_criteria: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
+136
View File
@@ -0,0 +1,136 @@
"""Saved filters routes — CRUD for reusable filter criteria."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.models.saved_filter import SavedFilter
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
class SavedFilterCreate(BaseModel):
"""Schema for creating a saved filter."""
name: str = Field(..., min_length=1, max_length=100)
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
filter_criteria: dict[str, Any] = Field(default_factory=dict)
class SavedFilterUpdate(BaseModel):
"""Schema for updating a saved filter."""
name: str | None = Field(default=None, min_length=1, max_length=100)
filter_criteria: dict[str, Any] | None = None
def _filter_to_dict(f: SavedFilter) -> dict[str, Any]:
return {
"id": str(f.id),
"name": f.name,
"entity_type": f.entity_type,
"filter_criteria": f.filter_criteria,
"user_id": str(f.user_id),
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
async def list_saved_filters(
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List saved filters for the current user, optionally filtered by entity_type."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
query = select(SavedFilter).where(
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.deleted_at.is_(None),
)
if entity_type:
query = query.where(SavedFilter.entity_type == entity_type)
query = query.order_by(SavedFilter.name)
result = await db.execute(query)
filters = result.scalars().all()
return [_filter_to_dict(f) for f in filters]
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
async def create_saved_filter(
body: SavedFilterCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new saved filter for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check uniqueness within user+entity
existing = await db.execute(
select(SavedFilter).where(
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.entity_type == body.entity_type,
SavedFilter.name == body.name,
SavedFilter.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"})
saved = SavedFilter(
tenant_id=tenant_id,
user_id=user_id,
name=body.name,
entity_type=body.entity_type,
filter_criteria=body.filter_criteria,
)
db.add(saved)
await db.flush()
return _filter_to_dict(saved)
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
async def delete_saved_filter(
filter_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a saved filter (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
fid = uuid.UUID(filter_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
result = await db.execute(
select(SavedFilter).where(
SavedFilter.id == fid,
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.deleted_at.is_(None),
)
)
saved = result.scalar_one_or_none()
if saved is None:
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
from datetime import datetime, timezone
saved.deleted_at = datetime.now(timezone.utc)
await db.flush()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,114 @@
/**
* SavedFilters component tests.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SavedFilters } from '@/components/SavedFilters';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}));
// Mock UI components
vi.mock('@/components/ui/Input', () => ({
Input: ({ value, onChange, label, ...props }: any) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={props['data-testid'] || 'input'}
value={value || ''}
onChange={onChange}
/>
</div>
),
}));
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, onClose, title, children }: any) =>
open ? (
<div data-testid="modal" role="dialog">
<h2>{title}</h2>
<button onClick={onClose}>Close</button>
{children}
</div>
) : null,
}));
vi.mock('@/components/ui/Button', () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
{children}
</button>
),
}));
// Mock saved filters API
vi.mock('@/api/savedFilters', () => ({
useSavedFilters: vi.fn(() => ({
data: [
{
id: 'filter-1',
name: 'Important Clients',
entity_type: 'contacts',
filter_criteria: { type: 'company' },
user_id: 'user-1',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
})),
useCreateSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useDeleteSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
describe('SavedFilters', () => {
it('renders saved filter buttons', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
expect(screen.getByTestId('saved-filters')).toBeInTheDocument();
expect(screen.getByText('Important Clients')).toBeInTheDocument();
});
it('opens save modal when save button is clicked', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('save-filter-btn'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('filter-name-input')).toBeInTheDocument();
});
it('calls onLoadFilter when a saved filter is clicked', () => {
const onLoadFilter = vi.fn();
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={onLoadFilter}
/>
);
fireEvent.click(screen.getByText('Important Clients'));
expect(onLoadFilter).toHaveBeenCalledWith({ type: 'company' });
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Saved filters API hooks — CRUD for reusable filter criteria.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiDelete } from './client';
export interface SavedFilter {
id: string;
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
user_id: string;
created_at: string;
updated_at: string;
}
export interface SavedFilterCreateInput {
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
}
export function useSavedFilters(entityType?: string) {
const params = new URLSearchParams();
if (entityType) params.set('entity_type', entityType);
return useQuery({
queryKey: ['saved-filters', entityType],
queryFn: () => apiGet<SavedFilter[]>(`/saved-filters?${params.toString()}`),
});
}
export function useCreateSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SavedFilterCreateInput) => apiPost<SavedFilter>('/saved-filters', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
export function useDeleteSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/saved-filters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
+119
View File
@@ -0,0 +1,119 @@
/**
* SavedFilters — filter-builder UI, save button, load saved filters.
* Integrates into list views (Contacts, Mail, Calendar, DMS).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { Bookmark, Trash2, Save } from 'lucide-react';
import {
useSavedFilters,
useCreateSavedFilter,
useDeleteSavedFilter,
type SavedFilter,
} from '@/api/savedFilters';
export interface SavedFiltersProps {
entityType: string;
currentCriteria: Record<string, any>;
onLoadFilter: (criteria: Record<string, any>) => void;
}
export function SavedFilters({ entityType, currentCriteria, onLoadFilter }: SavedFiltersProps) {
const { t } = useTranslation();
const toast = useToast();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [filterName, setFilterName] = useState('');
const { data: savedFilters = [] } = useSavedFilters(entityType);
const createMutation = useCreateSavedFilter();
const deleteMutation = useDeleteSavedFilter();
const handleSave = async () => {
if (!filterName.trim()) return;
try {
await createMutation.mutateAsync({
name: filterName.trim(),
entity_type: entityType,
filter_criteria: currentCriteria,
});
toast.success(t('savedFilters.saved'));
setFilterName('');
setSaveModalOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async (id: string) => {
try {
await deleteMutation.mutateAsync(id);
toast.success(t('savedFilters.deleted'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleLoad = (filter: SavedFilter) => {
onLoadFilter(filter.filter_criteria);
};
return (
<div className="flex items-center gap-2 flex-wrap" data-testid="saved-filters">
{/* Saved filter tabs/buttons */}
{savedFilters.map((filter) => (
<div
key={filter.id}
className="flex items-center gap-1 px-2 py-1 rounded-md bg-secondary-100 hover:bg-secondary-200 cursor-pointer group"
onClick={() => handleLoad(filter)}
data-testid={`saved-filter-${filter.id}`}
>
<Bookmark className="w-3 h-3 text-secondary-500" />
<span className="text-xs text-secondary-700">{filter.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(filter.id); }}
className="opacity-0 group-hover:opacity-100 text-danger-500 hover:text-danger-700"
aria-label={t('common.delete')}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
{/* Save current filter button */}
<Button
size="sm"
variant="ghost"
onClick={() => setSaveModalOpen(true)}
data-testid="save-filter-btn"
>
<Save className="w-3 h-3 mr-1" />
{t('savedFilters.save')}
</Button>
{/* Save modal */}
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} title={t('savedFilters.saveTitle')} size="sm">
<div className="space-y-4">
<Input
label={t('savedFilters.name')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder={t('savedFilters.namePlaceholder')}
data-testid="filter-name-input"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setSaveModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={!filterName.trim() || createMutation.isPending} data-testid="save-filter-confirm">
{t('common.save')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+10
View File
@@ -1022,5 +1022,15 @@
"priorityMedium": "Mittel",
"priorityHigh": "Hoch",
"priorityUrgent": "Dringend"
},
"savedFilters": {
"save": "Filter speichern",
"saveTitle": "Filter speichern",
"name": "Filtername",
"namePlaceholder": "z.B. Wichtige Kunden",
"saved": "Filter gespeichert",
"deleted": "Filter gelöscht",
"load": "Filter laden",
"noFilters": "Keine gespeicherten Filter"
}
}
+10
View File
@@ -1022,5 +1022,15 @@
"priorityMedium": "Medium",
"priorityHigh": "High",
"priorityUrgent": "Urgent"
},
"savedFilters": {
"save": "Save Filter",
"saveTitle": "Save Filter",
"name": "Filter Name",
"namePlaceholder": "e.g. Important Clients",
"saved": "Filter saved",
"deleted": "Filter deleted",
"load": "Load Filter",
"noFilters": "No saved filters"
}
}
+16
View File
@@ -16,6 +16,7 @@ import { ContactFolderTree, type ContactFilter } from '@/components/contacts/Con
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { SavedFilters } from '@/components/SavedFilters';
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
import {
useUnifiedContacts,
@@ -328,6 +329,21 @@ export function ContactsListPage() {
</button>
</div>
{/* Saved Filters */}
<div className="px-3 py-1.5 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
onLoadFilter={(criteria) => {
if (criteria.search) setSearch(criteria.search); else setSearch('');
if (criteria.sortBy) setSortBy(criteria.sortBy);
if (criteria.sortOrder) setSortOrder(criteria.sortOrder);
if (criteria.type) setSelectedFilter(criteria.type as ContactFilter);
setPage(1);
}}
/>
</div>
{/* List */}
<div className="flex-1 min-h-0">
<ContactList
+1
View File
@@ -82,6 +82,7 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
from app.plugins.builtins.tasks.models import Task # noqa: F401
from app.models.saved_filter import SavedFilter # noqa: F401
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
+128
View File
@@ -0,0 +1,128 @@
"""Saved filters tests — CRUD for reusable filter criteria."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestSavedFilterList:
"""GET /api/v1/saved-filters"""
async def test_list_saved_filters_returns_200(self, client: AsyncClient, db_session):
"""GET /saved-filters returns 200 with list."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_list_saved_filters_with_entity_type(self, client: AsyncClient, db_session):
"""GET /saved-filters?entity_type=contacts filters by entity."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters?entity_type=contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200
for item in resp.json():
assert item["entity_type"] == "contacts"
async def test_list_saved_filters_requires_auth(self, client: AsyncClient, db_session):
"""GET /saved-filters without auth returns 401."""
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestSavedFilterCreate:
"""POST /api/v1/saved-filters"""
async def test_create_saved_filter_returns_201(self, client: AsyncClient, db_session):
"""POST /saved-filters creates a filter and returns 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={
"name": "Important Clients",
"entity_type": "contacts",
"filter_criteria": {"type": "company", "search": "important"},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Important Clients"
assert data["entity_type"] == "contacts"
assert data["filter_criteria"]["type"] == "company"
async def test_create_saved_filter_duplicate_returns_409(self, client: AsyncClient, db_session):
"""POST /saved-filters with duplicate name returns 409."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create first
await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
# Create duplicate
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 409
async def test_create_saved_filter_invalid_entity_returns_422(self, client: AsyncClient, db_session):
"""POST /saved-filters with invalid entity_type returns 422."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "Test", "entity_type": "invalid", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestSavedFilterDelete:
"""DELETE /api/v1/saved-filters/{id}"""
async def test_delete_saved_filter_returns_204(self, client: AsyncClient, db_session):
"""DELETE /saved-filters/{id} soft-deletes the filter."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create
create_resp = await client.post(
"/api/v1/saved-filters",
json={"name": "To Delete", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
filter_id = create_resp.json()["id"]
# Delete
resp = await client.delete(f"/api/v1/saved-filters/{filter_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify gone from list
list_resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert not any(f["id"] == filter_id for f in list_resp.json())
async def test_delete_saved_filter_not_found_returns_404(self, client: AsyncClient, db_session):
"""DELETE non-existent filter returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete(
"/api/v1/saved-filters/00000000-0000-0000-0000-000000000000",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
async def test_delete_saved_filter_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
"""DELETE with invalid UUID returns 400."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete("/api/v1/saved-filters/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400