app scaffold
This commit is contained in:
@@ -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}) {}
|
||||
}
|
||||
Reference in New Issue
Block a user