520 lines
18 KiB
Dart
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();
|
|
}
|
|
});
|
|
}
|