Files
Aq-Accounting-Flutter/test/user_isolation_test.dart
T
2026-09-30 09:52:41 +08:00

405 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '不清的话登录页会把上一账号连明文密码一起回填');
});
}