405 lines
14 KiB
Dart
405 lines
14 KiB
Dart
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<AccountInfo> getAccount() async {
|
||
calls++;
|
||
return next;
|
||
}
|
||
|
||
@override
|
||
Future<AccountInfo> updateBalance(double initialBalance) async {
|
||
calls++;
|
||
return next;
|
||
}
|
||
}
|
||
|
||
class _FakeBudgetApi extends BudgetApi {
|
||
_FakeBudgetApi(this.next) : super(Dio());
|
||
|
||
BudgetInfo next;
|
||
int calls = 0;
|
||
|
||
@override
|
||
Future<BudgetInfo> getBudget() async {
|
||
calls++;
|
||
return next;
|
||
}
|
||
|
||
@override
|
||
Future<BudgetInfo> setCurrentBudget(double amount) async {
|
||
calls++;
|
||
return next;
|
||
}
|
||
}
|
||
|
||
class _FakeNoteApi extends NoteApi {
|
||
_FakeNoteApi() : super(Dio());
|
||
|
||
int listCalls = 0;
|
||
|
||
@override
|
||
Future<NotePage> 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<List<String>> tags() async => const [];
|
||
|
||
@override
|
||
Future<FolderTree> folderTree() async => FolderTree.empty;
|
||
}
|
||
|
||
class _FakeAiApi extends AiApi {
|
||
_FakeAiApi() : super(Dio());
|
||
|
||
int sessionsCalls = 0;
|
||
|
||
@override
|
||
Future<List<ChatSessionItem>> sessions() async {
|
||
sessionsCalls++;
|
||
return const [
|
||
ChatSessionItem(id: 9, title: '上一次的对话', updateTime: '2026-09-24 10:00'),
|
||
];
|
||
}
|
||
|
||
@override
|
||
Future<List<ChatMessageItem>> 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<void> _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<void> _settle() async {
|
||
for (var i = 0; i < 3; i++) {
|
||
await Future<void>.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: '不清的话登录页会把上一账号连明文密码一起回填');
|
||
});
|
||
}
|