import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:qiangbao_accounting/api/account_api.dart'; import 'package:qiangbao_accounting/api/ai_api.dart'; import 'package:qiangbao_accounting/api/budget_api.dart'; import 'package:qiangbao_accounting/api/note_api.dart'; import 'package:qiangbao_accounting/core/storage/local_storage.dart'; import 'package:qiangbao_accounting/models/account.dart'; import 'package:qiangbao_accounting/models/ai.dart'; import 'package:qiangbao_accounting/models/auth_response.dart'; import 'package:qiangbao_accounting/models/budget.dart'; import 'package:qiangbao_accounting/models/note.dart'; import 'package:qiangbao_accounting/providers/account_provider.dart'; import 'package:qiangbao_accounting/providers/ai_provider.dart'; import 'package:qiangbao_accounting/providers/auth_provider.dart'; import 'package:qiangbao_accounting/providers/budget_provider.dart'; import 'package:qiangbao_accounting/providers/note_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 回归测试:**换用户登录后,不能看到上一个用户的数据**。 /// /// ## 背景(真实踩过的 bug) /// /// `ProviderContainer` 在 main.dart 里创建一次、进程存活期内不重建,所以 /// **不带 `autoDispose` 的 provider 会活到 App 结束 —— 登出根本不会清它们**。 /// 新用户登录后读到的还是缓存值、连请求都不发,于是余额 / 本月预算 / 聊天记录 /// 全显示上一个人的。 /// /// 修法是让这批 provider 都 watch `sessionUserProvider`,换人就重建。 /// /// ## 为什么这些用例能守住它 /// /// **刻意走真实的 `authProvider` 链路,不 override `sessionUserProvider`。** /// 一旦 override 掉,`authProvider → sessionUser → 业务 provider` 这条链就断了, /// 把修复改回旧行为测试照样过 —— 那就成了永远绿的假测试。 /// /// 每条用例都验证过「改坏代码会让它失败」,不是凑数的。 // ---------------------------------------------------------------- 测试替身 /// 余额可切换的假账户接口,顺带记调用次数。 /// /// [next] 第一次是「u1 的余额」,第二次换成「u2 的余额」—— 用它区分 /// 界面上显示的到底是重新取回来的、还是上一个用户留下的缓存。 class _FakeAccountApi extends AccountApi { _FakeAccountApi(this.next) : super(Dio()); AccountInfo next; int calls = 0; @override Future getAccount() async { calls++; return next; } @override Future updateBalance(double initialBalance) async { calls++; return next; } } class _FakeBudgetApi extends BudgetApi { _FakeBudgetApi(this.next) : super(Dio()); BudgetInfo next; int calls = 0; @override Future getBudget() async { calls++; return next; } @override Future setCurrentBudget(double amount) async { calls++; return next; } } class _FakeNoteApi extends NoteApi { _FakeNoteApi() : super(Dio()); int listCalls = 0; @override Future list({ int page = 1, int size = 20, String scope = 'all', int? folderId, String sort = 'updated', String? keyword, String? tag, }) async { listCalls++; return NotePage( items: const [ NoteListItem( id: 1, folderId: null, title: 'u1 的笔记', summary: '', tags: [], pinned: false, updateTime: '2026-09-24 10:00', ), NoteListItem( id: 2, folderId: null, title: 'u1 的另一篇', summary: '', tags: [], pinned: false, updateTime: '2026-09-24 09:00', ), ], total: 2, hasMore: false, ); } @override Future> tags() async => const []; @override Future folderTree() async => FolderTree.empty; } class _FakeAiApi extends AiApi { _FakeAiApi() : super(Dio()); int sessionsCalls = 0; @override Future> sessions() async { sessionsCalls++; return const [ ChatSessionItem(id: 9, title: '上一次的对话', updateTime: '2026-09-24 10:00'), ]; } @override Future> messages(int sessionId) async => const [ ChatMessageItem(id: 1, role: 'user', content: '你好'), ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'), ]; } // ---------------------------------------------------------------- 辅助 const _u1Money = 100.0; const _u2Money = 777.0; AccountInfo _accountWith(double balance) => AccountInfo( initialBalance: balance, balance: balance, totalIncome: 0, totalExpense: 0, ); BudgetInfo _budgetWith(double amount) => BudgetInfo( amount: amount, usedAmount: 0, remainingAmount: amount, remainingDaily: 0, ); /// 真登录一次(写本地存储 + 改 auth 状态),不走界面。 Future _login(ProviderContainer c, String username) { return c.read(authProvider.notifier).loginSuccess( AuthResponse(token: 'token-$username', username: username, nickname: username), ); } /// 等 Riverpod 把重建跑完。 /// /// 重建**不是同步的** —— `ref.watch` 的依赖变化只是把 provider 标记为失效, /// 真正的重跑走 `container.scheduler` 的 `Timer(Duration.zero)` /// (见 riverpod 的 scheduler.dart)。所以必须让事件循环转几圈。 Future _settle() async { for (var i = 0; i < 3; i++) { await Future.delayed(Duration.zero); } } void main() { // LocalStorage 是单例,会真的写字。测试里给它一个空实现。 TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); // ------------------------------------------------------------ 账户 / 余额 test('★ 登出后不再展示上一用户的余额,也不再发请求', () async { final api = _FakeAccountApi(_accountWith(_u1Money)); final c = ProviderContainer( overrides: [accountApiProvider.overrideWithValue(api)], ); addTearDown(c.dispose); // 模拟 MinePage 挂在 IndexedStack 上常驻监听。 // 没有监听者的话 Riverpod 不会调度重建,这条测试就测不到东西了。 final sub = c.listen(accountProvider, (_, _) {}); addTearDown(sub.close); await _login(c, 'u1'); await _settle(); expect(c.read(accountProvider).value?.balance, _u1Money); final callsWhileLoggedIn = api.calls; await c.read(authProvider.notifier).logout(); await _settle(); expect(c.read(accountProvider).value?.balance, 0, reason: '登出后不能还挂着上一用户的余额'); expect(api.calls, callsWhileLoggedIn, reason: '未登录不得再发请求 —— 那时候 token 已经清了,' '请求必然 403,会被 ErrorInterceptor 当成「登录已过期」弹假红条'); }); test('★ 换账号后余额是新账号的,不是上一个留下的缓存', () async { final api = _FakeAccountApi(_accountWith(_u1Money)); final c = ProviderContainer( overrides: [accountApiProvider.overrideWithValue(api)], ); addTearDown(c.dispose); final sub = c.listen(accountProvider, (_, _) {}); addTearDown(sub.close); await _login(c, 'u1'); await _settle(); expect(c.read(accountProvider).value?.balance, _u1Money); // 服务端那边 u2 的余额是另一个数 api.next = _accountWith(_u2Money); await c.read(authProvider.notifier).logout(); await _settle(); await _login(c, 'u2'); await _settle(); expect(c.read(accountProvider).value?.balance, _u2Money, reason: '换人必须重新取数,不能拿缓存顶上'); }); // ------------------------------------------------------------ 助手 / 聊天记录 test('★ 登出后聊天记录清空,且新用户还能自动恢复自己的会话', () async { final api = _FakeAiApi(); final c = ProviderContainer(overrides: [aiApiProvider.overrideWithValue(api)]); addTearDown(c.dispose); final sub = c.listen(assistantProvider, (_, _) {}); addTearDown(sub.close); await _login(c, 'u1'); await _settle(); // u1 开了个新对话(会顺手把 _autoRestored 置 true) c.read(assistantProvider.notifier).newChat(personaId: 7); expect(c.read(assistantProvider).personaId, 7); await c.read(authProvider.notifier).logout(); await _settle(); final afterLogout = c.read(assistantProvider); expect(afterLogout.personaId, isNull, reason: '会话锁定的人设属于上一个人'); expect(afterLogout.sessionId, isNull); expect(afterLogout.bubbles, isEmpty); // ★ 这条才是「实例字段必须手动复位」的哨兵。 // Riverpod 重建时**不会新建 Notifier 实例**,所以 _autoRestored 不会自己清。 // 忘了在 build() 里复位的话,下面这次恢复会被上一轮留下的 true 挡掉, // 新用户进助手页永远看不到自己的历史对话。 await _login(c, 'u2'); await _settle(); await c.read(assistantProvider.notifier).restoreLastSessionIfNeeded(); await _settle(); expect(c.read(assistantProvider).sessionId, 9, reason: '换了人之后第一次进助手页,应该能恢复新用户自己的上一次会话'); }); // ------------------------------------------------------------ 笔记列表 test('★ 登出后笔记列表清空,且不再发请求', () async { final api = _FakeNoteApi(); final c = ProviderContainer( overrides: [noteApiProvider.overrideWithValue(api)], ); addTearDown(c.dispose); final sub = c.listen(noteListProvider, (_, _) {}); addTearDown(sub.close); await _login(c, 'u1'); await _settle(); expect(api.listCalls, 1); expect(c.read(noteListProvider).items.length, 2); await c.read(authProvider.notifier).logout(); await _settle(); final afterLogout = c.read(noteListProvider); expect(afterLogout.items, isEmpty, reason: '笔记属于上一个人,不能留在列表里'); expect(afterLogout.loading, isFalse, reason: '登出后的空态不该是「加载中」—— 界面会一直转圈。' '卡住的原因是 build() 里返回了 loading:true 却没发请求,' '没人再把它改回 false'); expect(api.listCalls, 1, reason: '未登录不该再发请求'); }); // ------------------------------------------------------------ 预算 test('★ 换账号后本月预算是新账号的', () async { final api = _FakeBudgetApi(_budgetWith(3000)); final c = ProviderContainer( overrides: [budgetApiProvider.overrideWithValue(api)], ); addTearDown(c.dispose); final sub = c.listen(budgetProvider, (_, _) {}); addTearDown(sub.close); await _login(c, 'u1'); await _settle(); expect(c.read(budgetProvider).value?.amount, 3000); api.next = _budgetWith(500); await c.read(authProvider.notifier).logout(); await _settle(); await _login(c, 'u2'); await _settle(); expect(c.read(budgetProvider).value?.amount, 500, reason: '预算卡必须重新取数,不能显示上一个人的预算'); }); // ------------------------------------------------------------ 一个容易被打回的设计决定 test('★ 改头像/昵称不该把数据清掉(只认用户名,不认整个 AuthState)', () async { // sessionUserProvider 是 select 到「用户名」的,不是 watch 整个 authProvider。 // 因为改头像、改昵称也会换掉 AuthState —— 直接 watch 会把聊天记录、 // 笔记列表、记账草稿全无谓清空。哪天有人图省事把它改成 watch(authProvider), // 这条会拦下来。 final noteApi = _FakeNoteApi(); final c = ProviderContainer( overrides: [noteApiProvider.overrideWithValue(noteApi)], ); addTearDown(c.dispose); final noteSub = c.listen(noteListProvider, (_, _) {}); addTearDown(noteSub.close); final assistantSub = c.listen(assistantProvider, (_, _) {}); addTearDown(assistantSub.close); await _login(c, 'u1'); await _settle(); c.read(assistantProvider.notifier).newChat(personaId: 7); expect(c.read(noteListProvider).items.length, 2); // 用户在「我的」页面换了头像 —— username 没变,只是头像变了 await c.read(authProvider.notifier).updateUserFromResponse( const AuthResponse(token: '', username: 'u1', nickname: 'u1', avatar: 'me.jpg'), ); await _settle(); expect(c.read(assistantProvider).personaId, 7, reason: '换头像不是换人,聊天记录不该被清'); expect(c.read(noteListProvider).items.length, 2, reason: '换头像不是换人,笔记列表不该被清空重取'); expect(noteApi.listCalls, 1, reason: '不该因为改头像就多打一次请求'); }); // ------------------------------------------------------------ 本地存储 test('★ 登出清掉本地存着的用户级数据', () async { final c = ProviderContainer(); addTearDown(c.dispose); await _login(c, 'u1'); // 这些 key 存的是「属于某个账号」的东西: // 前两个是服务端数据行的 id,后三个是个人资料/偏好 await LocalStorage.instance.saveAiConfigId(42); await LocalStorage.instance.saveAiPersonaId(99); await LocalStorage.instance.saveWatermarkSettings({'posX': 0.5}); await LocalStorage.instance.saveWatermarkAddresses(['昆明市公安局']); await LocalStorage.instance.saveRememberedPassword('u1', 'encrypted'); await c.read(authProvider.notifier).logout(); expect(LocalStorage.instance.getAiConfigId(), isNull, reason: 'AI 配置存的是行 id,留着会让新用户指到别人的配置上'); expect(LocalStorage.instance.getAiPersonaId(), isNull, reason: '人设同理'); expect(LocalStorage.instance.getWatermarkSettings(), isNull, reason: '水印上印的是自己的品牌信息'); expect(LocalStorage.instance.getWatermarkAddresses(), isEmpty, reason: '常用地址是用户常去的地点'); expect(LocalStorage.instance.getRememberedAccount(), isNull, reason: '不清的话登录页会把上一账号连明文密码一起回填'); }); }