feat: app 骨架 + 网络层 + 认证(登录/注册)
- flutter create 初始化 + 依赖(riverpod 3/dio/go_router/three_dart 0.0.16) - ApiClient:JWT 拦截器 + 统一响应解包 + 401 登出回调 + multipart 上传 - TokenStorage(shared_preferences)+ AuthNotifier 登录/登出 - 登录页(含注册对话框)+ 4 Tab 主框架 - 6 个单元测试通过(网络层 4 + 认证 2) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:slogan_app/core/auth/auth_provider.dart';
|
||||
import 'package:slogan_app/core/network/api_client.dart';
|
||||
import 'package:slogan_app/core/storage/token_storage.dart';
|
||||
|
||||
import '../network/api_client_test.dart';
|
||||
|
||||
void main() {
|
||||
test('登录成功:token 持久化 + 状态 authenticated', () async {
|
||||
final adapter = MockAdapter();
|
||||
adapter.onRequest = (o) => Response(
|
||||
requestOptions: o,
|
||||
data: {
|
||||
'code': 0,
|
||||
'message': 'OK',
|
||||
'data': {
|
||||
'token': 'jwt-token',
|
||||
'user': {'id': 7, 'name': '测试'}
|
||||
}
|
||||
});
|
||||
final storage = InMemoryTokenStorage();
|
||||
final container = ProviderContainer(overrides: [
|
||||
tokenStorageProvider.overrideWithValue(storage),
|
||||
apiClientProvider.overrideWith((ref) => ApiClient(
|
||||
tokenStorage: storage,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test'))
|
||||
..httpClientAdapter = adapter)),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(authProvider.notifier).login('a', '123456');
|
||||
final state = container.read(authProvider);
|
||||
expect(state.value?.authenticated, true);
|
||||
expect(state.value?.userId, 7);
|
||||
expect(storage.token, 'jwt-token');
|
||||
});
|
||||
|
||||
test('登录失败:状态 error 携带 message', () async {
|
||||
final adapter = MockAdapter();
|
||||
adapter.onRequest = (o) => Response(
|
||||
requestOptions: o,
|
||||
data: {'code': 50, 'message': '账号不存在', 'data': null});
|
||||
final storage = InMemoryTokenStorage();
|
||||
final container = ProviderContainer(overrides: [
|
||||
tokenStorageProvider.overrideWithValue(storage),
|
||||
apiClientProvider.overrideWith((ref) => ApiClient(
|
||||
tokenStorage: storage,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test'))
|
||||
..httpClientAdapter = adapter)),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(authProvider.notifier).login('a', '123456');
|
||||
final state = container.read(authProvider);
|
||||
expect(state.hasError, true);
|
||||
expect(state.error.toString(), contains('账号不存在'));
|
||||
expect(state.value?.authenticated, false);
|
||||
});
|
||||
}
|
||||
|
||||
/// 内存版 TokenStorage(测试用,无需 SharedPreferences 插件)
|
||||
class InMemoryTokenStorage extends TokenStorage {
|
||||
String? _t;
|
||||
|
||||
@override
|
||||
String? get token => _t;
|
||||
|
||||
@override
|
||||
Future<void> load() async {}
|
||||
|
||||
@override
|
||||
Future<void> save(String token) async => _t = token;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _t = null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:slogan_app/core/network/api_client.dart';
|
||||
import 'package:slogan_app/core/network/api_exception.dart';
|
||||
import 'package:slogan_app/core/storage/token_storage.dart';
|
||||
|
||||
/// 可编程 mock 的 Dio adapter
|
||||
class MockAdapter implements HttpClientAdapter {
|
||||
Response Function(RequestOptions options)? onRequest;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options,
|
||||
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
|
||||
final r = onRequest?.call(options);
|
||||
if (r == null) return ResponseBody.fromString('', 500);
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(r.data), r.statusCode ?? 200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType]
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _FakeTokenStorage extends TokenStorage {
|
||||
String? _t;
|
||||
|
||||
_FakeTokenStorage(this._t);
|
||||
|
||||
@override
|
||||
String? get token => _t;
|
||||
|
||||
@override
|
||||
Future<void> load() async {}
|
||||
|
||||
@override
|
||||
Future<void> save(String token) async => _t = token;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _t = null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockAdapter adapter;
|
||||
late ApiClient client;
|
||||
|
||||
setUp(() {
|
||||
adapter = MockAdapter();
|
||||
client = ApiClient(
|
||||
tokenStorage: _FakeTokenStorage('test-token'),
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter);
|
||||
});
|
||||
|
||||
test('成功响应解析 data', () async {
|
||||
adapter.onRequest = (o) => Response(
|
||||
requestOptions: o,
|
||||
data: {'code': 0, 'message': 'OK', 'data': {'id': 1}});
|
||||
final id = await client.post<Map<String, dynamic>>('/x', {});
|
||||
expect(id['id'], 1);
|
||||
});
|
||||
|
||||
test('code != 0 抛 ApiException 带 message', () async {
|
||||
adapter.onRequest = (o) => Response(
|
||||
requestOptions: o,
|
||||
data: {'code': 50, 'message': '衣橱服装不足', 'data': null});
|
||||
expect(
|
||||
() => client.get<Map<String, dynamic>>('/x'),
|
||||
throwsA(isA<ApiException>()
|
||||
.having((e) => e.code, 'code', 50)
|
||||
.having((e) => e.message, 'message', '衣橱服装不足')),
|
||||
);
|
||||
});
|
||||
|
||||
test('请求携带 Bearer token', () async {
|
||||
String? auth;
|
||||
adapter.onRequest = (o) {
|
||||
auth = o.headers['Authorization'];
|
||||
return Response(
|
||||
requestOptions: o, data: {'code': 0, 'message': 'OK', 'data': null});
|
||||
};
|
||||
await client.get<dynamic>('/x');
|
||||
expect(auth, 'Bearer test-token');
|
||||
});
|
||||
|
||||
test('401 触发登出回调', () async {
|
||||
var called = false;
|
||||
client.onUnauthorized = () => called = true;
|
||||
adapter.onRequest = (o) => Response(
|
||||
requestOptions: o,
|
||||
statusCode: 401,
|
||||
data: {'code': 401, 'message': '未登录', 'data': null});
|
||||
try {
|
||||
await client.get<dynamic>('/x');
|
||||
} catch (_) {}
|
||||
expect(called, true);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user