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:
2026-10-11 18:14:33 +09:00
parent cdd4856300
commit 00660c7d62
14 changed files with 662 additions and 109 deletions
+171
View File
@@ -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());
});
});
}
+108
View File
@@ -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}) {}
}