diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index 20afb2a..ab1f1d5 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -487,6 +487,70 @@ func TestStockInHandler_List_FilterByProductName(t *testing.T) { assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } +func TestStockInHandler_List_FilterByProductPinyin(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SI013") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 入库按 product_name 新建独立 product,会自动写入拼音列(name_pinyin/name_initials) + w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ + "warehouse_id": warehouse.ID, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 5.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + + // 全拼命中(feitianmaotai) + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=feitian", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 首字母命中(ftmt) + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ftmt", token, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + // 不相关拼音不命中 + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=wuliangye", token, nil) + assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) +} + +func TestStockInHandler_List_FilterByProductName_TenantIsolation(t *testing.T) { + db := testutil.SetupTestDB() + r := setupProtectedRouter(db) + + // 店A、店B 各建一张含「茅台」明细的入库单 + shopA := testutil.CreateTestShop(db, "SI014A") + userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") + whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") + tokenA := getAuthToken(userA.ID, shopA.ID, "admin") + + shopB := testutil.CreateTestShop(db, "SI014B") + userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") + whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB") + tokenB := getAuthToken(userB.ID, shopB.ID, "admin") + + for _, tc := range []struct { + token string + wh uint64 + }{{tokenA, whA.ID}, {tokenB, whB.ID}} { + w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tc.token, map[string]interface{}{ + "warehouse_id": tc.wh, + "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 1.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + } + + // 店A 搜茅台只看到自己的 1 单,绝不串到店B + w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenA, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) + + w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenB, nil) + assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) +} + func TestStockInHandler_List_FilterByStatus(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI010") diff --git a/client/test/stock_in_search_debounce_test.dart b/client/test/stock_in_search_debounce_test.dart new file mode 100644 index 0000000..010003c --- /dev/null +++ b/client/test/stock_in_search_debounce_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/models/stock_in.dart'; +import 'package:jiu_client/providers/stock_in_provider.dart'; +import 'package:jiu_client/repositories/stock_in_repository.dart'; + +/// 统计 list() 调用次数与最后一次关键词的假仓库,用于验证搜索限流。 +class _CountingStockInRepo extends StockInRepository { + int listCalls = 0; + String? lastKeyword; + + _CountingStockInRepo() : super(ApiClient(token: 't')); + + @override + Future> list({ + String? status, + String? startDate, + String? endDate, + String? keyword, + int page = 1, + int pageSize = 20, + }) async { + listCalls++; + lastKeyword = keyword; + return const PageResult( + data: [], total: 0, page: 1, pageSize: 20); + } +} + +void main() { + test('setKeyword 防抖:350ms 内连发只触发最后一次', () async { + final repo = _CountingStockInRepo(); + final container = ProviderContainer( + overrides: [stockInRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + // 初次 build 自动拉一次列表 + await container.read(stockInListProvider.future); + expect(repo.listCalls, 1); + + final notifier = container.read(stockInListProvider.notifier); + + // 350ms 内连发三个不同关键词 + notifier.setKeyword('茅'); + notifier.setKeyword('茅台'); + notifier.setKeyword('飞天茅台'); + + // 防抖窗口未到 → 不应有新请求 + await Future.delayed(const Duration(milliseconds: 120)); + expect(repo.listCalls, 1); + + // 过了防抖窗口 → 合并成 1 次,关键词取最后一个 + await Future.delayed(const Duration(milliseconds: 350)); + expect(repo.listCalls, 2); + expect(repo.lastKeyword, '飞天茅台'); + }); + + test('setKeyword 去重:相同关键词不重复查询', () async { + final repo = _CountingStockInRepo(); + final container = ProviderContainer( + overrides: [stockInRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + await container.read(stockInListProvider.future); + final notifier = container.read(stockInListProvider.notifier); + + // 首次搜索「茅台」 + notifier.setKeyword('茅台'); + await Future.delayed(const Duration(milliseconds: 400)); + expect(repo.listCalls, 2); + expect(repo.lastKeyword, '茅台'); + + // 再次搜索完全相同的关键词 → 直接 return,不发请求 + notifier.setKeyword('茅台'); + await Future.delayed(const Duration(milliseconds: 400)); + expect(repo.listCalls, 2); + }); +}