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 Function(dio.RequestOptions) respond; final requests = []; @override Future fetch(dio.RequestOptions options, Stream? requestStream, Future? 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 _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 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(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(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(); final response = Completer(); 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 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()), ); } 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(); } }); }