Files
pj_supermarket_cloud/test/user_cache_consistency_test.dart
dzc c6f9ffb998 feat:购买失败修改
fix:购买副屏展示用户余额
2026-10-26 17:46:54 +08:00

520 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as dio;
import 'package:floor/floor.dart' show Callback;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/common/services/database/database_migration.dart';
import 'package:sqflite/sqflite.dart' as sqlite;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class _CacheTestApp extends AppControl {
@override
void onClose() {}
}
/// Every request terminates here; these tests never contact a backend or device.
class _CacheAdapter implements dio.HttpClientAdapter {
_CacheAdapter(this.respond);
final Future<Object> Function(dio.RequestOptions) respond;
final requests = <dio.RequestOptions>[];
@override
Future<dio.ResponseBody> fetch(dio.RequestOptions options,
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
requests.add(options);
return dio.ResponseBody.fromString(
jsonEncode({'code': 0, 'data': await respond(options)}),
200,
headers: {
'content-type': ['application/json']
},
);
}
@override
void close({bool force = false}) {}
}
DiscountsItem _rule(double rate) => DiscountsItem(
name: 'cache consistency rule',
hourType: ProductHourType.MORNING,
type: null,
basePrice: 0,
specialDishEnable: true,
extraPer: rate,
);
ServiceUser _onlineUser({int balance = 700, String canteen = '10'}) =>
ServiceUser(
userId: '100',
username: '张三',
userMobile: 'synthetic-mobile',
userAvatar: 'synthetic-avatar',
walletId: '200',
walletTypeId: '300',
walletCardNumber: 'synthetic-card',
walletBalance: balance,
canteenId: canteen,
);
Map<String, dynamic> _snapshot({int balance = 900, int canteen = 10}) => {
'records': [
{
'userId': '100',
'username': '张*',
'walletCardNumber': 'synthetic-card',
'walletId': '200',
'walletTypeId': '300',
'walletBalance': balance,
'canteenId': canteen,
}
],
'nextAfterId': '100',
'hasMore': false,
'snapshotMaxId': '100',
};
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
late Directory directory;
late AppDatabase database;
late _CacheTestApp app;
late dio.HttpClientAdapter originalAdapter;
late String originalBaseUrl;
late _CacheAdapter adapter;
Future<int> cachedUserCount() async {
final rows = await database.database.rawQuery(
'SELECT COUNT(*) AS count FROM user_record WHERE userId = ?',
['100'],
);
return rows.single['count'] as int;
}
void mockSnapshot({int balance = 900, int canteen = 10}) {
adapter = _CacheAdapter((options) async {
expect(options.path, endsWith('/offline-user/sync-snapshot'));
return _snapshot(balance: balance, canteen: canteen);
});
HttpManage().dio.httpClientAdapter = adapter;
}
setUpAll(() async {
directory = await Directory.systemTemp.createTemp('pj-user-cache-test-');
for (final channel in [
'plugins.flutter.io/path_provider',
'external_path'
]) {
binding.defaultBinaryMessenger.setMockMethodCallHandler(
MethodChannel(channel),
(_) async => directory.path,
);
}
sqfliteFfiInit();
// Floor selects this factory on desktop; share it with the settings cache.
sqlite.databaseFactory = databaseFactoryFfi;
database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build();
app = _CacheTestApp()..dataBase = database;
Get.put<AppControl>(app);
await database.getSettingDao.save(SettingEntity(
sysJson: jsonEncode(
SysSetting(sysType: SysType.CANTEEN, serviceId: '10').toJson()),
userJson: jsonEncode(
(UserSetting(username: '', password: '')..tenantId = '1').toJson()),
));
await logger.init;
final client = HttpManage().dio;
originalAdapter = client.httpClientAdapter;
originalBaseUrl = client.options.baseUrl;
client.options.baseUrl = 'https://user-cache-test.invalid/api';
});
setUp(() async {
app.isNetwork.value = true;
await database.getUserDao.delAll();
await database.getFaceDao.delAll();
mockSnapshot();
});
tearDownAll(() async {
final client = HttpManage().dio;
client.httpClientAdapter = originalAdapter;
client.options.baseUrl = originalBaseUrl;
await logger.close();
await AppSettingTool.removeSettingCache();
await database.close();
Get.delete<AppControl>(force: true);
await directory.delete(recursive: true);
});
test('并发首次缓存同一人员只生成一行,之后仍可单行读取', () async {
await Future.wait(List.generate(
8,
(i) => SyncUserTool.storageUser(user: _onlineUser(balance: 700 + i)),
));
expect(await cachedUserCount(), 1);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.nickname, '张三');
expect(stored.walletBalance, inInclusiveRange(700, 707));
expect(stored.createTime, isNotNull);
expect(stored.updateTime, isNotNull);
expect(adapter.requests, isEmpty);
});
test('在线缓存与离线人员同步交错时仍只有一行并保留完整身份', () async {
final requested = Completer<void>();
final response = Completer<Object>();
adapter = _CacheAdapter((options) {
expect(options.path, endsWith('/offline-user/sync-snapshot'));
requested.complete();
return response.future;
});
HttpManage().dio.httpClientAdapter = adapter;
final offline = SyncUserTool.syncOfflineUsers();
await requested.future;
final online = SyncUserTool.storageUser(user: _onlineUser());
response.complete(_snapshot());
await Future.wait([offline, online]);
expect(await cachedUserCount(), 1);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.nickname, '张三');
expect(stored.mobile, 'synthetic-mobile');
expect(stored.avatar, 'synthetic-avatar');
expect(stored.walletBalance, anyOf(700, 900));
expect(adapter.requests, hasLength(1));
});
test('并发优惠更新不会被在线人员刷新覆盖', () async {
await SyncUserTool.storageUser(user: _onlineUser(balance: 1000));
final updatedAt = DateTime(2026, 10, 26, 12);
await Future.wait([
SyncUserTool.storageUser(user: _onlineUser(balance: 600)),
database.getUserDao.updateDiscounts(
'100',
jsonEncode([_rule(2).toJson()]),
updatedAt,
),
]);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.walletBalance, 600);
expect(stored.discountsList!.single.extraPer, 2);
expect(stored.discountsUpdateTime, updatedAt);
});
test('离线人员同步与优惠清空并发时保留最新空规则和身份字段', () async {
await SyncUserTool.storageUser(user: _onlineUser(balance: 1000));
await database.getUserDao.updateDiscounts(
'100',
jsonEncode([_rule(2).toJson()]),
DateTime(2026, 10, 25),
);
final updatedAt = DateTime(2026, 10, 26, 12);
await Future.wait([
SyncUserTool.syncOfflineUsers(),
database.getUserDao.updateDiscounts('100', '[]', updatedAt),
]);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.walletBalance, 900);
expect(stored.nickname, '张三');
expect(stored.mobile, 'synthetic-mobile');
expect(stored.avatar, 'synthetic-avatar');
expect(stored.discountsList, isEmpty);
expect(stored.discountsUpdateTime, updatedAt);
});
test('仅商户归属或头像变化也会更新缓存,离线快照保留真实身份', () async {
await SyncUserTool.storageUser(user: _onlineUser());
await SyncUserTool.storageUser(
user: _onlineUser()..userAvatar = 'new-synthetic-avatar',
);
expect((await database.getUserDao.findByUserId('100'))!.avatar,
'new-synthetic-avatar');
mockSnapshot(balance: 700, canteen: 20);
final summary = await SyncUserTool.syncOfflineUsers();
expect(summary.updated, 1);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.canteenId, '20');
expect(stored.nickname, '张三');
expect(stored.avatar, 'new-synthetic-avatar');
});
test('同一用户多个样本人脸都能离线解析到唯一人员', () async {
await SyncUserTool.storageUser(user: _onlineUser());
for (final sampleId in ['101', '102', '103']) {
await database.getFaceDao.save(FaceEntity(
userId: '100',
sampleId: sampleId,
userName: '张三',
imagePath: 'synthetic-$sampleId',
faceModel: [1, 2, 3],
));
}
app.isNetwork.value = false;
for (final sampleId in [101, 102, 103]) {
final recognized = await AppFaceTool.resolveRecognizedUser(
recognition: FaceStream(
similar: 0.95, cost: 1, sampleId: sampleId, userId: '100'),
sysType: SysType.CANTEEN,
);
expect(recognized!.userId, '100');
expect(recognized.walletBalance, 700);
}
expect(await cachedUserCount(), 1);
expect(await database.getFaceDao.findByUserId('100'), hasLength(3));
expect(adapter.requests, isEmpty);
});
test('空用户编号不能生成无法归属的离线缓存', () async {
await SyncUserTool.storageUser(user: _onlineUser()..userId = ' ');
await SyncUserTool.storageUser(user: _onlineUser()..userId = null);
expect(await database.getUserDao.findCount('-1'), 0);
});
test('15升级16合并重复人员且保留人脸、订单,修复旧版缺列和索引', () async {
final path = '${directory.path}/legacy-user-cache.db';
var legacy = await $FloorAppDatabase.databaseBuilder(path).build();
await legacy.database
.execute('DROP INDEX IF EXISTS index_user_record_userId');
await legacy.database
.execute('ALTER TABLE face DROP COLUMN qualityReportedAt');
const hotIndexes = [
'index_supermarket_sku_barCode',
'index_supermarket_sku_spuId',
'index_supermarket_spu_classifyServiceUniqueCode_delFlag',
'index_supermarket_spu_serviceUniqueCode',
'index_face_sampleId',
'index_face_userId',
];
for (final name in hotIndexes) {
await legacy.database.execute('DROP INDEX IF EXISTS $name');
}
final oldRules = jsonEncode([_rule(1.5).toJson()]);
final newRules = jsonEncode([_rule(2).toJson()]);
Future<void> seed(int id, String userId,
{required String updated,
required int balance,
String name = '张*',
String? mobile,
String? avatar,
String? rules,
String? rulesUpdated,
String created = '2026-10-19 08:00:00'}) async {
await legacy.database.insert('user_record', {
'id': id,
'userId': userId,
'nickname': name,
'mobile': mobile,
'avatar': avatar,
'walletId': 'wallet-$id',
'walletTypeId': 'type-$id',
'walletCardNumber': 'card-$id',
'walletBalance': balance,
'canteenId': 'canteen-$id',
'createTime': created,
'updateTime': updated,
'discountsList': rules,
'discountsUpdateTime': rulesUpdated,
});
}
await seed(10, '100',
updated: '2026-10-20 08:00:00',
balance: 1300,
name: '张三',
mobile: 'synthetic-mobile',
avatar: 'synthetic-avatar',
created: '2026-10-10 08:00:00',
rules: oldRules,
rulesUpdated: '2026-10-21 08:00:00');
await seed(20, '100',
updated: '2026-10-26 08:00:00',
balance: 700,
mobile: '',
avatar: '',
rules: oldRules,
rulesUpdated: '2026-10-22 08:00:00');
await seed(30, '100',
updated: '2026-10-24 08:00:00',
balance: 900,
name: '未知',
rules: '[]',
rulesUpdated: '2026-10-25 08:00:00');
await seed(40, '200',
updated: '2026-10-23 08:00:00',
balance: 1100,
name: '李四',
rules: newRules,
rulesUpdated: '2026-10-26 08:00:00');
await seed(50, '200',
updated: '2026-10-25 08:00:00',
balance: 500,
name: '李*',
rules: oldRules,
rulesUpdated: '2026-10-24 08:00:00');
await seed(60, '300',
updated: '2026-10-25 08:00:00', balance: 600, name: '王五');
await seed(70, '400',
updated: '2026-10-26 08:00:00',
balance: 420,
name: '赵六',
rules: oldRules,
rulesUpdated: '2026-10-24 08:00:00');
// The historical converter encodes an empty rule list as SQL NULL.
// Its newer timestamp must clear old rules independently of the wallet row.
await seed(80, '400',
updated: '2026-10-23 08:00:00',
balance: 1000,
name: '赵*',
rules: ListDiscountsConverter().encode([]),
rulesUpdated: '2026-10-26 09:00:00');
await legacy.database.insert('face', {
'userId': '100',
'sampleId': '101',
'userName': '张三',
'imagePath': 'synthetic-face',
'faceModel': null,
'qualityStatus': 'pending',
});
await legacy.getOrderDao.save(OrderEntity(
uniqueCode: 'synthetic-offline-order',
serviceUniqueCode: '',
sysType: SysType.CANTEEN,
userCode: '100',
price: 400,
extraPrice: 0,
payPrice: 400,
type: OrderType.BELOW,
status: OrderStatus.OFFLINE_PAY,
payType: OrderPayType.FACE,
count: 2,
weight: 0,
createTime: DateTime(2026, 10, 25),
));
final faceBefore = await legacy.database.query('face');
final ordersBefore = await legacy.database.query('order_record');
final otherBefore = await legacy.database
.query('user_record', where: 'userId = ?', whereArgs: ['300']);
await legacy.database.execute('PRAGMA user_version = 15');
await legacy.close();
legacy = await $FloorAppDatabase
.databaseBuilder(path)
.addMigrations(DatabaseMigration.getDatabaseMigrations())
.build();
try {
expect(await legacy.database.getVersion(), 16);
expect(await legacy.getUserDao.findCount('-1'), 4);
final merged = (await legacy.getUserDao.findByUserId('100'))!;
expect(merged.id, 20);
expect(merged.walletBalance, 700);
expect(merged.walletId, 'wallet-20');
expect(merged.walletCardNumber, 'card-20');
expect(merged.walletTypeId, 'type-20');
expect(merged.canteenId, 'canteen-20');
expect(merged.nickname, '张三');
expect(merged.mobile, 'synthetic-mobile');
expect(merged.avatar, 'synthetic-avatar');
expect(merged.createTime, DateTime(2026, 10, 10, 8));
expect(merged.discountsList, isEmpty);
expect(merged.discountsUpdateTime, DateTime(2026, 10, 25, 8));
final second = (await legacy.getUserDao.findByUserId('200'))!;
expect(second.id, 50);
expect(second.walletBalance, 500);
expect(second.nickname, '李四');
expect(second.discountsList!.single.extraPer, 2);
expect(second.discountsUpdateTime, DateTime(2026, 10, 26, 8));
final cleared = (await legacy.getUserDao.findByUserId('400'))!;
expect(cleared.id, 70);
expect(cleared.walletBalance, 420);
expect(cleared.walletId, 'wallet-70');
expect(cleared.discountsList, isEmpty);
expect(cleared.discountsUpdateTime, DateTime(2026, 10, 26, 9));
final clearedRow = await legacy.database
.query('user_record', where: 'userId = ?', whereArgs: ['400']);
expect(clearedRow.single['discountsList'], isNull);
expect(
await legacy.database
.query('user_record', where: 'userId = ?', whereArgs: ['300']),
otherBefore);
expect(await legacy.database.query('order_record'), ordersBefore);
final faceAfter = await legacy.database.query('face');
expect(faceAfter, [
{
...faceBefore.single,
'faceModel': '',
'qualityCheckedAt': '',
'qualityReportedAt': null,
}
]);
final columns = await legacy.database.rawQuery('PRAGMA table_info(face)');
expect(columns.map((column) => column['name']),
contains('qualityReportedAt'));
final indexes = await legacy.database
.rawQuery("SELECT name FROM sqlite_master WHERE type = 'index'");
expect(indexes.map((index) => index['name']), containsAll(hotIndexes));
final userIndexes =
await legacy.database.rawQuery('PRAGMA index_list(user_record)');
expect(userIndexes,
contains(containsPair('name', 'index_user_record_userId')));
expect(
userIndexes.singleWhere(
(index) => index['name'] == 'index_user_record_userId')['unique'],
1);
await expectLater(
legacy.getUserDao
.save(UserEntity(userId: '100', nickname: 'duplicate')),
throwsA(isA<sqlite.DatabaseException>()),
);
} finally {
await legacy.close();
}
});
test('已盖章16但同名索引非唯一且列错误时,打开仍能无损修复', () async {
final path = '${directory.path}/stamped-user-cache.db';
var stamped = await $FloorAppDatabase.databaseBuilder(path).build();
await stamped.database
.execute('DROP INDEX IF EXISTS index_user_record_userId');
await stamped.database.execute(
'CREATE INDEX index_user_record_userId ON user_record(nickname)');
await stamped.getUserDao.batchSave([
UserEntity(userId: '100', nickname: '张三', walletBalance: 1000),
UserEntity(userId: '100', nickname: '张三', walletBalance: 700),
]);
await stamped.close();
stamped = await $FloorAppDatabase
.databaseBuilder(path)
.addCallback(Callback(onOpen: DatabaseMigration.repairSchema))
.build();
try {
expect(await stamped.database.getVersion(), 16);
expect(await stamped.getUserDao.findCount('-1'), 1);
expect(
(await stamped.getUserDao.findByUserId('100'))!.walletBalance, 700);
final indexes =
await stamped.database.rawQuery('PRAGMA index_list(user_record)');
expect(
indexes.singleWhere(
(index) => index['name'] == 'index_user_record_userId')['unique'],
1);
expect(
(await stamped.database
.rawQuery('PRAGMA index_info(index_user_record_userId)'))
.map((column) => column['name']),
['userId']);
await DatabaseMigration.repairSchema(stamped.database as sqlite.Database);
expect(await stamped.getUserDao.findCount('-1'), 1);
} finally {
await stamped.close();
}
});
}