diff --git a/flutter/test/api_mode_flow_test.dart b/flutter/test/api_mode_flow_test.dart new file mode 100644 index 0000000..5598397 --- /dev/null +++ b/flutter/test/api_mode_flow_test.dart @@ -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 _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': [], + 'conditions': [], + 'watchConditions': [], + '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 _pumpApiApp(WidgetTester tester, FakeApi api, Map 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.delayed(const Duration(milliseconds: 300))); + await tester.pump(const Duration(seconds: 4)); + await settle(tester); +} + +/// 가짜 서버 응답(비동기)이 화면에 반영되고, 그 화면이 부른 요청까지 모두 끝날 때까지 +Future _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.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); + }); +}