test(flutter): 서버 모드 화면 흐름 테스트

기존 흐름 테스트는 기기 저장 모드로 돈다. 가짜 서버를 붙여 서버 모드에서도
로그인 → 온보딩 → 홈(서버의 아이) 흐름, 세션 만료 시 로그인 화면 복귀(AUTH_002),
로그인 전에는 서버에 묻지 않는 것을 화면 단위로 확인한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:40:36 +09:00
parent b9c351d7cf
commit 373d50576a
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/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/features/auth/data/repositories/device_key_store.dart';
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart';
import 'support/app_harness.dart' show settle;
import 'support/fake_api.dart';
/// 서버에 이미 등록된 아이
Map<String, Object?> _kongJson() => {
'id': 'pet-kong',
'tint': '#FF9B6A',
'name': '콩이',
'species': '강아지',
'breed': '말티즈',
'gender': 'male',
'neutered': true,
'birthDate': '2021-09-14',
'weight': 3.2,
'weightUnit': 'kg',
'photoFileId': null,
'microchip': null,
'bloodType': null,
'vet': null,
'memo': null,
'allergies': <String>[],
'conditions': <String>[],
'watchConditions': <String>[],
'archive': null,
};
/// 서버 하나를 흉내 낸다 — 로그인·내 정보·목록들
void _serve(FakeApi api, {required bool sessionAlive}) {
api.on('POST', '/auth/dev-sessions', (r) => ok({
'accessToken': 'access-1',
'refreshToken': 'refresh-1',
'accessTokenExpiresIn': 3600,
'refreshTokenExpiresAt': '2026-10-25T00:00:00.000Z',
'user': {
'id': 'user-1',
'nickname': '콩이 보호자',
'photoFileId': null,
'provider': 'kakao',
'createdAt': '2026-10-11T00:00:00.000Z',
},
'isNewUser': true,
}, status: 201));
api.on('POST', '/auth/tokens', (r) => failure('AUTH_001', 401));
FakeReply guarded(Object? data) => sessionAlive ? ok(data) : failure('AUTH_001', 401);
api.on('GET', '/users/me', (r) => guarded({
'id': 'user-1',
'nickname': '콩이 보호자',
'photoFileId': null,
'provider': 'kakao',
'createdAt': '2026-10-11T00:00:00.000Z',
}));
api.on('GET', '/pets', (r) => guarded(page([_kongJson()])));
for (final path in [
'/schedule-events',
'/repeat-rules',
'/repeat-rule-completions',
'/diaries',
'/diary-posts',
'/diary-comments',
'/pet-cards',
]) {
api.on('GET', path, (r) => guarded(page(const [])));
}
}
Future<void> _pumpApiApp(WidgetTester tester, FakeApi api, Map<String, Object> saved) async {
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues(saved);
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
dataModeProvider.overrideWithValue(DataMode.api),
sessionStoreProvider.overrideWithValue(api.session),
dioProvider.overrideWithValue(api.dio),
deviceKeyStoreProvider.overrideWithValue(const FixedDeviceKeyStore('test-device-key-000001')),
],
child: const RupApp(),
),
);
// 스플래시 이미지 디코딩 → 2.6초 뒤 다음 화면
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
await tester.pump(const Duration(seconds: 4));
await settle(tester);
}
/// 가짜 서버 응답(비동기)이 화면에 반영되고, 그 화면이 부른 요청까지 모두 끝날 때까지
Future<void> _network(WidgetTester tester, FakeApi api) async {
var seen = -1;
for (var i = 0; i < 10 && seen != api.requests.length; i++) {
seen = api.requests.length;
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await settle(tester);
}
}
void main() {
testWidgets('서버 모드: 로그인하면 토큰을 받고 온보딩을 거쳐 홈에서 서버의 아이를 보여 준다', (tester) async {
final api = FakeApi(tokens: null);
_serve(api, sessionAlive: true);
await _pumpApiApp(tester, api, {});
await tester.tap(find.text('시작하기'));
await settle(tester);
expect(find.byType(LoginPage), findsOneWidget);
await tester.tap(find.text('카카오로 3초만에 시작하기'));
await _network(tester, api);
expect(api.session.tokens?.accessToken, 'access-1');
expect(find.byType(OnboardingPage), findsOneWidget);
for (var i = 0; i < 3; i++) {
await tester.tap(find.byType(OnboardingPage));
await tester.pump(const Duration(milliseconds: 100));
}
await tester.tap(find.text('시작하기'));
await _network(tester, api);
expect(find.byType(RupTabBar), findsOneWidget);
expect(find.text('콩이'), findsWidgets);
expect(api.sent('GET', '/pets'), isNotEmpty);
expect(api.reported, isEmpty);
});
testWidgets('서버가 세션을 끊으면(갱신 거절) 로그인 화면으로 돌아가고 세션 만료로 알린다', (tester) async {
final api = FakeApi(tokens: const AuthTokens(accessToken: 'stale', refreshToken: 'revoked'));
_serve(api, sessionAlive: false);
await _pumpApiApp(tester, api, {'entry.onboardingSeen': true});
await _network(tester, api);
expect(api.session.hasSession, isFalse);
expect(api.session.expired, isTrue);
expect(find.byType(LoginPage), findsOneWidget);
expect(api.reported.map((e) => e.code).toSet(), {'AUTH_002'});
});
testWidgets('로그인 전(세션 없음)에는 서버에 기록을 묻지 않는다', (tester) async {
final api = FakeApi(tokens: null);
_serve(api, sessionAlive: true);
await _pumpApiApp(tester, api, {});
expect(api.requests, isEmpty);
});
}