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,32 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 목록 API 한 번에 읽는 최대 개수 (backend PAGINATION_MAX_LIMIT)
|
||||
const int kApiPageLimit = 100;
|
||||
|
||||
/// 목록을 끝까지 읽는 상한 — 서버가 nextCursor 를 계속 주는 사고로 멈추지 않는 일을 막는다
|
||||
const int _kMaxPages = 1000;
|
||||
|
||||
/// 커서 페이지네이션 목록({items, pageInfo})을 마지막 페이지까지 읽어 이어 붙인다.
|
||||
/// 앱은 지금 전체 기록을 들고 화면에서 거른다 — 기록이 아주 많아지면 기간 조회로 바꾼다.
|
||||
Future<List<T>> fetchAllPages<T>(
|
||||
Dio dio,
|
||||
String path,
|
||||
T Function(Map<String, Object?> json) parse,
|
||||
) async {
|
||||
final items = <T>[];
|
||||
String? cursor;
|
||||
for (var page = 0; page < _kMaxPages; page++) {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
path,
|
||||
queryParameters: {'limit': kApiPageLimit, 'cursor': ?cursor},
|
||||
);
|
||||
final data = response.data!;
|
||||
for (final item in data['items']! as List<Object?>) {
|
||||
items.add(parse(item! as Map<String, Object?>));
|
||||
}
|
||||
final pageInfo = data['pageInfo']! as Map<String, Object?>;
|
||||
cursor = pageInfo['nextCursor'] as String?;
|
||||
if (pageInfo['hasNext'] != true || cursor == null) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -1,49 +1,73 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
|
||||
const String kJwtTokenKey = 'jwt_token';
|
||||
export 'session_store.dart' show kJwtTokenKey, kRefreshTokenKey;
|
||||
|
||||
const Duration _kConnectTimeout = Duration(seconds: 15);
|
||||
const Duration _kReceiveTimeout = Duration(seconds: 15);
|
||||
|
||||
/// 요청 extra — 실패해도 전역 알림을 띄우지 않을 에러 코드 목록 (호출한 쪽이 그 코드를 직접 처리할 때)
|
||||
/// dio.post('/diaries', data: ..., options: Options(extra: {kSilentCodesExtra: ['DIARY_001']}))
|
||||
const String kSilentCodesExtra = 'silentCodes';
|
||||
|
||||
/// 요청 extra — 실패해도 알림을 전혀 띄우지 않는다 (사진 불러오기처럼 화면이 빈칸으로 처리할 때)
|
||||
const String kSilentExtra = 'silent';
|
||||
|
||||
/// 토큰을 갱신해 한 번 다시 보낸 요청 표시 — 같은 요청을 두 번 갱신하지 않는다
|
||||
const String _kRetriedExtra = 'authRetried';
|
||||
|
||||
/// 요청 extra — 갱신하려다 서버에 닿지 못했다 (세션은 그대로 둔다)
|
||||
const String _kRefreshUnreachableExtra = 'refreshUnreachable';
|
||||
|
||||
/// 토큰 갱신 결과
|
||||
enum _RefreshResult { refreshed, rejected, unreachable }
|
||||
|
||||
/// 토큰 갱신 경로 (backend AuthController `POST /auth/tokens`)
|
||||
const String kRefreshPath = '/auth/tokens';
|
||||
|
||||
/// 정규화한 실패를 화면에 알린다 (토스트) — 호출부는 메시지를 새로 만들지 않는다 (.claude/rules/error-codes.md)
|
||||
typedef ApiErrorReporter = void Function(ApiException error);
|
||||
|
||||
/// 앱의 HTTP 클라이언트 — 인터셉터(토큰 첨부·응답 언래핑·토큰 갱신·에러 매핑)가 적용된 [dio] 를 낸다.
|
||||
/// 세션(토큰)은 [SessionStore] 가 들고 있고, 갱신·만료도 그쪽에 반영한다.
|
||||
class DioClient {
|
||||
static final DioClient _instance = DioClient._internal();
|
||||
late Dio _dio;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
|
||||
factory DioClient() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
DioClient._internal() {
|
||||
// 백엔드 글로벌 prefix(/api) 와 일치
|
||||
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
DioClient({
|
||||
required String baseUrl,
|
||||
required SessionStore session,
|
||||
ApiErrorReporter? onError,
|
||||
HttpClientAdapter? httpClientAdapter,
|
||||
}) : _session = session,
|
||||
_onError = onError {
|
||||
final options = BaseOptions(
|
||||
// 백엔드 글로벌 prefix(/api) 와 일치
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: _kConnectTimeout,
|
||||
receiveTimeout: _kReceiveTimeout,
|
||||
));
|
||||
);
|
||||
_dio = Dio(options);
|
||||
// 갱신 전용 — 인터셉터를 두지 않아 갱신 요청이 다시 갱신을 부르지 않는다
|
||||
_authDio = Dio(options);
|
||||
if (httpClientAdapter != null) {
|
||||
_dio.httpClientAdapter = httpClientAdapter;
|
||||
_authDio.httpClientAdapter = httpClientAdapter;
|
||||
}
|
||||
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
|
||||
final token = await _storage.read(key: kJwtTokenKey);
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
onRequest: (options, handler) {
|
||||
// Token Storage 정책: 세션의 액세스 토큰을 헤더에 붙인다 (SecureStorage 는 세션이 들고 있다)
|
||||
final token = _session.tokens?.accessToken;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
return handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
final payload = response.data;
|
||||
|
||||
if (payload is Map<String, dynamic> &&
|
||||
payload.containsKey('success')) {
|
||||
if (payload is Map<String, dynamic> && payload.containsKey('success')) {
|
||||
if (payload['success'] == true) {
|
||||
// Global Response Wrapper: 데이터 Unwrap
|
||||
response.data = payload['data'];
|
||||
@@ -52,14 +76,16 @@ class DioClient {
|
||||
|
||||
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
|
||||
final error = _readErrorObject(payload);
|
||||
final apiException = ApiException.fromCode(
|
||||
error.code ?? 'BIZ_001',
|
||||
traceId: error.traceId,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
_report(response.requestOptions, apiException);
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
error: ApiException.fromCode(
|
||||
error.code ?? 'BIZ_001',
|
||||
traceId: error.traceId,
|
||||
statusCode: response.statusCode,
|
||||
),
|
||||
error: apiException,
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
@@ -67,8 +93,18 @@ class DioClient {
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) async {
|
||||
final apiException = await _toApiException(e);
|
||||
// 액세스 토큰이 만료됐으면 갱신해 한 번 다시 보낸다
|
||||
if (await _canRetryWithFreshToken(e)) {
|
||||
try {
|
||||
return handler.resolve(await _dio.fetch<dynamic>(_withFreshToken(e.requestOptions)));
|
||||
} on DioException catch (retryError) {
|
||||
// 다시 보낸 요청도 이 인터셉터를 지나며 정규화·알림까지 끝났다
|
||||
return handler.next(retryError);
|
||||
}
|
||||
}
|
||||
|
||||
final apiException = await _toApiException(e);
|
||||
_report(e.requestOptions, apiException);
|
||||
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
|
||||
return handler.next(e.copyWith(error: apiException));
|
||||
},
|
||||
@@ -76,10 +112,68 @@ class DioClient {
|
||||
);
|
||||
}
|
||||
|
||||
final SessionStore _session;
|
||||
final ApiErrorReporter? _onError;
|
||||
late final Dio _dio;
|
||||
late final Dio _authDio;
|
||||
|
||||
/// 진행 중인 토큰 갱신 — 동시에 여러 요청이 401 을 받아도 갱신은 한 번만 한다
|
||||
Future<_RefreshResult>? _refreshing;
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// 401 이고, 토큰을 붙여 보낸 요청이며, 아직 다시 보내지 않았으면 — 갱신(또는 이미 갱신된 토큰)으로 다시 보낸다
|
||||
Future<bool> _canRetryWithFreshToken(DioException e) async {
|
||||
final options = e.requestOptions;
|
||||
if (e.response?.statusCode != 401 || options.extra[_kRetriedExtra] == true) return false;
|
||||
final sent = options.headers['Authorization'];
|
||||
final tokens = _session.tokens;
|
||||
if (sent == null || tokens == null) return false;
|
||||
|
||||
// 다른 요청이 이미 갱신했으면 새 토큰으로 다시 보내기만 한다
|
||||
if (sent != 'Bearer ${tokens.accessToken}') return true;
|
||||
|
||||
final result = await _refreshOnce();
|
||||
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = true;
|
||||
return result == _RefreshResult.refreshed;
|
||||
}
|
||||
|
||||
RequestOptions _withFreshToken(RequestOptions options) {
|
||||
options.extra[_kRetriedExtra] = true;
|
||||
final token = _session.tokens?.accessToken;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
return options;
|
||||
}
|
||||
|
||||
Future<_RefreshResult> _refreshOnce() => _refreshing ??= _refresh().whenComplete(() => _refreshing = null);
|
||||
|
||||
/// 리프레시 토큰으로 새 한 쌍을 받는다. 서버가 거절하면(401) 세션을 끊는다 —
|
||||
/// 네트워크 오류면 세션은 그대로 두고 이번 요청만 실패시킨다(다시 로그인할 일이 아니다).
|
||||
Future<_RefreshResult> _refresh() async {
|
||||
final tokens = _session.tokens;
|
||||
if (tokens == null) return _RefreshResult.rejected;
|
||||
try {
|
||||
final response = await _authDio.post<Map<String, dynamic>>(
|
||||
kRefreshPath,
|
||||
data: {'refreshToken': tokens.refreshToken},
|
||||
);
|
||||
final data = response.data?['data'];
|
||||
if (data is! Map<String, dynamic>) return _RefreshResult.rejected;
|
||||
await _session.save(AuthTokens.fromJson(data));
|
||||
return _RefreshResult.refreshed;
|
||||
} on DioException catch (e) {
|
||||
final response = e.response;
|
||||
if (response == null || (response.statusCode ?? 0) >= 500) return _RefreshResult.unreachable;
|
||||
return _RefreshResult.rejected;
|
||||
}
|
||||
}
|
||||
|
||||
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
|
||||
Future<ApiException> _toApiException(DioException e) async {
|
||||
final response = e.response;
|
||||
final existing = e.error;
|
||||
if (existing is ApiException) return existing;
|
||||
|
||||
final response = e.response;
|
||||
if (response == null) {
|
||||
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
|
||||
return ApiException.fromCode('SYS_001');
|
||||
@@ -96,11 +190,16 @@ class DioClient {
|
||||
|
||||
code ??= _resolveCodeByStatus(response.statusCode);
|
||||
|
||||
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
|
||||
// 토큰을 붙여 보냈는데 끝내 401 이면 "세션 만료"(AUTH_002) — 세션을 지우면 진입 상태가 로그인 화면으로 보낸다.
|
||||
// 갱신하려다 서버에 닿지 못했으면 다시 로그인할 일이 아니므로 일시적 오류(SYS_001)로 둔다.
|
||||
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
|
||||
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
|
||||
await _storage.delete(key: kJwtTokenKey);
|
||||
code = 'AUTH_002';
|
||||
if (code == 'AUTH_001' && e.requestOptions.headers['Authorization'] != null) {
|
||||
if (e.requestOptions.extra[_kRefreshUnreachableExtra] == true) {
|
||||
code = 'SYS_001';
|
||||
} else {
|
||||
if (_session.hasSession) await _session.clear(expired: true);
|
||||
code = 'AUTH_002';
|
||||
}
|
||||
}
|
||||
|
||||
return ApiException.fromCode(
|
||||
@@ -110,6 +209,13 @@ class DioClient {
|
||||
);
|
||||
}
|
||||
|
||||
void _report(RequestOptions options, ApiException error) {
|
||||
if (options.extra[kSilentExtra] == true) return;
|
||||
final silentCodes = options.extra[kSilentCodesExtra];
|
||||
if (silentCodes is List && silentCodes.contains(error.code)) return;
|
||||
_onError?.call(error);
|
||||
}
|
||||
|
||||
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
|
||||
String _resolveCodeByStatus(int? statusCode) {
|
||||
switch (statusCode) {
|
||||
@@ -143,6 +249,20 @@ class DioClient {
|
||||
traceId: traceId is String ? traceId : null,
|
||||
);
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
|
||||
/// 같은 에러 코드의 알림이 연달아 뜨지 않게 거른다 (앱을 열 때 여러 목록이 함께 실패하는 경우 등)
|
||||
class ApiErrorThrottle {
|
||||
ApiErrorThrottle({this.window = const Duration(seconds: 3)});
|
||||
|
||||
final Duration window;
|
||||
final Map<String, DateTime> _lastShown = {};
|
||||
|
||||
/// [now] 에 [code] 알림을 띄워도 되는지 — 띄우면 그 시각을 기억한다
|
||||
bool allow(String code, DateTime now) {
|
||||
final last = _lastShown[code];
|
||||
if (last != null && now.difference(last) < window) return false;
|
||||
_lastShown[code] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
|
||||
import 'dio_client.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// 실패를 화면에 알리는 방법 — main() 에서 토스트로 바꿔 끼운다 (기본값은 아무것도 하지 않는다)
|
||||
final apiErrorReporterProvider = Provider<ApiErrorReporter>((ref) => (_) {});
|
||||
|
||||
/// 인터셉터(토큰 첨부·응답 언래핑·토큰 갱신·에러 매핑)가 적용된 클라이언트
|
||||
final dioClientProvider = Provider<DioClient>((ref) {
|
||||
return DioClient(
|
||||
baseUrl: ref.watch(apiBaseUrlProvider),
|
||||
session: ref.watch(sessionStoreProvider),
|
||||
onError: (error) => ref.read(apiErrorReporterProvider)(error),
|
||||
);
|
||||
});
|
||||
|
||||
// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다.
|
||||
// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다.
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
return DioClient().dio;
|
||||
});
|
||||
final dioProvider = Provider<Dio>((ref) => ref.watch(dioClientProvider).dio);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.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/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/utils/image_type.dart';
|
||||
|
||||
/// 파일 업로드 경로 (backend UploadController `POST /files`)
|
||||
const String kFilesPath = '/files';
|
||||
|
||||
/// 기기 사진을 서버에 올린다 — 사진 경로 자리의 값을 서버 파일 id 로 바꾸는 일을 맡는다.
|
||||
/// 같은 사진을 두 번 올리지 않도록, 이번 실행 동안 올린 경로를 기억한다.
|
||||
class FileUploader {
|
||||
FileUploader(this._dio, this._report);
|
||||
|
||||
final Dio _dio;
|
||||
final ApiErrorReporter _report;
|
||||
final Map<String, String> _uploaded = {};
|
||||
|
||||
/// 사진 경로 → 서버 파일 id
|
||||
/// - null → null (사진 없음)
|
||||
/// - 이미 서버 파일 → 그 id
|
||||
/// - 기기 사진 → 올리고 받은 id
|
||||
Future<String?> ensureUploaded(String? path) async {
|
||||
if (path == null) return null;
|
||||
final serverId = serverFileIdOf(path);
|
||||
if (serverId != null) return serverId;
|
||||
|
||||
final known = _uploaded[path];
|
||||
if (known != null) return known;
|
||||
|
||||
final bytes = await XFile(path).readAsBytes();
|
||||
final type = detectImageType(bytes);
|
||||
if (type == null) {
|
||||
// 서버도 거절할 형식 — 보내지 않고 서버의 "형식 오류"(FILE_001)와 똑같이 알린다
|
||||
final error = ApiException.fromCode('FILE_001');
|
||||
_report(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
kFilesPath,
|
||||
data: FormData.fromMap({
|
||||
'file': MultipartFile.fromBytes(
|
||||
bytes,
|
||||
// 원본 이름은 보내지 않는다 — 서버가 저장 이름을 정하고, 확장자만 내용과 맞춘다
|
||||
filename: 'photo.${type.extension}',
|
||||
contentType: DioMediaType.parse(type.mimeType),
|
||||
),
|
||||
}),
|
||||
);
|
||||
final id = response.data!['id']! as String;
|
||||
_uploaded[path] = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
final fileUploaderProvider = Provider<FileUploader>(
|
||||
(ref) => FileUploader(ref.watch(dioProvider), (error) => ref.read(apiErrorReporterProvider)(error)),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
// 사진 경로 표기 — 기기 사진 경로와 서버 파일을 같은 문자열 자리(photoPath 등)에 담는다.
|
||||
|
||||
/// 서버에 올린 파일 표시 — `server-file:<파일 id>`
|
||||
const String kServerFilePrefix = 'server-file:';
|
||||
|
||||
/// 앱에 들어 있는 그림 (명함 기본 배경 등)
|
||||
const String kBundledAssetPrefix = 'assets/';
|
||||
|
||||
/// 서버 파일 id → 사진 경로 자리에 넣을 값
|
||||
String serverFileRef(String fileId) => '$kServerFilePrefix$fileId';
|
||||
|
||||
/// 서버 파일이면 그 id, 아니면 null
|
||||
String? serverFileIdOf(String? path) =>
|
||||
path != null && path.startsWith(kServerFilePrefix) ? path.substring(kServerFilePrefix.length) : null;
|
||||
|
||||
/// 서버 파일 id(또는 null) → 사진 경로 자리에 넣을 값(또는 null)
|
||||
String? serverFileRefOrNull(String? fileId) => fileId == null ? null : serverFileRef(fileId);
|
||||
|
||||
/// 앱에 들어 있는 그림인지
|
||||
bool isBundledAsset(String path) => path.startsWith(kBundledAssetPrefix);
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
|
||||
const String kJwtTokenKey = 'jwt_token';
|
||||
|
||||
/// 리프레시 토큰 저장 키.
|
||||
/// 웹은 HttpOnly 쿠키로 받지만, 모바일 앱에는 쿠키 저장소가 없어 secure storage 에 둔다 (.claude/rules/security.md).
|
||||
const String kRefreshTokenKey = 'refresh_token';
|
||||
|
||||
/// 로그인 토큰 한 쌍 — 액세스(짧게) + 리프레시(한 번 쓰면 새로 받는다)
|
||||
@immutable
|
||||
class AuthTokens {
|
||||
const AuthTokens({required this.accessToken, required this.refreshToken});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
/// 서버 로그인·갱신 응답(data)에서 꺼낸다
|
||||
factory AuthTokens.fromJson(Map<String, Object?> json) => AuthTokens(
|
||||
accessToken: json['accessToken']! as String,
|
||||
refreshToken: json['refreshToken']! as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// 토큰을 기기에 보관하는 곳
|
||||
abstract interface class TokenVault {
|
||||
Future<AuthTokens?> read();
|
||||
|
||||
Future<void> write(AuthTokens tokens);
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// flutter_secure_storage — 운영 앱의 보관소 (SharedPreferences 에 토큰을 두지 않는다)
|
||||
class SecureTokenVault implements TokenVault {
|
||||
const SecureTokenVault([this._storage = const FlutterSecureStorage()]);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<AuthTokens?> read() async {
|
||||
final access = await _storage.read(key: kJwtTokenKey);
|
||||
final refresh = await _storage.read(key: kRefreshTokenKey);
|
||||
if (access == null || refresh == null) return null;
|
||||
return AuthTokens(accessToken: access, refreshToken: refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(AuthTokens tokens) async {
|
||||
await _storage.write(key: kJwtTokenKey, value: tokens.accessToken);
|
||||
await _storage.write(key: kRefreshTokenKey, value: tokens.refreshToken);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: kJwtTokenKey);
|
||||
await _storage.delete(key: kRefreshTokenKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 메모리 보관소 — 테스트·기기 저장 모드
|
||||
class MemoryTokenVault implements TokenVault {
|
||||
AuthTokens? _tokens;
|
||||
|
||||
@override
|
||||
Future<AuthTokens?> read() async => _tokens;
|
||||
|
||||
@override
|
||||
Future<void> write(AuthTokens tokens) async => _tokens = tokens;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _tokens = null;
|
||||
}
|
||||
|
||||
/// 지금 로그인 세션 — 토큰을 메모리에 들고, 바뀌면 기기 보관소에도 쓴다.
|
||||
/// 서버가 세션을 끊으면(갱신 실패) [expired] 가 켜지고 리스너(진입 상태)가 로그인 화면으로 보낸다.
|
||||
class SessionStore extends ChangeNotifier {
|
||||
SessionStore(this._vault, [this._tokens]);
|
||||
|
||||
/// 앱을 열 때 — 첫 화면을 정하기 전에 보관한 토큰을 읽어 둔다
|
||||
static Future<SessionStore> load(TokenVault vault) async => SessionStore(vault, await vault.read());
|
||||
|
||||
final TokenVault _vault;
|
||||
AuthTokens? _tokens;
|
||||
bool _expired = false;
|
||||
|
||||
AuthTokens? get tokens => _tokens;
|
||||
|
||||
bool get hasSession => _tokens != null;
|
||||
|
||||
/// 서버가 세션을 끊어 로그아웃됐는지 (AUTH_002)
|
||||
bool get expired => _expired;
|
||||
|
||||
Future<void> save(AuthTokens tokens) async {
|
||||
_tokens = tokens;
|
||||
_expired = false;
|
||||
notifyListeners();
|
||||
await _vault.write(tokens);
|
||||
}
|
||||
|
||||
/// 세션을 지운다 — [expired] 는 사용자가 아니라 서버가 끊은 경우
|
||||
Future<void> clear({bool expired = false}) async {
|
||||
final had = _tokens != null;
|
||||
_tokens = null;
|
||||
_expired = expired && had;
|
||||
notifyListeners();
|
||||
await _vault.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트).
|
||||
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore(MemoryTokenVault()));
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 기록을 어디에 두는지
|
||||
/// - [api]: 서버 (기본값 — 로그인한 회원의 기록을 어느 기기에서나 본다)
|
||||
/// - [local]: 이 기기 (서버 없이 화면만 볼 때 · 테스트)
|
||||
enum DataMode { api, local }
|
||||
|
||||
/// `.env` 에 API_URL 이 없을 때 — 호스트에서 띄운 개발 서버 (백엔드 글로벌 prefix /api 포함)
|
||||
const String kDefaultApiUrl = 'http://localhost:3000/api';
|
||||
|
||||
/// `.env` 의 DATA_MODE (api | local). `.env` 를 읽지 않았으면(테스트) 기기 저장으로 둔다.
|
||||
DataMode readDataMode() {
|
||||
if (!dotenv.isInitialized) return DataMode.local;
|
||||
return dotenv.maybeGet('DATA_MODE') == DataMode.local.name ? DataMode.local : DataMode.api;
|
||||
}
|
||||
|
||||
/// `.env` 의 API_URL — 안드로이드 에뮬레이터에서 호스트에 붙을 때는 http://10.0.2.2:3000/api
|
||||
String readApiBaseUrl() => (dotenv.isInitialized ? dotenv.maybeGet('API_URL') : null) ?? kDefaultApiUrl;
|
||||
|
||||
final dataModeProvider = Provider<DataMode>((ref) => readDataMode());
|
||||
|
||||
final apiBaseUrlProvider = Provider<String>((ref) => readApiBaseUrl());
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 사진 형식 — 올릴 때 파일 이름의 확장자와 MIME 을 정한다 (서버는 확장자·MIME·내용을 함께 확인한다)
|
||||
enum ImageType {
|
||||
jpeg('jpg', 'image/jpeg'),
|
||||
png('png', 'image/png'),
|
||||
gif('gif', 'image/gif'),
|
||||
webp('webp', 'image/webp');
|
||||
|
||||
const ImageType(this.extension, this.mimeType);
|
||||
|
||||
final String extension;
|
||||
final String mimeType;
|
||||
}
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> prefix, [int offset = 0]) {
|
||||
if (bytes.length < offset + prefix.length) return false;
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (bytes[offset + i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 파일 앞부분(매직 바이트)으로 사진 형식을 알아낸다 — 모르면 null.
|
||||
/// 웹은 사진 경로가 blob 주소라 확장자를 알 수 없어 내용으로 정한다.
|
||||
ImageType? detectImageType(Uint8List bytes) {
|
||||
if (_startsWith(bytes, const [0xFF, 0xD8, 0xFF])) return ImageType.jpeg;
|
||||
if (_startsWith(bytes, const [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) return ImageType.png;
|
||||
if (_startsWith(bytes, 'GIF87a'.codeUnits) || _startsWith(bytes, 'GIF89a'.codeUnits)) return ImageType.gif;
|
||||
if (_startsWith(bytes, 'RIFF'.codeUnits) && _startsWith(bytes, 'WEBP'.codeUnits, 8)) return ImageType.webp;
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:tma_app/shared/widgets/photo_image.dart';
|
||||
|
||||
/// 기기에 저장된 반려동물 사진 — 웹 미리보기에서는 브라우저 임시 주소를 쓴다
|
||||
/// 반려동물·다이어리 사진 — 서버 파일 또는 기기 사진. 읽을 수 없으면 빈 칸으로 둔다
|
||||
class PetPhotoImage extends StatelessWidget {
|
||||
const PetPhotoImage({super.key, required this.path, this.fit = BoxFit.cover});
|
||||
|
||||
@@ -11,12 +9,5 @@ class PetPhotoImage extends StatelessWidget {
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 사진 파일이 지워졌거나 읽을 수 없으면 빈 칸으로 둔다
|
||||
Widget error(BuildContext context, Object e, StackTrace? s) => const SizedBox.shrink();
|
||||
if (kIsWeb) {
|
||||
return Image.network(path, fit: fit, errorBuilder: error);
|
||||
}
|
||||
return Image.file(File(path), fit: fit, errorBuilder: error);
|
||||
}
|
||||
Widget build(BuildContext context) => PhotoImage(path: path, fit: fit);
|
||||
}
|
||||
|
||||
+13
-1
@@ -4,7 +4,11 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/app.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -21,10 +25,18 @@ Future<void> main() async {
|
||||
|
||||
// 진입 화면 결정에 필요한 저장값을 첫 프레임 전에 읽어 둔다
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// 로그인 세션(토큰) — 서버 모드에서만 secure storage 를 쓴다
|
||||
final session = readDataMode() == DataMode.api
|
||||
? await SessionStore.load(const SecureTokenVault())
|
||||
: SessionStore(MemoryTokenVault());
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
sessionStoreProvider.overrideWithValue(session),
|
||||
apiErrorReporterProvider.overrideWith(apiErrorToastReporter),
|
||||
],
|
||||
child: const RupApp(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/shared/widgets/photo_image.dart';
|
||||
|
||||
/// 사람(보호자) 원형 아바타 — 사진이 없으면 기본 사람 아이콘
|
||||
class PersonAvatar extends StatelessWidget {
|
||||
@@ -19,18 +17,14 @@ class PersonAvatar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = photoPath;
|
||||
Widget error(BuildContext c, Object e, StackTrace? s) =>
|
||||
SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover);
|
||||
final placeholder = SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: background, shape: BoxShape.circle),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null
|
||||
? SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover)
|
||||
: kIsWeb
|
||||
? Image.network(photo, fit: BoxFit.cover, errorBuilder: error)
|
||||
: Image.file(File(photo), fit: BoxFit.cover, errorBuilder: error),
|
||||
// 서버 파일·기기 사진 모두 — 읽을 수 없으면 기본 사람 아이콘
|
||||
child: photo == null ? placeholder : PhotoImage(path: photo, fallback: placeholder),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
|
||||
/// 서버에 올린 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다.
|
||||
/// (Image.network 에 토큰을 직접 붙이면 액세스 토큰이 만료된 뒤 사진만 깨진다)
|
||||
@immutable
|
||||
class ServerFileImage extends ImageProvider<ServerFileImage> {
|
||||
const ServerFileImage(this.fileId, this.dio);
|
||||
|
||||
final String fileId;
|
||||
final Dio dio;
|
||||
|
||||
@override
|
||||
Future<ServerFileImage> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ServerFileImage key, ImageDecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
'$kFilesPath/$fileId',
|
||||
// 사진 하나가 안 보여도 알림을 띄우지 않는다 — 화면은 빈칸(기본 그림)으로 둔다
|
||||
options: Options(responseType: ResponseType.bytes, extra: {kSilentExtra: true}),
|
||||
);
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(Uint8List.fromList(response.data!));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
/// 파일 id 가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다
|
||||
@override
|
||||
bool operator ==(Object other) => other is ServerFileImage && other.fileId == fileId;
|
||||
|
||||
@override
|
||||
int get hashCode => fileId.hashCode;
|
||||
}
|
||||
|
||||
/// 사진 한 장 — 서버 파일 · 기기 파일 · 웹 미리보기(브라우저 임시 주소)를 같은 자리에 그린다.
|
||||
/// 읽을 수 없으면 [fallback] 을 그린다.
|
||||
class PhotoImage extends ConsumerWidget {
|
||||
const PhotoImage({
|
||||
super.key,
|
||||
required this.path,
|
||||
this.fit = BoxFit.cover,
|
||||
this.fallback = const SizedBox.shrink(),
|
||||
});
|
||||
|
||||
final String path;
|
||||
final BoxFit fit;
|
||||
final Widget fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget error(BuildContext context, Object e, StackTrace? s) => fallback;
|
||||
|
||||
final fileId = serverFileIdOf(path);
|
||||
if (fileId != null) {
|
||||
return Image(image: ServerFileImage(fileId, ref.watch(dioProvider)), fit: fit, errorBuilder: error);
|
||||
}
|
||||
if (kIsWeb) return Image.network(path, fit: fit, errorBuilder: error);
|
||||
return Image.file(File(path), fit: fit, errorBuilder: error);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
@@ -139,3 +140,13 @@ class _ToastBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// API 실패를 토스트로 알린다 — 문구는 에러 사전에서만 온다. 같은 코드는 잠깐 동안 한 번만 띄운다.
|
||||
/// main() 에서 `apiErrorReporterProvider.overrideWith(apiErrorToastReporter)` 로 끼운다.
|
||||
ApiErrorReporter apiErrorToastReporter(Ref ref) {
|
||||
final throttle = ApiErrorThrottle();
|
||||
return (error) {
|
||||
if (!throttle.allow(error.code, DateTime.now())) return;
|
||||
ref.read(toastProvider.notifier).show(error.message, icon: RupIconName.alert);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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