import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; // 包名此刻仍是 nontonki,1-3 改名时会一并替换成 fruitshorts import 'package:nontonki/core/data/remote/api_client.dart'; import 'package:nontonki/core/data/remote/auth_api.dart'; import 'package:nontonki/core/data/services/auth_controller.dart'; import 'package:nontonki/core/data/services/auth_session_store.dart'; /// 用假的 http.Client 覆盖三条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话。 void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); /// http.Response(String) 默认按 Latin-1 编码,中文消息会直接抛 /// "Contains invalid characters" —— 统一用 UTF-8 字节构造。 http.Response jsonResponse(String body, [int statusCode = 200]) { return http.Response.bytes( utf8.encode(body), statusCode, headers: const {'content-type': 'application/json; charset=utf-8'}, ); } String tokenBody({ required String accessToken, required String refreshToken, String uid = 'U1', }) { return jsonEncode({ 'code': 0, 'message': 'ok', 'data': { 'accessToken': accessToken, 'refreshToken': refreshToken, 'expiresIn': 7200, 'user': {'uid': uid, 'nickname': 'Tester', 'avatarUrl': ''}, }, }); } ApiClient clientWith( AuthController Function() auth, { required MockClient httpClient, }) { final store = AuthSessionStore(); return ApiClient( baseUrl: 'http://127.0.0.1:8081', client: httpClient, accessTokenReader: () => auth().accessToken, tokenRefresher: () => auth().refresh(), onUnauthorized: () => auth().clear(), deviceIdReader: store.deviceId, ); } test('register stores session and exposes signed-in state', () async { late AuthController auth; final mock = MockClient((request) async { expect(request.url.path, '/app/auth/register'); expect(request.headers['X-Device-Id'], isNotEmpty); expect(request.headers.containsKey('Authorization'), isFalse); return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); }); final store = AuthSessionStore(); auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store); expect(auth.isSignedIn, isFalse); await auth.register(email: 'a@b.com', password: 'abcd1234'); expect(auth.isSignedIn, isTrue); expect(auth.accessToken, 'a1'); expect(auth.user?.nickname, 'Tester'); // 落盘:新建一个 store 也能读回来 expect((await AuthSessionStore().read())?.refreshToken, 'r1'); }); test('401 triggers refresh once and retries with the new token', () async { var calls = 0; late AuthController auth; final mock = MockClient((request) async { calls++; if (request.url.path == '/app/auth/refresh') { return jsonResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2')); } if (request.url.path != '/app/me') { // 注册等其它请求:直接发第一版令牌 return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); } final token = request.headers['Authorization']; if (token == 'Bearer a1') { return jsonResponse( jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), ); } expect(token, 'Bearer a2'); return jsonResponse(jsonEncode({'code': 0, 'message': 'ok', 'data': {'hit': true}})); }); final store = AuthSessionStore(); auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store); await auth.register(email: 'a@b.com', password: 'abcd1234'); calls = 0; final api = clientWith(() => auth, httpClient: mock); final data = await api.get('/app/me'); expect((data as Map)['hit'], isTrue); expect(calls, 3); // 原请求(401) + 刷新 + 重试 expect(auth.accessToken, 'a2'); }); test('refresh failure clears the session (401 handling)', () async { late AuthController auth; final mock = MockClient((request) async { if (request.url.path == '/app/auth/refresh') { return jsonResponse(jsonEncode({'code': 401, 'message': '登录已过期', 'data': null})); } if (request.url.path == '/app/auth/register') { return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); } return jsonResponse( jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), ); }); final store = AuthSessionStore(); auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store); await auth.register(email: 'a@b.com', password: 'abcd1234'); final api = clientWith(() => auth, httpClient: mock); await expectLater(api.get('/app/me'), throwsA(isA())); expect(auth.isSignedIn, isFalse); expect(await AuthSessionStore().read(), isNull); }); test('signOut clears local session even if the server call fails', () async { late AuthController auth; final mock = MockClient((request) async { if (request.url.path == '/app/auth/logout') { return jsonResponse('{}', 500); } return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); }); final store = AuthSessionStore(); auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store); await auth.register(email: 'a@b.com', password: 'abcd1234'); await auth.signOut(); expect(auth.isSignedIn, isFalse); expect(await AuthSessionStore().read(), isNull); }); }