generated from ttipo/project-base
feat(flutter): 로그인·프로필·탈퇴를 서버에 연결
- 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다. 기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다 - 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤 기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다 - 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다 - 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다 - 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다 - 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다) - 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
|
||||
import 'support/fake_api.dart';
|
||||
|
||||
/// 1×1 PNG — 올리기 테스트용 기기 사진
|
||||
final Uint8List kPngBytes = Uint8List.fromList([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48, 0x44, 0x52, //
|
||||
]);
|
||||
|
||||
Map<String, Object?> _me({String? nickname = '콩이 보호자', String? photoFileId}) => {
|
||||
'id': 'user-1',
|
||||
'nickname': nickname,
|
||||
'photoFileId': photoFileId,
|
||||
'provider': 'kakao',
|
||||
'createdAt': '2026-10-11T00:00:00.000Z',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late SharedPreferences prefs;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
});
|
||||
|
||||
group('ApiAuthRepository', () {
|
||||
ApiAuthRepository repo(FakeApi api) => ApiAuthRepository(
|
||||
dio: api.dio,
|
||||
session: api.session,
|
||||
prefs: prefs,
|
||||
deviceKeys: const FixedDeviceKeyStore('device-key-abcdefghijklmnop'),
|
||||
);
|
||||
|
||||
test('개발용 로그인으로 가입하고 토큰·계정 종류·이름을 저장한다', () async {
|
||||
final api = FakeApi(tokens: null);
|
||||
api.on('POST', '/auth/dev-sessions', (r) {
|
||||
expect(r.json, {'provider': 'naver', 'deviceKey': 'device-key-abcdefghijklmnop'});
|
||||
return ok({
|
||||
'accessToken': 'a1',
|
||||
'refreshToken': 'r1',
|
||||
'accessTokenExpiresIn': 3600,
|
||||
'refreshTokenExpiresAt': '2026-10-25T00:00:00.000Z',
|
||||
'user': _me(nickname: '두부맘')..['provider'] = 'naver',
|
||||
'isNewUser': true,
|
||||
}, status: 201);
|
||||
});
|
||||
final auth = repo(api);
|
||||
|
||||
expect(auth.hasSession, isFalse);
|
||||
await auth.signIn(SocialProvider.naver);
|
||||
|
||||
expect(auth.hasSession, isTrue);
|
||||
expect(api.session.tokens?.accessToken, 'a1');
|
||||
expect(auth.provider, SocialProvider.naver);
|
||||
expect(auth.nickname, '두부맘');
|
||||
});
|
||||
|
||||
test('로그아웃은 리프레시 토큰을 폐기하고 세션을 지운다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('POST', '/auth/token-revocation', (r) {
|
||||
expect(r.json, {'refreshToken': 'refresh'});
|
||||
return noContent;
|
||||
});
|
||||
final auth = repo(api);
|
||||
|
||||
await auth.signOut();
|
||||
|
||||
expect(api.session.hasSession, isFalse);
|
||||
expect(api.sent('POST', '/auth/token-revocation'), hasLength(1));
|
||||
});
|
||||
|
||||
test('폐기 요청이 실패해도 이 기기에서는 로그아웃하고 알림을 띄우지 않는다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('POST', '/auth/token-revocation', (r) => failure('SYS_001', 500));
|
||||
|
||||
await repo(api).signOut();
|
||||
|
||||
expect(api.session.hasSession, isFalse);
|
||||
expect(api.reported, isEmpty);
|
||||
});
|
||||
|
||||
test('탈퇴는 서버에서 회원을 지운 뒤 세션을 지운다 — 실패하면 세션을 그대로 둔다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('DELETE', '/users/me', (r) => failure('SYS_001', 500));
|
||||
final auth = repo(api);
|
||||
|
||||
await expectLater(auth.deleteAccount(), throwsA(isA<DioException>()));
|
||||
expect(api.session.hasSession, isTrue);
|
||||
|
||||
api.on('DELETE', '/users/me', (r) => noContent);
|
||||
await auth.deleteAccount();
|
||||
expect(api.session.hasSession, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiProfileRepository', () {
|
||||
late Directory temp;
|
||||
|
||||
setUp(() => temp = Directory.systemTemp.createTempSync('rup-profile'));
|
||||
tearDown(() => temp.deleteSync(recursive: true));
|
||||
|
||||
ApiProfileRepository repo(FakeApi api) => ApiProfileRepository(
|
||||
dio: api.dio,
|
||||
session: api.session,
|
||||
uploader: FileUploader(api.dio, api.reported.add),
|
||||
prefs: prefs,
|
||||
);
|
||||
|
||||
test('서버의 내 정보를 받아 기기에 두고, 사진은 서버 파일로 가리킨다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('GET', '/users/me', (r) => ok(_me(photoFileId: 'file-1')));
|
||||
final profiles = repo(api);
|
||||
|
||||
final profile = await profiles.fetch();
|
||||
|
||||
expect(profile, const UserProfile(nickname: '콩이 보호자', photoPath: 'server-file:file-1'));
|
||||
// 다음 실행에서는 서버를 기다리지 않고 먼저 보여 준다
|
||||
expect(profiles.load(), profile);
|
||||
});
|
||||
|
||||
test('로그인 전에는 서버를 부르지 않는다', () async {
|
||||
final api = FakeApi(tokens: null);
|
||||
|
||||
expect(await repo(api).fetch(), const UserProfile());
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('기기 사진은 올린 뒤 그 파일 id 로 프로필을 바꾼다', () async {
|
||||
final photo = File('${temp.path}/me.jpg')..writeAsBytesSync(kPngBytes);
|
||||
final api = FakeApi();
|
||||
api.on('POST', '/files', (r) {
|
||||
final form = r.options.data as FormData;
|
||||
final file = form.files.single;
|
||||
expect(file.key, 'file');
|
||||
// 원본 이름 대신 내용에 맞춘 이름·형식으로 보낸다 (확장자가 .jpg 여도 내용은 PNG)
|
||||
expect(file.value.filename, 'photo.png');
|
||||
expect(file.value.contentType.toString(), 'image/png');
|
||||
return ok({'id': 'file-9', 'originalName': 'photo.png', 'mimeType': 'image/png', 'size': 16}, status: 201);
|
||||
});
|
||||
api.on('PATCH', '/users/me', (r) {
|
||||
expect(r.json, {'nickname': '집사', 'photoFileId': 'file-9'});
|
||||
return ok(_me(nickname: '집사', photoFileId: 'file-9'));
|
||||
});
|
||||
|
||||
final saved = await repo(api).save(UserProfile(nickname: '집사', photoPath: photo.path));
|
||||
|
||||
expect(saved.photoPath, 'server-file:file-9');
|
||||
});
|
||||
|
||||
test('로그아웃하면 기기에 둔 프로필을 지운다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('GET', '/users/me', (r) => ok(_me()));
|
||||
final profiles = repo(api);
|
||||
await profiles.fetch();
|
||||
|
||||
await profiles.forget();
|
||||
|
||||
expect(profiles.load(), const UserProfile());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.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});
|
||||
|
||||
/// 받은 요청 — 경로 변수(:id)와 본문을 꺼내 쓰기 쉽게 정리한다
|
||||
class FakeRequest {
|
||||
FakeRequest(this.options, this.params);
|
||||
|
||||
final RequestOptions options;
|
||||
final Map<String, String> params;
|
||||
|
||||
String get method => options.method;
|
||||
String get path => options.path;
|
||||
Map<String, dynamic> get query => options.queryParameters;
|
||||
|
||||
/// JSON 본문 (FormData 면 null)
|
||||
Map<String, dynamic>? get json => options.data is Map<String, dynamic> ? options.data as Map<String, dynamic> : null;
|
||||
}
|
||||
|
||||
typedef FakeHandler = FutureOr<FakeReply> Function(FakeRequest request);
|
||||
|
||||
/// 표준 성공 응답 `{ success: true, data }`
|
||||
FakeReply ok(Object? data, {int status = 200}) => (status: status, body: {'success': true, 'data': data});
|
||||
|
||||
/// 본문 없는 성공 (204)
|
||||
const FakeReply noContent = (status: 204, body: null);
|
||||
|
||||
/// 표준 실패 응답 — 문구는 앱 사전이 정한다
|
||||
FakeReply failure(String code, int status) => (
|
||||
status: status,
|
||||
body: {
|
||||
'success': false,
|
||||
'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'},
|
||||
},
|
||||
);
|
||||
|
||||
/// 목록 응답 `{ items, pageInfo }` 한 페이지
|
||||
Map<String, Object?> page(List<Object?> items, {String? nextCursor, int? total}) => {
|
||||
'items': items,
|
||||
'pageInfo': {'hasNext': nextCursor != null, 'nextCursor': nextCursor, 'total': total ?? items.length},
|
||||
};
|
||||
|
||||
/// 네트워크 없이 서버를 흉내 낸다 — `api.on('GET', '/pets/:id', (r) => ok(...))`
|
||||
class FakeApi implements HttpClientAdapter {
|
||||
FakeApi({AuthTokens? tokens = const AuthTokens(accessToken: 'access', refreshToken: 'refresh')}) {
|
||||
session = SessionStore(MemoryTokenVault(), tokens);
|
||||
client = DioClient(
|
||||
baseUrl: 'http://test/api',
|
||||
session: session,
|
||||
onError: reported.add,
|
||||
httpClientAdapter: this,
|
||||
);
|
||||
}
|
||||
|
||||
late final SessionStore session;
|
||||
late final DioClient client;
|
||||
final List<ApiException> reported = [];
|
||||
final List<FakeRequest> requests = [];
|
||||
final List<(String, RegExp, List<String>, FakeHandler)> _routes = [];
|
||||
|
||||
Dio get dio => client.dio;
|
||||
|
||||
/// 경로 패턴은 `/pets/:id` 처럼 쓴다 — 나중에 등록한 것이 먼저 맞는다
|
||||
void on(String method, String pattern, FakeHandler handler) {
|
||||
final names = <String>[];
|
||||
final regex = RegExp(
|
||||
'^${pattern.replaceAllMapped(RegExp(r':(\w+)'), (m) {
|
||||
names.add(m[1]!);
|
||||
return '([^/]+)';
|
||||
})}\$',
|
||||
);
|
||||
_routes.insert(0, (method, regex, names, handler));
|
||||
}
|
||||
|
||||
/// [method] [path] 로 온 요청들
|
||||
List<FakeRequest> sent(String method, String path) =>
|
||||
requests.where((r) => r.method == method && r.path == path).toList();
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
|
||||
for (final (method, regex, names, handler) in _routes) {
|
||||
final match = regex.firstMatch(options.path);
|
||||
if (method != options.method || match == null) continue;
|
||||
final request = FakeRequest(options, {for (var i = 0; i < names.length; i++) names[i]: match[i + 1]!});
|
||||
requests.add(request);
|
||||
final reply = await handler(request);
|
||||
return ResponseBody.fromString(
|
||||
reply.body == null ? '' : jsonEncode(reply.body),
|
||||
reply.status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
throw StateError('등록하지 않은 요청: ${options.method} ${options.path}');
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
Reference in New Issue
Block a user