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:
2026-10-11 18:07:49 +09:00
parent 607ff5c474
commit cdd4856300
16 changed files with 836 additions and 67 deletions
+219
View File
@@ -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();
}
+48
View File
@@ -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');
});
});
}
+7 -1
View File
@@ -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(),
),
);