generated from ttipo/project-base
feat(flutter): 서버 연동 기반 — 세션·토큰 갱신·사진 업로드·서버 사진 표시
기기 저장소를 API 로 바꾸기 전에, 모든 도메인이 함께 쓸 네트워크 계층을 먼저 갖춘다. - DATA_MODE(api|local): 기본은 서버. .env 를 읽지 않는 테스트·화면 확인은 기기 저장소로 돈다 - SessionStore: 액세스·리프레시 토큰을 secure storage 에 두고 앱을 열 때 미리 읽는다 (모바일에는 쿠키 저장소가 없어 리프레시 토큰도 secure storage 에 둔다) - DioClient: 401 이면 토큰을 한 번 갱신해 다시 보낸다(동시 요청도 갱신은 한 번). 서버가 갱신을 거절하면 세션을 끊고 AUTH_002, 서버에 닿지 못하면 세션은 두고 SYS_001 - 실패 알림은 사전 문구로 토스트 한 곳에서 띄우고 같은 코드는 잠깐 한 번만 — 호출부가 처리하는 코드는 끌 수 있다 - 사진: 경로 자리에 server-file:<id> 를 담고, 기기 사진은 저장할 때 형식을 내용으로 확인해 올린다. 서버 사진은 Dio 로 받아 토큰 갱신을 그대로 쓴다 (PetPhotoImage·PersonAvatar) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
|
||||
/// 요청 하나에 대한 가짜 응답 — 상태 코드와 JSON 본문
|
||||
typedef FakeReply = ({int status, Object? body});
|
||||
|
||||
/// 네트워크 없이 응답을 정해 주는 어댑터. 받은 요청을 기록한다.
|
||||
class FakeAdapter implements HttpClientAdapter {
|
||||
FakeAdapter(this.reply);
|
||||
|
||||
final FutureOr<FakeReply> Function(RequestOptions options) reply;
|
||||
final List<RequestOptions> requests = [];
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
|
||||
requests.add(options);
|
||||
final r = await reply(options);
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(r.body),
|
||||
r.status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
const _oldTokens = AuthTokens(accessToken: 'old-access', refreshToken: 'old-refresh');
|
||||
|
||||
Map<String, Object?> _ok(Object? data) => {'success': true, 'data': data};
|
||||
|
||||
Map<String, Object?> _fail(String code) => {
|
||||
'success': false,
|
||||
'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'},
|
||||
};
|
||||
|
||||
Map<String, Object?> _newTokens([String suffix = '1']) =>
|
||||
_ok({'accessToken': 'new-access-$suffix', 'refreshToken': 'new-refresh-$suffix'});
|
||||
|
||||
String? _auth(RequestOptions o) => o.headers['Authorization'] as String?;
|
||||
|
||||
void main() {
|
||||
late List<ApiException> reported;
|
||||
|
||||
setUp(() => reported = []);
|
||||
|
||||
({DioClient client, FakeAdapter adapter, SessionStore session}) build(
|
||||
FutureOr<FakeReply> Function(RequestOptions o) reply, {
|
||||
AuthTokens? tokens = _oldTokens,
|
||||
}) {
|
||||
final adapter = FakeAdapter(reply);
|
||||
final session = SessionStore(MemoryTokenVault(), tokens);
|
||||
final client = DioClient(
|
||||
baseUrl: 'http://test/api',
|
||||
session: session,
|
||||
onError: reported.add,
|
||||
httpClientAdapter: adapter,
|
||||
);
|
||||
return (client: client, adapter: adapter, session: session);
|
||||
}
|
||||
|
||||
Future<ApiException> errorOf(Future<Object?> request) async {
|
||||
try {
|
||||
await request;
|
||||
} on DioException catch (e) {
|
||||
return e.error! as ApiException;
|
||||
}
|
||||
throw StateError('요청이 실패하지 않았다');
|
||||
}
|
||||
|
||||
test('성공 응답은 data 만 꺼내 주고, 액세스 토큰을 붙여 보낸다', () async {
|
||||
final t = build((o) => (status: 200, body: _ok({'id': 'pet-1'})));
|
||||
|
||||
final res = await t.client.dio.get<Map<String, dynamic>>('/pets/1');
|
||||
|
||||
expect(res.data, {'id': 'pet-1'});
|
||||
expect(_auth(t.adapter.requests.single), 'Bearer old-access');
|
||||
});
|
||||
|
||||
test('실패는 서버의 에러 코드로 정규화되고 문구는 사전에서 온다', () async {
|
||||
final t = build((o) => (status: 404, body: _fail('RES_001')));
|
||||
|
||||
final error = await errorOf(t.client.dio.get<Object?>('/pets/1'));
|
||||
|
||||
expect(error.code, 'RES_001');
|
||||
expect(error.traceId, 'trace-1');
|
||||
expect(error.message, isNot('서버 문구'));
|
||||
expect(reported.map((e) => e.code), ['RES_001']);
|
||||
});
|
||||
|
||||
test('액세스 토큰이 만료되면 갱신하고 같은 요청을 새 토큰으로 다시 보낸다', () async {
|
||||
final t = build((o) {
|
||||
if (o.path == kRefreshPath) return (status: 201, body: _newTokens());
|
||||
return _auth(o) == 'Bearer new-access-1'
|
||||
? (status: 200, body: _ok('done'))
|
||||
: (status: 401, body: _fail('AUTH_001'));
|
||||
});
|
||||
|
||||
final res = await t.client.dio.get<Object?>('/users/me');
|
||||
|
||||
expect(res.data, 'done');
|
||||
expect(t.session.tokens?.accessToken, 'new-access-1');
|
||||
expect(t.session.tokens?.refreshToken, 'new-refresh-1');
|
||||
final refresh = t.adapter.requests.where((o) => o.path == kRefreshPath).single;
|
||||
expect(refresh.data, {'refreshToken': 'old-refresh'});
|
||||
// 갱신 요청에는 만료된 액세스 토큰을 붙이지 않는다
|
||||
expect(_auth(refresh), isNull);
|
||||
expect(reported, isEmpty);
|
||||
});
|
||||
|
||||
test('여러 요청이 함께 401 을 받아도 갱신은 한 번만 한다', () async {
|
||||
var refreshCalls = 0;
|
||||
final t = build((o) async {
|
||||
if (o.path == kRefreshPath) {
|
||||
refreshCalls++;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
return (status: 201, body: _newTokens());
|
||||
}
|
||||
return _auth(o) == 'Bearer new-access-1'
|
||||
? (status: 200, body: _ok(o.path))
|
||||
: (status: 401, body: _fail('AUTH_001'));
|
||||
});
|
||||
|
||||
final results = await Future.wait([
|
||||
t.client.dio.get<Object?>('/pets'),
|
||||
t.client.dio.get<Object?>('/diaries'),
|
||||
t.client.dio.get<Object?>('/pet-cards'),
|
||||
]);
|
||||
|
||||
expect(results.map((r) => r.data), ['/pets', '/diaries', '/pet-cards']);
|
||||
expect(refreshCalls, 1);
|
||||
});
|
||||
|
||||
test('서버가 리프레시 토큰을 거절하면 세션을 끊고 AUTH_002(세션 만료)로 알린다', () async {
|
||||
final t = build((o) => o.path == kRefreshPath
|
||||
? (status: 401, body: _fail('AUTH_001'))
|
||||
: (status: 401, body: _fail('AUTH_001')));
|
||||
var notified = 0;
|
||||
t.session.addListener(() => notified++);
|
||||
|
||||
final error = await errorOf(t.client.dio.get<Object?>('/pets'));
|
||||
|
||||
expect(error.code, 'AUTH_002');
|
||||
expect(t.session.hasSession, isFalse);
|
||||
expect(t.session.expired, isTrue);
|
||||
expect(notified, greaterThan(0));
|
||||
expect(reported.map((e) => e.code), ['AUTH_002']);
|
||||
});
|
||||
|
||||
test('갱신하려다 서버에 닿지 못하면 세션은 두고 일시적 오류(SYS_001)로 알린다', () async {
|
||||
final t = build((o) {
|
||||
if (o.path == kRefreshPath) throw const SocketExceptionLike();
|
||||
return (status: 401, body: _fail('AUTH_001'));
|
||||
});
|
||||
|
||||
final error = await errorOf(t.client.dio.get<Object?>('/pets'));
|
||||
|
||||
expect(error.code, 'SYS_001');
|
||||
expect(t.session.hasSession, isTrue);
|
||||
});
|
||||
|
||||
test('로그인 전(토큰 없음)의 401 은 그대로 AUTH_001 이다', () async {
|
||||
final t = build((o) => (status: 401, body: _fail('AUTH_001')), tokens: null);
|
||||
|
||||
final error = await errorOf(t.client.dio.post<Object?>('/auth/sessions'));
|
||||
|
||||
expect(error.code, 'AUTH_001');
|
||||
expect(t.adapter.requests.where((o) => o.path == kRefreshPath), isEmpty);
|
||||
});
|
||||
|
||||
test('호출한 쪽이 직접 처리하는 코드는 전역 알림을 띄우지 않는다', () async {
|
||||
final t = build((o) => (status: 409, body: _fail('DIARY_001')));
|
||||
|
||||
final error = await errorOf(t.client.dio.post<Object?>(
|
||||
'/diaries',
|
||||
options: Options(extra: {
|
||||
kSilentCodesExtra: ['DIARY_001'],
|
||||
}),
|
||||
));
|
||||
|
||||
expect(error.code, 'DIARY_001');
|
||||
expect(reported, isEmpty);
|
||||
});
|
||||
|
||||
test('네트워크가 끊기면 SYS_001 이다', () async {
|
||||
final t = build((o) => throw const SocketExceptionLike());
|
||||
|
||||
final error = await errorOf(t.client.dio.get<Object?>('/pets'));
|
||||
|
||||
expect(error.code, 'SYS_001');
|
||||
});
|
||||
|
||||
group('ApiErrorThrottle', () {
|
||||
test('같은 코드는 잠깐 동안 한 번만, 다른 코드는 따로 띄운다', () {
|
||||
final throttle = ApiErrorThrottle(window: const Duration(seconds: 3));
|
||||
final t0 = DateTime(2026, 10, 11, 9);
|
||||
|
||||
expect(throttle.allow('SYS_001', t0), isTrue);
|
||||
expect(throttle.allow('SYS_001', t0.add(const Duration(seconds: 1))), isFalse);
|
||||
expect(throttle.allow('RES_001', t0.add(const Duration(seconds: 1))), isTrue);
|
||||
expect(throttle.allow('SYS_001', t0.add(const Duration(seconds: 4))), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 어댑터에서 연결 실패를 흉내 낸다 — Dio 는 응답 없는 DioException 으로 감싼다
|
||||
class SocketExceptionLike implements Exception {
|
||||
const SocketExceptionLike();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/utils/image_type.dart';
|
||||
|
||||
void main() {
|
||||
group('사진 경로 표기', () {
|
||||
test('서버 파일은 server-file: 접두어로 담고 id 를 다시 꺼낸다', () {
|
||||
final ref = serverFileRef('abc');
|
||||
|
||||
expect(ref, 'server-file:abc');
|
||||
expect(serverFileIdOf(ref), 'abc');
|
||||
expect(serverFileRefOrNull(null), isNull);
|
||||
});
|
||||
|
||||
test('기기 사진·앱 그림은 서버 파일이 아니다', () {
|
||||
expect(serverFileIdOf('/data/user/0/app/pet_photos/1.jpg'), isNull);
|
||||
expect(serverFileIdOf('assets/svg/card_bg/bg_1.svg'), isNull);
|
||||
expect(serverFileIdOf(null), isNull);
|
||||
expect(isBundledAsset('assets/svg/card_bg/bg_1.svg'), isTrue);
|
||||
expect(isBundledAsset('/tmp/a.png'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('사진 형식 알아내기', () {
|
||||
test('파일 앞부분으로 형식을 정한다', () {
|
||||
expect(detectImageType(Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0])), ImageType.jpeg);
|
||||
expect(
|
||||
detectImageType(Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0])),
|
||||
ImageType.png,
|
||||
);
|
||||
expect(detectImageType(Uint8List.fromList('GIF89a..'.codeUnits)), ImageType.gif);
|
||||
expect(detectImageType(Uint8List.fromList('RIFF\x00\x00\x00\x00WEBP'.codeUnits)), ImageType.webp);
|
||||
});
|
||||
|
||||
test('사진이 아니면 null — 보내지 않는다', () {
|
||||
expect(detectImageType(Uint8List.fromList('<html>'.codeUnits)), isNull);
|
||||
expect(detectImageType(Uint8List(0)), isNull);
|
||||
});
|
||||
|
||||
test('확장자와 MIME 이 서버 허용 목록과 맞는다', () {
|
||||
expect(ImageType.jpeg.extension, 'jpg');
|
||||
expect(ImageType.jpeg.mimeType, 'image/jpeg');
|
||||
expect(ImageType.webp.mimeType, 'image/webp');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/app.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
|
||||
@@ -43,7 +44,12 @@ Future<SharedPreferences> launchSignedIn(
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs), ...overrides],
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
// 흐름 테스트는 기기 저장소로 돈다 — 서버 저장소는 api_client_test·api_repository_test 가 맡는다
|
||||
dataModeProvider.overrideWithValue(DataMode.local),
|
||||
...overrides,
|
||||
],
|
||||
child: const RupApp(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user