app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
// core_auth 的两个高价值断言:
// 1. 刷新必须串行——并发刷新会触发后端的重放判定,把用户全端踢下线(11)。
// 2. 切店的级联顺序——服务端失败时本地必须原样不动(11 的那张表)。
// 其余都是数据搬运,不写用例。
import 'dart:convert';
import 'dart:typed_data';
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
/// 内存版 secure storage。
class _FakeTokenStorage implements TokenStorage {
TokenPair? saved = const TokenPair(accessToken: 'a', refreshToken: 'r0');
int clearCount = 0;
@override
void Function(Object, StackTrace)? get onReadFailure => null;
@override
Future<String?> readAccessToken() async => saved?.accessToken;
@override
Future<String?> readRefreshToken() async => saved?.refreshToken;
@override
Future<void> save(TokenPair pair) async => saved = pair;
@override
Future<void> clear() async {
clearCount++;
saved = null;
}
}
class _FakeRemote implements SessionRemote {
_FakeRemote({this.stores = const <StoreContext>[]});
List<StoreContext> stores;
bool switchFails = false;
int switchCalls = 0;
@override
Future<UserContext> fetchCurrentUser() async => const UserContext(
userId: 'u1',
employeeId: 'e1',
phone: '13800001111',
roleCode: 'manager',
channel: 'app',
);
@override
Future<List<StoreContext>> fetchAccessibleStores() async => stores;
@override
Future<StoreContext> switchStore(int storeId) async {
switchCalls++;
if (switchFails) throw const NetworkException('网络不通');
return stores.firstWhere((StoreContext s) => s.storeId == storeId);
}
@override
Future<void> revokeSession() async {}
}
class _RecordingStore implements SessionScopedStore {
final List<String> calls = <String>[];
@override
String get debugName => 'recording';
@override
Future<void> onStoreChanged() async => calls.add('store');
@override
Future<void> onSessionEnded() async => calls.add('session');
}
StoreContext _store(int id) =>
StoreContext(storeId: id, storeCode: 'S$id', storeName: '门店$id', orgId: 1);
void main() {
group('TokenRefresher', () {
setUpAll(() {
AppEnv.resetForTest();
AppEnv.install(
const AppEnv(
flavor: AppFlavor.dev,
apiBaseUrl: 'https://example.test',
enableLog: false,
sentryDsn: '',
h5AllowedHosts: <String>{},
),
);
});
test('并发刷新只发一个请求——多发一个就会被后端判定为 refresh token 重放', () async {
int requests = 0;
final Dio dio = Dio()
..httpClientAdapter = _StubAdapter(() {
requests++;
return <String, dynamic>{
'data': <String, dynamic>{'accessToken': 'a1', 'refreshToken': 'r1'},
};
});
final _FakeTokenStorage storage = _FakeTokenStorage();
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: dio);
final List<TokenPair> results = await Future.wait<TokenPair>(<Future<TokenPair>>[
refresher.refresh(),
refresher.refresh(),
refresher.refresh(),
]);
expect(requests, 1);
expect(results.every((TokenPair p) => p.accessToken == 'a1'), isTrue);
// 新 token 必须在返回前就写回,否则进程被杀会留下一个作废的 refreshToken。
expect(storage.saved?.refreshToken, 'r1');
});
test('没有 refresh token 时直接抛 UnauthorizedException,不发请求', () async {
final _FakeTokenStorage storage = _FakeTokenStorage()..saved = null;
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: Dio());
await expectLater(refresher.refresh(), throwsA(isA<UnauthorizedException>()));
});
});
group('SessionNotifier.switchStore', () {
late _FakeTokenStorage storage;
late _FakeRemote remote;
late _RecordingStore scoped;
ProviderContainer makeContainer() {
storage = _FakeTokenStorage();
remote = _FakeRemote(stores: <StoreContext>[_store(1), _store(2)]);
scoped = _RecordingStore();
return ProviderContainer(
overrides: [
tokenStorageProvider.overrideWithValue(storage),
sessionRemoteProvider.overrideWithValue(remote),
sessionScopedStoresProvider.overrideWithValue(<SessionScopedStore>[scoped]),
lastStoreIdProvider.overrideWithValue(1),
],
);
}
test('服务端切换失败时本地状态原样不动,级联清理一次都不能跑', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
final AppSession initial = await container.read(sessionProvider.future);
expect(initial, isA<SessionActive>());
scoped.calls.clear();
remote.switchFails = true;
await expectLater(
container.read(sessionProvider.notifier).switchStore(2),
throwsA(isA<NetworkException>()),
);
// 先清缓存再请求的写法,会让用户停在"门店没变但数据全没了"的状态。
expect(scoped.calls, isEmpty);
expect((container.read(sessionProvider).value! as SessionActive).store.storeId, 1);
});
test('有未完成的写操作时拒绝切店', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
await container.read(sessionProvider.future);
container.read(pendingWritesProvider.notifier).add('submitOrder');
await expectLater(
container.read(sessionProvider.notifier).switchStore(2),
throwsA(isA<PreconditionException>()),
);
expect(remote.switchCalls, 1); // 只有冷启动那次
});
test('登出会清 token 并跑完整的级联清理', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
await container.read(sessionProvider.future);
scoped.calls.clear();
await container.read(sessionProvider.notifier).logout();
expect(scoped.calls, <String>['session']);
expect(storage.saved, isNull);
expect(
container.read(sessionProvider).value,
isA<SessionUnauthenticated>().having(
(SessionUnauthenticated s) => s.reason,
'reason',
LogoutReason.userInitiated,
),
);
});
});
}
/// 固定返回一份 JSON 的 adapter,省掉起 http server。
class _StubAdapter implements HttpClientAdapter {
_StubAdapter(this.body);
final Map<String, dynamic> Function() body;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
final Map<String, dynamic> payload = body();
return ResponseBody.fromString(
jsonEncode(payload),
200,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}