test: 补酒名搜索拼音/多租户与前端搜索限流用例

- backend: 入库酒名全拼(feitian)+首字母(ftmt)命中、不相关拼音不命中
- backend: 入库酒名反查的多租户隔离(店A搜茅台不串到店B)
- client: setKeyword 防抖(350ms内连发取最后一次)+去重(同词不重查)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 00:07:17 +08:00
parent 102ace42ac
commit 84f99ef459
2 changed files with 146 additions and 0 deletions
+64
View File
@@ -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")
@@ -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<PageResult<StockInOrder>> list({
String? status,
String? startDate,
String? endDate,
String? keyword,
int page = 1,
int pageSize = 20,
}) async {
listCalls++;
lastKeyword = keyword;
return const PageResult<StockInOrder>(
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<void>.delayed(const Duration(milliseconds: 120));
expect(repo.listCalls, 1);
// 过了防抖窗口 → 合并成 1 次,关键词取最后一个
await Future<void>.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<void>.delayed(const Duration(milliseconds: 400));
expect(repo.listCalls, 2);
expect(repo.lastKeyword, '茅台');
// 再次搜索完全相同的关键词 → 直接 return,不发请求
notifier.setKeyword('茅台');
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(repo.listCalls, 2);
});
}