From 9238f2ef242cfb39a6f0a850b10b65bd1a250905 Mon Sep 17 00:00:00 2001 From: Jiayu Wang Date: Wed, 28 Oct 2026 18:23:55 -0700 Subject: [PATCH 1/3] Test invalid item titles --- backend/tests/api/routes/test_items.py | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py index 3e82cd0134..851e0649a5 100644 --- a/backend/tests/api/routes/test_items.py +++ b/backend/tests/api/routes/test_items.py @@ -23,6 +23,36 @@ def test_create_item( assert "id" in content assert "owner_id" in content +def test_create_item_empty_title( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"title": "", "description": "测试空标题"} + response = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json=data, + ) + # print(response.json()) + assert response.status_code == 422 + +def test_create_item_missing_title( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"description": "测试缺少标题"} + response = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json=data, + ) + # print(response.json()) + assert response.status_code == 422 + + errors = response.json()["detail"] + assert any( + error["loc"] == ["body", "title"] + and error["type"] == "missing" + for error in errors + ) def test_read_item( client: TestClient, superuser_token_headers: dict[str, str], db: Session @@ -39,7 +69,6 @@ def test_read_item( assert content["id"] == str(item.id) assert content["owner_id"] == str(item.owner_id) - def test_read_item_not_found( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: From b10844dee3f408015a59aa97e107147bf7fd577a Mon Sep 17 00:00:00 2001 From: Jiayu Wang Date: Wed, 28 Oct 2026 21:03:06 -0700 Subject: [PATCH 2/3] Validate item pagination parameters --- backend/app/api/routes/items.py | 9 +++-- backend/tests/api/routes/test_items.py | 50 ++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index f0eb30e4ce..97381c9665 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,7 +1,7 @@ import uuid -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep @@ -12,7 +12,10 @@ @router.get("/", response_model=ItemsPublic) def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 + session: SessionDep, + current_user: CurrentUser, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 100, ) -> Any: """ Retrieve items. diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py index 851e0649a5..729824ec62 100644 --- a/backend/tests/api/routes/test_items.py +++ b/backend/tests/api/routes/test_items.py @@ -1,5 +1,6 @@ import uuid +import pytest from fastapi.testclient import TestClient from sqlmodel import Session @@ -23,6 +24,7 @@ def test_create_item( assert "id" in content assert "owner_id" in content + def test_create_item_empty_title( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: @@ -35,6 +37,7 @@ def test_create_item_empty_title( # print(response.json()) assert response.status_code == 422 + def test_create_item_missing_title( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: @@ -49,11 +52,11 @@ def test_create_item_missing_title( errors = response.json()["detail"] assert any( - error["loc"] == ["body", "title"] - and error["type"] == "missing" + error["loc"] == ["body", "title"] and error["type"] == "missing" for error in errors ) + def test_read_item( client: TestClient, superuser_token_headers: dict[str, str], db: Session ) -> None: @@ -69,6 +72,49 @@ def test_read_item( assert content["id"] == str(item.id) assert content["owner_id"] == str(item.owner_id) + +@pytest.mark.parametrize( + "params", + [ + {"skip": -1}, + {"limit": 0}, + {"limit": 101}, + ], +) +def test_read_items_invalid_pagination( + client: TestClient, + superuser_token_headers: dict[str, str], + params: dict[str, int], +) -> None: + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params=params, + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("limit", [1, 100]) +def test_read_items_valid_limit( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session, + limit: int, +) -> None: + create_random_item(db) + create_random_item(db) + + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params={"skip": 0, "limit": limit}, + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) == min(limit, content["count"]) + + def test_read_item_not_found( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: From c4ddadc489930450407144de449f8c477864d5ad Mon Sep 17 00:00:00 2001 From: Jiayu Wang Date: Thu, 29 Oct 2026 16:56:31 -0700 Subject: [PATCH 3/3] Add item title search --- backend/app/api/routes/items.py | 45 +++--- backend/tests/api/routes/test_items.py | 214 +++++++++++++++++++++++++ frontend/src/client/types.gen.ts | 4 + 3 files changed, 241 insertions(+), 22 deletions(-) diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 97381c9665..5774fa9fea 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -16,33 +16,34 @@ def read_items( current_user: CurrentUser, skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 100, + q: str | None = None, ) -> Any: """ Retrieve items. """ + count_statement = select(func.count()).select_from(Item) + statement = select(Item) - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = ( - select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit) - ) - items = session.exec(statement).all() - else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() - statement = ( - select(Item) - .where(Item.owner_id == current_user.id) - .order_by(col(Item.created_at).desc()) - .offset(skip) - .limit(limit) - ) - items = session.exec(statement).all() + # 普通用户只能看到自己的数据 + if not current_user.is_superuser: + owner_condition = Item.owner_id == current_user.id + count_statement = count_statement.where(owner_condition) + statement = statement.where(owner_condition) + + # 去除关键词两端的空格;空关键词不进行过滤 + keyword = q.strip() if q else "" + + if keyword: + search_condition = col(Item.title).icontains(keyword, autoescape=True) + count_statement = count_statement.where(search_condition) + statement = statement.where(search_condition) + + count = session.exec(count_statement).one() + + statement = ( + statement.order_by(col(Item.created_at).desc()).offset(skip).limit(limit) + ) + items = session.exec(statement).all() items_public = [ItemPublic.model_validate(item) for item in items] return ItemsPublic(data=items_public, count=count) diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py index 729824ec62..1b83db4f06 100644 --- a/backend/tests/api/routes/test_items.py +++ b/backend/tests/api/routes/test_items.py @@ -73,6 +73,220 @@ def test_read_item( assert content["owner_id"] == str(item.owner_id) +def test_read_items_search_no_match( + client: TestClient, + superuser_token_headers: dict[str, str], +) -> None: + created = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": "买牛奶"}, + ) + assert created.status_code == 200 + + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params={"q": uuid.uuid4().hex}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["data"] == [] + assert content["count"] == 0 + + +def test_read_items_search_ignores_case( + client: TestClient, + superuser_token_headers: dict[str, str], +) -> None: + keyword = f"Hello{uuid.uuid4().hex}" + + created = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": f"学习 {keyword.upper()}"}, + ) + assert created.status_code == 200 + created_id = created.json()["id"] + + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params={"q": keyword.lower()}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert [item["id"] for item in content["data"]] == [created_id] + + +@pytest.mark.parametrize( + "search_params", + [ + {}, + {"q": ""}, + {"q": " "}, + ], +) +def test_read_items_empty_search( + client: TestClient, + normal_user_token_headers: dict[str, str], + search_params: dict[str, str], +) -> None: + expected_ids = set() + + # 为当前普通用户准备两条数据 + for title in ("学习 Python", "买牛奶"): + created = client.post( + f"{settings.API_V1_STR}/items/", + headers=normal_user_token_headers, + json={"title": title}, + ) + assert created.status_code == 200 + expected_ids.add(created.json()["id"]) + + # 不搜索时的结果,作为比较基准 + baseline = client.get( + f"{settings.API_V1_STR}/items/", + headers=normal_user_token_headers, + params={"skip": 0, "limit": 100}, + ) + assert baseline.status_code == 200 + baseline_content = baseline.json() + + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=normal_user_token_headers, + params={"skip": 0, "limit": 100, **search_params}, + ) + + assert response.status_code == 200 + content = response.json() + assert content == baseline_content + + baseline_ids = {item["id"] for item in baseline_content["data"]} + assert expected_ids <= baseline_ids + + +def test_read_items_search_pagination_count( + client: TestClient, + superuser_token_headers: dict[str, str], +) -> None: + keyword = uuid.uuid4().hex + matching_ids = set() + + # 创建两条标题匹配的数据 + for number in range(2): + created = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": f"{keyword} 第 {number + 1} 条"}, + ) + assert created.status_code == 200 + matching_ids.add(created.json()["id"]) + + # 创建一条不匹配的数据,不应计入搜索总数 + other = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": "买牛奶"}, + ) + assert other.status_code == 200 + + returned_ids = [] + + # 分别查询第一页和第二页 + for skip in (0, 1): + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params={"q": keyword, "skip": skip, "limit": 1}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 2 + assert len(content["data"]) == 1 + returned_ids.append(content["data"][0]["id"]) + + assert set(returned_ids) == matching_ids + + +def test_read_items_search_respects_owner( + client: TestClient, + normal_user_token_headers: dict[str, str], + superuser_token_headers: dict[str, str], +) -> None: + keyword = uuid.uuid4().hex + + # 普通用户创建自己的匹配数据 + own = client.post( + f"{settings.API_V1_STR}/items/", + headers=normal_user_token_headers, + json={"title": f"自己的 {keyword}"}, + ) + assert own.status_code == 200 + own_id = own.json()["id"] + + # 管理员创建另一条同样匹配的数据 + other = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": f"别人的 {keyword}"}, + ) + assert other.status_code == 200 + + # 普通用户搜索,只应看到自己的那条 + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=normal_user_token_headers, + params={"q": keyword}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert [item["id"] for item in content["data"]] == [own_id] + + +def test_read_items_search_by_title( + client: TestClient, + superuser_token_headers: dict[str, str], +) -> None: + keyword = uuid.uuid4().hex + + # 创建标题匹配的数据 + matching = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": f"学习 {keyword}"}, + ) + assert matching.status_code == 200 + matching_id = matching.json()["id"] + + # 创建标题不匹配、但描述包含关键词的数据 + other = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json={"title": "买牛奶", "description": keyword}, + ) + assert other.status_code == 200 + + # 按标题关键词搜索 + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + params={"q": keyword}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert [item["id"] for item in content["data"]] == [matching_id] + + @pytest.mark.parametrize( "params", [ diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 6252d88c2f..38842af9ca 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -784,6 +784,10 @@ export type itemsReadItemsData = { * Limit */ limit?: number; + /** + * Q + */ + q?: string | null; }; url: '/api/v1/items/'; };